@bongos/core 1.24.56 → 1.24.58

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 (42) hide show
  1. package/.bongos-core.json +71 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +0 -2
  4. package/clients/bongos-client/index.cjs +0 -2
  5. package/clients/bongos-client/index.d.ts +1 -3
  6. package/clients/bongos-client/index.mjs +0 -2
  7. package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +1 -37
  10. package/docs/api-reference.md +2 -3
  11. package/docs/copy-inventory.md +38 -44
  12. package/docs/copy-registry.json +44 -108
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +4 -4
  15. package/modules/hall-ui/public/deploy-handoff.js +11 -3
  16. package/modules/hall-ui/public/deploy.js +10 -3
  17. package/modules/hall-ui/public/settings-software-update.js +4 -71
  18. package/modules/hall-ui/public/update-pill.js +6 -31
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +12 -0
  22. package/scripts/bongos/css-literal-scan.js +144 -0
  23. package/scripts/bongos/design-generators-fresh.js +32 -0
  24. package/scripts/bongos/fitness-ratchets.js +70 -3
  25. package/scripts/bongos/fitness.js +3 -2
  26. package/scripts/bongos/provision-core-upgrade.js +30 -1
  27. package/scripts/bongos/provision-units.js +18 -0
  28. package/scripts/gds/css-literal-scan.js +7 -0
  29. package/scripts/gds/design-generators-fresh.js +6 -0
  30. package/scripts/hall-preview/server.js +0 -3
  31. package/src/bongos/module-scope-map.js +1 -1
  32. package/src/bongos/routes/core-update.js +36 -76
  33. package/src/bongos/routes/feedback.js +1 -2
  34. package/src/module-api.js +1 -1
  35. package/tests/deploy_handoff.mjs +23 -2
  36. package/tests/deploy_page_projects.mjs +35 -3
  37. package/tests/design_counts_ratchet.mjs +134 -0
  38. package/tests/provision_settings_apply.mjs +52 -0
  39. package/tests/software_update.mjs +35 -5
  40. package/tests/update_pill.mjs +9 -5
  41. package/src/bongos/upgrade-request.js +0 -158
  42. package/tests/upgrade_request.mjs +0 -348
@@ -66,49 +66,12 @@
66
66
  </div>`;
67
67
  }
68
68
 
69
- // "Ask for this upgrade" (task 1004595). A project the hub's operator runs cannot take an
70
- // update itself, so instead of a dead end the page offers one press that reaches the people
71
- // who run its server, and then says that it was asked. The server decides whether it is
72
- // offered (`ask.available`) and remembers the request (`ask.requested`).
73
69
  function longDate(iso) {
74
70
  const d = new Date(iso);
75
71
  if (!Number.isFinite(d.getTime())) return null;
76
72
  try { return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch (e) { return d.toISOString().slice(0, 10); }
77
73
  }
78
74
 
79
- // Asked, and still waiting: for this very version, or recently enough that asking again
80
- // would only be refused.
81
- function askPending(ask, offered) {
82
- const r = ask && ask.requested;
83
- if (!r) return false;
84
- if (r.version === offered) return true;
85
- const again = Date.parse(ask.ask_again_after);
86
- return Number.isFinite(again) && Date.now() < again;
87
- }
88
-
89
- function askHtml(ask, offered, { lead }) {
90
- const team = ask.hub_host ? `the team at ${escapeHtml(ask.hub_host)}` : 'the team that runs this project\'s server';
91
- if (askPending(ask, offered)) {
92
- const r = ask.requested;
93
- const when = longDate(r.requested_at);
94
- return `<div class="su-door su-ask">
95
- <p class="panel-status panel-status--ok" role="status">Upgrade requested${when ? ` on ${escapeHtml(when)}` : ''}${r.by ? ` by @${escapeHtml(r.by)}` : ''}, for version ${mono(r.version)}. ${team.charAt(0).toUpperCase() + team.slice(1)} has your request. This page shows the new version once it is installed.</p>
96
- </div>`;
97
- }
98
- const last = ask.requested && longDate(ask.requested.requested_at);
99
- const before = last ? ` You last asked on ${escapeHtml(last)}, for ${mono(ask.requested.version)}.` : '';
100
- const intro = lead
101
- ? `<p class="panel-hint">This project runs on a server that ${team} looks after, so they install its updates. Ask them for this one and they are told which version you want.${before}</p>`
102
- : `<p class="panel-hint">If that page does not let you in, ask ${team} to install it for you.${before}</p>`;
103
- return `<div class="su-door su-ask">
104
- ${intro}
105
- <div class="row-end">
106
- <button type="button" class="pbtn ${lead ? 'pbtn--save' : 'pbtn--quiet'}" id="su-ask">Ask for this upgrade</button>
107
- <span class="panel-hint" id="su-ask-status" role="status"></span>
108
- </div>
109
- </div>`;
110
- }
111
-
112
75
  // Where the Update button goes, and what it says it will do. Only a plain same-origin path
113
76
  // or an http(s) URL is ever put in an href; anything else draws no button.
114
77
  function doorHtml(data, offered) {
@@ -116,9 +79,9 @@
116
79
  if (!data.can_update) {
117
80
  return '<p class="panel-hint su-door">Only the people allowed to upgrade this project see an Update button here.</p>';
118
81
  }
119
- const ask = data.ask || {};
82
+ // No request-the-upgrade button any more (task 1005128): a project the hub hosts always has
83
+ // the hub's door, so `cli` is only ever a server its owner runs themselves.
120
84
  if (door.kind === 'cli') {
121
- if (ask.available) return askHtml(ask, offered, { lead: true });
122
85
  return '<p class="panel-hint su-door">This project is upgraded on the server that runs it: <code>bongos upgrade</code> there takes this version, with the same health check and rollback.</p>';
123
86
  }
124
87
  const url = String(door.url || '');
@@ -134,7 +97,7 @@
134
97
  return `<div class="row-end su-door">
135
98
  <a class="pbtn pbtn--save" id="su-update" href="${escapeHtml(url)}">Update to ${escapeHtml(offered)}</a>
136
99
  <span class="panel-hint">Takes a few minutes. The project restarts once, checks itself, and goes back to the version it runs now if it does not come back healthy.${where ? ` A small window from ${escapeHtml(where)} opens for a moment to start it.` : ''}</span>
137
- </div>${door.kind === 'hub' && ask.available ? askHtml(ask, offered, { lead: false }) : ''}`;
100
+ </div>`;
138
101
  }
139
102
 
140
103
  // One entry: its text, and — only in the hall whose ledger the ids belong to, which is
@@ -223,8 +186,7 @@
223
186
  html += row('Newest for this project', `${mono(u.running)} — this project is up to date.`, '<span class="su-pill su-pill--ok">Up to date</span>');
224
187
  } else {
225
188
  const when = ago(u.offered_published_at);
226
- const pending = data.can_update && data.ask && data.ask.available && askPending(data.ask, u.offered);
227
- html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${onHold ? 'On hold' : pending ? 'Requested' : 'Update ready'}</span>`);
189
+ html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${onHold ? 'On hold' : 'Update ready'}</span>`);
228
190
  // No button to take again the very version that was just rolled back here (task 1004730).
229
191
  if (!onHold) html += doorHtml(data, u.offered);
230
192
  html += whatsNewHtml(u.whats_new, (data.door || {}).kind === 'local');
@@ -237,9 +199,6 @@
237
199
  body.innerHTML = html + historyHtml(data.history);
238
200
  if (note) note.textContent = notes.join(' ');
239
201
 
240
- const askBtn = byId('su-ask');
241
- if (askBtn) askBtn.addEventListener('click', () => askForUpgrade(data, askBtn));
242
-
243
202
  // The same press as the pill's Update now; only when it took the update in place is the
244
203
  // link's own navigation stopped.
245
204
  const updateBtn = byId('su-update');
@@ -259,32 +218,6 @@
259
218
  }
260
219
  }
261
220
 
262
- // One press: the server sends the request and remembers it; the panel redraws from its
263
- // answer. "Asked recently" carries the stored request, so it redraws as pending too.
264
- async function askForUpgrade(data, btn) {
265
- const status = byId('su-ask-status');
266
- btn.disabled = true;
267
- btn.textContent = 'Asking…';
268
- if (status) status.textContent = '';
269
- try {
270
- const res = await api.request('POST', `${API}/software-update/request`, { body: {} });
271
- const err = (res.data && res.data.error) || {};
272
- const request = (res.data && res.data.request) || (err.details && err.details.request);
273
- if ((res.ok || res.status === 429) && request) {
274
- const again = new Date(Date.parse(request.requested_at) + 24 * 3600 * 1000).toISOString();
275
- render({ ...data, ask: { ...data.ask, requested: request, ask_again_after: again } });
276
- return;
277
- }
278
- // Every refusal the server sends carries a sentence; a bare code is only ever a
279
- // proxy or a crash, so the status stands in for it.
280
- throw new Error(err.message && err.message !== err.code ? err.message : `HTTP ${res.status}`);
281
- } catch (e) {
282
- btn.disabled = false;
283
- btn.textContent = 'Ask for this upgrade';
284
- if (status) status.textContent = `Could not send the request: ${String(e.message || 'unknown error').replace(/\.$/, '')}.`;
285
- }
286
- }
287
-
288
221
  async function load() {
289
222
  if (asked) return;
290
223
  asked = true;
@@ -23,7 +23,6 @@
23
23
  // with &handoff=1, this page tells it the version over postMessage, and the hub
24
24
  // posts the move with the owner's OWN hub session (deploy-handoff.js). This
25
25
  // project's server gains no capability; the only thing that crosses is a version.
26
- // ask a hosted project the hub's operator runs: "Ask for this update" (task 1004595).
27
26
  // cli a self-hosted instance: no button; the card names the command.
28
27
  //
29
28
  // PROGRESS IS READ, NEVER GUESSED (deploy.js's rule). The page watches its own public
@@ -41,9 +40,10 @@
41
40
  var RUN_KEY = 'bongos.update.run';
42
41
  var MSG = 'bongos-update'; // the tag deploy-handoff.js answers to; both sides check it
43
42
  var VERSION_RE = /^\d+\.\d+\.\d+$/;
44
- // A hub door as the server words it (core-update.js HUB_UPGRADE_URL_RE's shape): an
45
- // origin and /deploy?project=<slug>, nothing else. Anything else draws no button.
46
- var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?project=[A-Za-z0-9._%-]+$/;
43
+ // A hub door as the server words it (core-update.js updateDoor): an origin and
44
+ // /deploy?project=<slug>, or /deploy?host=<address> for a hosted project whose server never
45
+ // learned its slug (task 1005128). Anything else draws no button.
46
+ var HUB_DOOR_RE = /^(https?:\/\/[^\s"'<>/]+)\/deploy\?(?:project|host)=[A-Za-z0-9._%-]+$/;
47
47
  var SETTINGS_HREF = '/settings#software-update';
48
48
  var HIGHLIGHTS = 3;
49
49
  var LINE_MAX = 120;
@@ -101,14 +101,13 @@
101
101
  function doorPlan(sw) {
102
102
  if (!sw || sw.can_update !== true) return { kind: 'none' };
103
103
  var door = sw.door || {};
104
- var ask = sw.ask || {};
105
104
  if (door.kind === 'local' && door.url === '/deploy') return { kind: 'local' };
106
105
  if (door.kind === 'hub') {
107
106
  var m = HUB_DOOR_RE.exec(String(door.url || ''));
108
- if (!m) return ask.available ? { kind: 'ask' } : { kind: 'none' };
107
+ if (!m) return { kind: 'none' };
109
108
  return { kind: 'hub', url: door.url, origin: m[1] };
110
109
  }
111
- if (door.kind === 'cli') return ask.available ? { kind: 'ask' } : { kind: 'cli' };
110
+ if (door.kind === 'cli') return { kind: 'cli' };
112
111
  return { kind: 'none' };
113
112
  }
114
113
 
@@ -263,13 +262,6 @@
263
262
  note = '<p class="upd-card__note">Takes a few minutes. The project restarts once, and goes back to the version it runs now on its own if the new one is not healthy.' +
264
263
  (host ? ' A small window from ' + esc(host) + ' opens for a moment to start it.' : '') + '</p>';
265
264
  action = '<button type="button" class="upd-btn upd-btn--go" id="upd-go">Update now</button>';
266
- } else if (plan.kind === 'ask') {
267
- var ask = state.sw.ask || {};
268
- var team = ask.hub_host ? 'the team at ' + ask.hub_host : 'the team that runs this project\'s server';
269
- var asked = ask.requested && ask.requested.version === state.offered;
270
- note = '<p class="upd-card__note">' + esc(asked ? 'You asked ' + team + ' for this update. This shows the new version once it is installed.'
271
- : 'This project runs on a server that ' + team + ' looks after, so they install its updates.') + '</p>';
272
- if (!asked) action = '<button type="button" class="upd-btn upd-btn--go" id="upd-ask">Ask for this update</button>';
273
265
  } else if (plan.kind === 'cli') {
274
266
  note = '<p class="upd-card__note">This project is updated on the server that runs it: <code>bongos upgrade</code> there takes this version, and puts the old one back if it fails.</p>';
275
267
  } else {
@@ -294,7 +286,6 @@
294
286
  var on = function (id, fn) { var el = c.querySelector('#' + id); if (el) el.addEventListener('click', fn); };
295
287
  on('upd-close', closeCard);
296
288
  on('upd-go', function () { updateNow(); });
297
- on('upd-ask', function (e) { askForIt(e.currentTarget); });
298
289
  on('upd-reload', reload);
299
290
  on('upd-open', function () { updateNow(); });
300
291
  }
@@ -565,22 +556,6 @@
565
556
  if (root.location && root.location.reload) root.location.reload();
566
557
  }
567
558
 
568
- // "Ask for this update" (task 1004595): one press reaches the people who run the server.
569
- function askForIt(btn) {
570
- btn.disabled = true;
571
- btn.textContent = 'Asking…';
572
- state.api.request('POST', '/api/bongos/software-update/request', { body: {} }).then(function (res) {
573
- var data = (res && res.data) || {};
574
- var request = data.request || (data.error && data.error.details && data.error.details.request);
575
- if (!((res.ok || res.status === 429) && request)) throw new Error(errorOf(res, 'Could not send the request.'));
576
- state.sw.ask = Object.assign({}, state.sw.ask, { requested: request });
577
- drawCard();
578
- }).then(null, function (e) {
579
- state.error = String((e && e.message) || 'Could not send the request.');
580
- drawCard();
581
- });
582
- }
583
-
584
559
  /** shell.js hands over GET /core-update's answer; the pill decides what, if anything, to show. */
585
560
  function mount(data, opts) {
586
561
  state.api = opts && opts.api;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.56",
3
+ "version": "1.24.58",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.56",
9
+ "version": "1.24.58",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.56",
3
+ "version": "1.24.58",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11307,5 +11307,17 @@
11307
11307
  "id": "1005087",
11308
11308
  "text": "The Studio, the thinking sky, the tweak editor, the city, approvals, the atlas and the welcome screen now take their colours and fonts from the project's own branding, so a re-branded project sees them in its own colours; the"
11309
11309
  }
11310
+ ],
11311
+ "1.24.57": [
11312
+ {
11313
+ "id": "1005093",
11314
+ "text": "Stylesheets can no longer gain new typed-in colours, text sizes, corners, shadows or breakpoints without the build failing, and the public design reference must be regenerated whenever its sources change."
11315
+ }
11316
+ ],
11317
+ "1.24.58": [
11318
+ {
11319
+ "id": "1005128",
11320
+ "text": "A project's owner now takes a new Bongos version with one Update button; the \"ask the team for this upgrade\" step is gone for every hosted project."
11321
+ }
11310
11322
  ]
11311
11323
  }
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // css-literal-scan.js — the detector behind the per-stylesheet DESIGN COUNTS
4
+ // ratchet in fitness-ratchets.js (task 1005093, goal 1000138 "one Cloud Bongos
5
+ // design system"; DESIGN.md, the Fifteen Rule and the --ds-* scales).
6
+ //
7
+ // WHAT IT COUNTS, per tracked stylesheet, in five kinds:
8
+ // colours — every typed-in colour: a hex (#abc, #aabbcc, #aabbccdd) or a
9
+ // colour function (rgb/rgba/hsl/hsla/hwb/lab/lch/oklab/oklch).
10
+ // Counted per OCCURRENCE. The one literal the Fifteen Rule allows
11
+ // — black or white with alpha as a scrim/shadow/halo
12
+ // (rgba(0,0,0,.4), rgb(255 255 255 / .2)) — is not counted.
13
+ // Named colours (red, white…) are not detected.
14
+ // font_sizes — font-size / font declarations whose value carries a typed-in
15
+ // non-zero length (px, rem, em, pt, %, vw…) instead of a token.
16
+ // radii — *-radius declarations with a typed-in non-zero length.
17
+ // shadows — box-shadow / text-shadow declarations that are not none/inherit
18
+ // and carry a typed-in length or colour.
19
+ // breakpoints — @media / @container rules with a typed-in length (a custom
20
+ // property cannot be used in a media query, so these never become
21
+ // tokens; the count stops the set of breakpoints sprawling).
22
+ // Sizes, radii, shadows and breakpoints are counted per DECLARATION / RULE.
23
+ //
24
+ // WHAT IT DOES NOT COUNT. A custom-property declaration (`--x: #123`) is a token
25
+ // DEFINITION — tokens.css and the hall's token block are exactly where literals
26
+ // belong — so its value is skipped. Page-private palettes are guarded separately
27
+ // (tests/hall_private_palettes.mjs). Custom-property NAMES are removed from a
28
+ // value before it is read (so --ds-space-12 is not a "12"), and url(...) bodies
29
+ // are emptied (a data URI is not a colour). A literal inside a var() fallback
30
+ // DOES count: a fallback is a typed-in value.
31
+ //
32
+ // Scope: every tracked *.css outside docs/ (the design mocks there are not
33
+ // served), node_modules and vendor/.
34
+ //
35
+ // Print the counts: node scripts/bongos/css-literal-scan.js [--all]
36
+
37
+ const path = require('path');
38
+ const fs = require('fs');
39
+ const { execFileSync } = require('child_process');
40
+
41
+ const ROOT = path.resolve(__dirname, '..', '..');
42
+ const KINDS = ['colours', 'font_sizes', 'radii', 'shadows', 'breakpoints'];
43
+
44
+ const EXCLUDE_RE = /^docs\/|(^|\/)(node_modules|vendor)\//;
45
+ const COMMENT_RE = /\/\*[\s\S]*?\*\//g;
46
+ const URL_RE = /url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\)/gi;
47
+ const VAR_NAME_RE = /--[\w-]+/g;
48
+ // A declaration: a property, a colon, and a value that ends at ; or } (a selector
49
+ // such as `a:hover {` ends at `{`, so it never matches).
50
+ const DECL_RE = /(?:^|[{;\s])(-?-?[a-zA-Z][\w-]*)\s*:\s*([^;{}]+)(?=[;}])/g;
51
+ const AT_RULE_RE = /@(media|container)\b([^{;]*)\{/g;
52
+ const HEX_RE = /#[0-9a-fA-F]{3,8}(?![\w-])/g;
53
+ const COLOUR_FN_RE = /(?<![\w-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(([^()]*)\)/gi;
54
+ const LENGTH_RE = /(?<![\w.#-])(\d*\.?\d+)(px|rem|em|pt|vw|vh|vmin|vmax|ch|ex|%)(?![\w%])/g;
55
+ const MEDIA_LENGTH_RE = /(?<![\w.-])\d*\.?\d+(px|em|rem)(?![\w])/;
56
+ const NO_SHADOW_RE = /^\s*(none|inherit|initial|unset|revert)\s*(!important)?\s*$/i;
57
+
58
+ /** True for black or white with alpha — the Fifteen Rule's one allowed literal. */
59
+ function isScrim(args) {
60
+ const parts = args.split(/[\s,/]+/).filter(Boolean);
61
+ if (parts.length !== 4) return false;
62
+ const rgb = parts.slice(0, 3).map((p) => p.toLowerCase());
63
+ const black = rgb.every((p) => p === '0' || p === '0%');
64
+ const white = rgb.every((p) => p === '255' || p === '100%');
65
+ return black || white;
66
+ }
67
+
68
+ function colourCount(value) {
69
+ let n = (value.match(HEX_RE) || []).length;
70
+ COLOUR_FN_RE.lastIndex = 0;
71
+ let m;
72
+ while ((m = COLOUR_FN_RE.exec(value))) {
73
+ const fn = m[0].slice(0, m[0].indexOf('(')).toLowerCase();
74
+ if ((fn === 'rgb' || fn === 'rgba') && isScrim(m[1])) continue;
75
+ n += 1;
76
+ }
77
+ return n;
78
+ }
79
+
80
+ function hasLength(value) {
81
+ LENGTH_RE.lastIndex = 0;
82
+ let m;
83
+ while ((m = LENGTH_RE.exec(value))) if (Number(m[1]) !== 0) return true;
84
+ return false;
85
+ }
86
+
87
+ /** The five counts for one stylesheet's text. */
88
+ function countSheet(text) {
89
+ const counts = Object.fromEntries(KINDS.map((k) => [k, 0]));
90
+ const css = String(text).replace(COMMENT_RE, ' ').replace(URL_RE, 'url()');
91
+ AT_RULE_RE.lastIndex = 0;
92
+ let m;
93
+ while ((m = AT_RULE_RE.exec(css))) if (MEDIA_LENGTH_RE.test(m[2])) counts.breakpoints += 1;
94
+ DECL_RE.lastIndex = 0;
95
+ while ((m = DECL_RE.exec(css))) {
96
+ const prop = m[1].toLowerCase();
97
+ if (prop.startsWith('--')) continue; // a token definition
98
+ const value = m[2].replace(VAR_NAME_RE, ' ');
99
+ counts.colours += colourCount(value);
100
+ if ((prop === 'font-size' || prop === 'font') && hasLength(value)) counts.font_sizes += 1;
101
+ else if (prop.endsWith('radius') && hasLength(value)) counts.radii += 1;
102
+ else if ((prop === 'box-shadow' || prop === 'text-shadow') && !NO_SHADOW_RE.test(value)
103
+ && (hasLength(value) || colourCount(value) > 0)) counts.shadows += 1;
104
+ }
105
+ return counts;
106
+ }
107
+
108
+ function trackedStylesheets(root = ROOT) {
109
+ const out = execFileSync('git', ['ls-files', '-z', '--', '*.css'], { cwd: root, encoding: 'utf8' });
110
+ return out.split('\0').filter((f) => f && !EXCLUDE_RE.test(f)).sort();
111
+ }
112
+
113
+ /**
114
+ * Counts for every tracked stylesheet: { sheets: { [path]: counts }, scanned }.
115
+ * Sheets whose five counts are all zero are omitted (a missing sheet reads as zero).
116
+ */
117
+ function scanCssLiterals({ root = ROOT, files = null, read = null } = {}) {
118
+ const list = files || trackedStylesheets(root);
119
+ const reader = read || ((f) => fs.readFileSync(path.join(root, f), 'utf8'));
120
+ const sheets = {};
121
+ let scanned = 0;
122
+ for (const f of list) {
123
+ let text;
124
+ try { text = reader(f); } catch { continue; }
125
+ scanned += 1;
126
+ const c = countSheet(text);
127
+ if (KINDS.some((k) => c[k] > 0)) sheets[f] = c;
128
+ }
129
+ return { sheets, scanned };
130
+ }
131
+
132
+ if (require.main === module) {
133
+ const { sheets, scanned } = scanCssLiterals();
134
+ const totals = Object.fromEntries(KINDS.map((k) => [k, 0]));
135
+ for (const [f, c] of Object.entries(sheets)) {
136
+ for (const k of KINDS) totals[k] += c[k];
137
+ if (process.argv.includes('--all') || KINDS.some((k) => c[k] > 0)) {
138
+ process.stdout.write(`${f} ${KINDS.map((k) => `${k}=${c[k]}`).join(' ')}\n`);
139
+ }
140
+ }
141
+ process.stdout.write(`\n${scanned} stylesheet(s) scanned · totals ${KINDS.map((k) => `${k}=${totals[k]}`).join(' ')}\n`);
142
+ }
143
+
144
+ module.exports = { scanCssLiterals, countSheet, KINDS };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ // design-generators-fresh.js — the design system's generated files are fresh
3
+ // (task 1005093, goal 1000138). Part of fitness.js's "generated artifacts fresh"
4
+ // check; kept in its own file because fitness.js sits at the 1,500-line budget.
5
+ //
6
+ // Both generated files read the shared token sheet (modules/ui-design/public/tokens.css) and the brand pack; the public
7
+ // reference also reads the hall's sheets and DESIGN.md. A stylesheet edit without a
8
+ // regeneration used to surface only as a failing unit test; this names it in fitness.
9
+ // Skipped when the ui-design module is not in the checkout. `root` is injectable for
10
+ // tests/design_counts_ratchet.mjs, which runs it against a copied tree.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execFileSync } = require('child_process');
15
+
16
+ const { REPO_ROOT } = require('./fitness-lib.js');
17
+
18
+ function checkDesignGeneratorsFresh(root = REPO_ROOT) {
19
+ const violations = [];
20
+ for (const [script, out, task] of [
21
+ ['gen-design-tokens.js', 'config/design-tokens.neutral.json', '1005082'],
22
+ ['gen-design-reference.js', 'modules/ui-design/public/brand.html (the public /brand page)', '1005083'],
23
+ ]) {
24
+ const gen = path.join(root, 'modules', 'ui-design', 'scripts', script);
25
+ if (!fs.existsSync(gen)) continue;
26
+ try { execFileSync(process.execPath, [gen, '--check'], { cwd: root, stdio: 'pipe' }); }
27
+ catch { violations.push(`${out} is STALE — a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md) changed without a regeneration. Run \`node modules/ui-design/scripts/${script}\` and commit (task ${task}).`); }
28
+ }
29
+ return violations;
30
+ }
31
+
32
+ module.exports = { checkDesignGeneratorsFresh };
@@ -27,6 +27,13 @@
27
27
  // docs-entropy scanner's three counts, reused as a library
28
28
  // source_text_only_tests — test files that only read source text and match
29
29
  // its wording, never running the code (task 1004868)
30
+ // design counts — PER STYLESHEET, the typed-in colours, font sizes, radii,
31
+ // shadows and breakpoints (task 1005093, goal 1000138; detector:
32
+ // css-literal-scan.js). Kept in their own `design_counts` object rather than
33
+ // `metrics` — ~60 sheets × 5 kinds would drown the metric line — but with the
34
+ // same verdict: higher than baseline fails, lower nudges a ratchet-down. A
35
+ // sheet with no baseline row has a baseline of zero, so a NEW sheet starts on
36
+ // the --ds-* tokens.
30
37
  //
31
38
  // The knip dead-code ratchet needs node_modules + ~seconds, so it is NOT part
32
39
  // of the in-suite check: CI runs `node scripts/bongos/fitness-ratchets.js --knip`
@@ -535,6 +542,46 @@ function skillListingMetrics() {
535
542
  }
536
543
  }
537
544
 
545
+ // ---------- design counts (task 1005093) ----------
546
+
547
+ const DESIGN_HINT = ' — use a --ds-* scale token (modules/ui-design/public/tokens.css) or one of the fifteen colour '
548
+ + 'tokens instead of a typed-in value (DESIGN.md, the Fifteen Rule). Run '
549
+ + '`node scripts/bongos/css-literal-scan.js` to see every sheet\'s counts and the rule.';
550
+
551
+ // Current per-sheet counts, or null when the scan found no stylesheet at all —
552
+ // a collapsed enumeration is a scan defect, never a flattering all-zero tree.
553
+ function designCounts() {
554
+ const { scanCssLiterals } = require('./css-literal-scan.js');
555
+ const { sheets, scanned } = scanCssLiterals();
556
+ if (!scanned) {
557
+ process.stderr.write('fitness-ratchets: design counts NOT MEASURED — the stylesheet scan enumerated ZERO files, which is a scan defect, not a clean tree\n');
558
+ return null;
559
+ }
560
+ return sheets;
561
+ }
562
+
563
+ // Pure verdict over { sheet: { kind: n } } maps. Keys starting with `_` are notes.
564
+ function compareDesignCounts(current, baseline) {
565
+ const { KINDS } = require('./css-literal-scan.js');
566
+ const violations = [];
567
+ const warnings = [];
568
+ const sheets = new Set([...Object.keys(current || {}), ...Object.keys(baseline || {})].filter((k) => !k.startsWith('_')));
569
+ for (const sheet of [...sheets].sort()) {
570
+ const cur = (current || {})[sheet] || {};
571
+ const base = (baseline || {})[sheet] || {};
572
+ for (const kind of KINDS) {
573
+ const value = cur[kind] || 0;
574
+ const floor = base[kind] || 0;
575
+ if (value > floor) {
576
+ violations.push(`design count ${kind} in ${sheet} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass${DESIGN_HINT}`);
577
+ } else if (value < floor) {
578
+ warnings.push(`ratchet-down available: design count ${kind} in ${sheet} improved to ${value} (baseline ${floor}) — lower it in config/fitness-baselines.json design_counts`);
579
+ }
580
+ }
581
+ }
582
+ return { violations, warnings };
583
+ }
584
+
538
585
  function computeMetrics({ withDocs = true } = {}) {
539
586
  const files = trackedJsFiles();
540
587
  const contents = new Map(files.map((f) => [f, readRepoFile(f)]));
@@ -560,15 +607,28 @@ function computeMetrics({ withDocs = true } = {}) {
560
607
  // computeMetrics() sweep — git ls-files plus every tracked .js plus the
561
608
  // docs-entropy scan, measured at 23.7s + 2.5s + 2.8s, i.e. ~29s of this file's
562
609
  // 29s runtime, which is what pushed it into the 90s timeout in the first place.
563
- function checkQualityRatchets({ baselinePath = BASELINE_PATH, metrics: injectedMetrics = null } = {}) {
610
+ // `designCounts` (task 1005093) is injectable the same way; when a test injects
611
+ // `metrics` alone, the stylesheet scan is skipped rather than run for real.
612
+ function checkQualityRatchets({ baselinePath = BASELINE_PATH, metrics: injectedMetrics = null, designCounts: injectedDesign = null } = {}) {
564
613
  const metrics = injectedMetrics === null ? computeMetrics() : injectedMetrics;
565
- const { metrics: baselines, error: baselineError } = readBaselines(baselinePath);
614
+ const { metrics: baselines, error: baselineError, parsed } = readBaselines(baselinePath);
566
615
  const { violations, warnings } = compareToBaselines(metrics, baselines);
616
+ const design = injectedDesign !== null ? injectedDesign : (injectedMetrics === null ? designCounts() : null);
617
+ if (design) {
618
+ const designBaseline = parsed && parsed.design_counts;
619
+ if (!baselineError && (!designBaseline || typeof designBaseline !== 'object')) {
620
+ violations.push('config/fitness-baselines.json has no "design_counts" object — the per-stylesheet design ratchet cannot verify anything, so it fails CLOSED. Bootstrap deliberately: node scripts/bongos/fitness-ratchets.js --update-baselines');
621
+ } else if (designBaseline) {
622
+ const d = compareDesignCounts(design, designBaseline);
623
+ violations.push(...d.violations);
624
+ warnings.push(...d.warnings);
625
+ }
626
+ }
567
627
  // task 1003227: an unreadable baseline file is a hard failure, not silence. Put it
568
628
  // FIRST so it is the headline rather than buried under per-metric noise.
569
629
  if (baselineError) violations.unshift(baselineError);
570
630
  return {
571
- name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals, source-text-only tests only tighten (task 1003129)',
631
+ name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals, source-text-only tests, per-stylesheet design counts only tighten (task 1003129)',
572
632
  hardFail: violations.length > 0,
573
633
  violations,
574
634
  warnings,
@@ -598,6 +658,12 @@ function main() {
598
658
  // this file cannot be eaten by a refresh either.
599
659
  const preserved = existingFile && typeof existingFile === 'object' ? { ...existingFile } : {};
600
660
  delete preserved.metrics;
661
+ // design_counts is recomputed too (task 1005093), keeping its `_` notes.
662
+ const design = designCounts();
663
+ if (design) {
664
+ const notes = Object.fromEntries(Object.entries(preserved.design_counts || {}).filter(([k]) => k.startsWith('_')));
665
+ preserved.design_counts = { ...notes, ...design };
666
+ }
601
667
  if (!preserved._comment) {
602
668
  preserved._comment = 'fitness-ratchets baselines (task 1003129): quality debt recorded at gate-adoption. Shrink-only — lower a value when the metric improves; raising one to pass CI is out of policy.';
603
669
  }
@@ -647,6 +713,7 @@ module.exports = {
647
713
  readBaselines,
648
714
  computeMetrics,
649
715
  compareToBaselines,
716
+ compareDesignCounts,
650
717
  sourceTextTestMetrics,
651
718
  duplicateWindowCount,
652
719
  normalizeLines,
@@ -200,7 +200,7 @@ function checkNestedDocsPresent() {
200
200
  // here would false-fail every PR (the gitignored whole file is absent from a clean checkout, so it
201
201
  // always reads as "would change"); `--check-block` is the exact, non-noisy gate. (docs/file-map.md +
202
202
  // the OpenAPI docs are still fully committed and unchanged by ADR 0110 — they keep the plain --check.)
203
- function checkGeneratedArtifactsFresh() {
203
+ function checkGeneratedArtifactsFresh({ designRoot = REPO_ROOT } = {}) { // designRoot: tests only (task 1005093)
204
204
  const violations = [];
205
205
  try {
206
206
  execFileSync(process.execPath, [path.join(__dirname, 'gen-repo-map.js'), '--check-block'], { cwd: REPO_ROOT, stdio: 'pipe' });
@@ -254,9 +254,10 @@ function checkGeneratedArtifactsFresh() {
254
254
  } catch {
255
255
  violations.push('docs/page-inventory.json + docs/page-inventory.md are STALE (or two pages claim one id) — a page was added, renamed, retitled or changed its scripts/states. Run `node scripts/bongos/page-inventory.js` and commit (task 1004314).');
256
256
  }
257
+ violations.push(...require('./design-generators-fresh.js').checkDesignGeneratorsFresh(designRoot)); // task 1005093
257
258
  // NB the role packs' responsibility blocks are generated too, but their freshness
258
259
  // is asserted by role-pack-guard.js — one check owns "the packs are correct".
259
- return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, the API spec + typed client GENERATE from the routes (both gitignored, task 1004866), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
260
+ return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, the API spec + typed client GENERATE from the routes (both gitignored, task 1004866), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314), and the design system config/design-tokens.neutral.json + public /brand reference (task 1005093). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
260
261
  }
261
262
 
262
263
  // ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
@@ -338,6 +338,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
338
338
  log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
339
339
  return { ok: true, served: null, preflight: verdict };
340
340
  }
341
+ backfillHubUpgradeUrl(inst, deps, boxExec);
341
342
  try {
342
343
  log(` [upgrade] (in ${runDir}) ${cmd}`);
343
344
  boxExec(cmd, { cwd: runDir });
@@ -672,6 +673,34 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
672
673
  // HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
673
674
  // a call site whose safety depends on a helper's internals is one refactor away from a
674
675
  // shell-injection path (and the apply leg's own guard sits at the route, not here).
676
+ // WHERE THIS PROJECT'S UPDATE BUTTON GOES, written on every move (task 1005128): a project
677
+ // provisioned before task 1004608 has no <PREFIX>_HUB_UPGRADE_URL line, and only a
678
+ // settings-apply ever wrote one. The move restarts the service, so the line is read the moment
679
+ // the new core comes up. BEST-EFFORT: it is written before the move and never decides it; an
680
+ // unreadable file, a refused write or a line already right changes nothing and fails nothing.
681
+ function backfillHubUpgradeUrl(inst, deps, boxExec) {
682
+ // The slug is interpolated into a shell command below, so it is checked HERE, at the point
683
+ // of use, not trusted to a caller that validated it earlier (the rule coreUpgradeInstance
684
+ // states for its own command).
685
+ if (!inst || !isValidSlug(inst.slug)) return false;
686
+ try {
687
+ const { hubUpgradeEnvPatch, webEnvPath } = require('./provision-units.js'); // eslint-disable-line global-require
688
+ const sudoP = deps.privileged ? 'sudo ' : '';
689
+ const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
690
+ if (!read || read.softFailed) return false;
691
+ const next = hubUpgradeEnvPatch(read.stdout, inst);
692
+ if (!next) return false;
693
+ const write = inst.hosting_shape === 'cloud-host' ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
694
+ if (typeof write !== 'function') return false;
695
+ write(webEnvPath(inst), next, { mode: 0o600, sudo: true });
696
+ deps.log(' [config] wrote where this project\'s Update button goes into web.env (read on restart)');
697
+ return true;
698
+ } catch (e) {
699
+ deps.log(` [config] could not write the Update button's address (${(e && e.message) || e}); the move goes on`);
700
+ return false;
701
+ }
702
+ }
703
+
675
704
  function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', automatic = false, selfRoot = selfInstanceRoot, runAs = null, migrateAs = null } = {}) {
676
705
  if (!isValidSlug(inst && inst.slug)) {
677
706
  throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
@@ -1164,4 +1193,4 @@ async function snapshotAllCoreVersions(deps) {
1164
1193
  return { seen };
1165
1194
  }
1166
1195
 
1167
- module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
1196
+ module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, backfillHubUpgradeUrl, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -447,9 +447,27 @@ function hubUpgradeEnvVarsFor(inst) {
447
447
  return { [`${provisionedEnvPrefix()}_HUB_UPGRADE_URL`]: origin ? `${origin}/deploy?project=${encodeURIComponent(String(inst.slug))}` : '' };
448
448
  }
449
449
 
450
+ // THE BACKFILL (task 1005128). Only a settings-apply wrote that line, and one runs only when a
451
+ // setting changes, so a project provisioned before task 1004608 (toast) never got it and its
452
+ // owner was offered "Ask for this upgrade" instead of the Update button. Every core move now
453
+ // carries the line too: the web.env as it stands, with that ONE line set, or null when it is
454
+ // already right, there is nothing to write, or the file is empty (a missing file is never
455
+ // "patched" into a one-line body). PURE; the core-upgrade leg does the read and the write.
456
+ function hubUpgradeEnvPatch(body, inst) {
457
+ const vars = hubUpgradeEnvVarsFor(inst);
458
+ const [key] = Object.keys(vars);
459
+ const text = String(body == null ? '' : body);
460
+ if (!key || !vars[key] || !text.trim()) return null;
461
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
462
+ const have = new RegExp(`^${esc}=(.*)$`, 'm').exec(text);
463
+ if (have && have[1].trim() === vars[key]) return null;
464
+ return upsertEnvVars(text, vars);
465
+ }
466
+
450
467
  module.exports = {
451
468
  federationHubOrigin,
452
469
  hubUpgradeEnvVarsFor,
470
+ hubUpgradeEnvPatch,
453
471
  ENV_PREFIX_PACK,
454
472
  envPrefixMismatch,
455
473
  normalizePackEnvPrefix,