@bongos/core 1.19.424 → 1.19.426

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 (32) hide show
  1. package/.bongos-core.json +59 -34
  2. package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +1 -1
  5. package/docs/copy-inventory.md +184 -184
  6. package/docs/copy-registry.json +235 -235
  7. package/docs/design/mocks/hub/wizard.html +421 -0
  8. package/docs/design/projects-hub-direction-v2.md +26 -0
  9. package/docs/file-map.md +2 -0
  10. package/docs/module-api-changelog.md +4 -0
  11. package/modules/hall-ui/public/people.js +5 -5
  12. package/modules/hall-ui/public/settings.html +2 -2
  13. package/modules/hall-ui/public/settings.js +12 -12
  14. package/modules/platform-identity/account-visibility.js +47 -1
  15. package/modules/platform-identity/platform-identity.js +14 -11
  16. package/modules/platform-identity/routes/scouting.js +61 -21
  17. package/modules/platform-identity/routes/sso.js +6 -0
  18. package/modules/platform-identity/scouting-authz.js +139 -0
  19. package/modules/ui-design/kit/serve.js +3 -1
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/public-landing/assets/cosmos.css +63 -0
  23. package/public-landing/projects.html +207 -199
  24. package/public-landing/projects.probes.json +7 -1
  25. package/public-landing/projects.states.json +7 -1
  26. package/src/bongos/auth.js +59 -3
  27. package/src/module-api.js +1 -1
  28. package/tests/account_privacy_flags.mjs +23 -13
  29. package/tests/effective_visibility_predicate.mjs +49 -4
  30. package/tests/projects_hub.mjs +56 -0
  31. package/tests/scouting_owner_access_and_reach.mjs +544 -0
  32. package/tests/scouting_page_gate.mjs +185 -0
@@ -390,9 +390,54 @@ const PAGE_GATES = Object.freeze({
390
390
  requireNonXenosPage: Object.freeze({ permission: 'page.view.builder', deniedTo: '/builders/not-ready' }),
391
391
  requireGovernmentPage: Object.freeze({ permission: 'page.view.government', deniedTo: '/builders' }),
392
392
  requireGovernmentManagePage: Object.freeze({ permission: 'government.manage', deniedTo: '/builders' }),
393
- requireProjectCuratePage: Object.freeze({ permission: 'project.curate', deniedTo: '/builders' }),
393
+ // `alsoAdmits` — a SECOND, non-rank audience this page's DATA ROUTE admits, so
394
+ // the page can follow it (ADR 0210, task 1002290). /people fronts GET /scouting,
395
+ // which since R14 admits a `project.curate` holder OR a PROJECT OWNER; a page
396
+ // gate that could only ever ask about a permission would leave the widened
397
+ // audience staring at a redirect. The value names a function on the
398
+ // `platform-identity.hub` port — the module that owns the ownership fact — and
399
+ // is consulted ONLY after the permission check has already refused, so it can
400
+ // widen and never narrow. Absent port, absent function, or a throw: no admit.
401
+ requireProjectCuratePage: Object.freeze({
402
+ permission: 'project.curate', deniedTo: '/builders', alsoAdmits: 'isProjectOwner',
403
+ }),
394
404
  });
395
405
 
406
+ // The `alsoAdmits` door — "is this visitor the page's OTHER audience?", asked of
407
+ // the module that owns the answer, only for the one gate that declares one.
408
+ //
409
+ // FAIL-CLOSED, EVERY WAY IT CAN FAIL: a gate with no `alsoAdmits`, a hub module
410
+ // that is off (every non-hub instance), a port that never grew the function, or a
411
+ // resolver that throws all answer FALSE — the visitor is redirected exactly as
412
+ // before. The only outcome that admits is an explicit truthy answer.
413
+ //
414
+ // Not authority (ADR 0016): the answer gates a PAGE SHELL and nothing else, and
415
+ // every data endpoint behind that shell still carries its own server-side gate.
416
+ async function pageGateAlsoAdmits(name, session) {
417
+ const { alsoAdmits } = PAGE_GATES[name];
418
+ if (!alsoAdmits) return false;
419
+ // The WHOLE lookup is inside the try, not just the call: resolving a seam can
420
+ // throw too, and a throw there would 500 the page instead of taking the clean
421
+ // deny-redirect every other failure here takes. One catch, one outcome.
422
+ try {
423
+ const hub = seams.resolveOptional('platform-identity.hub');
424
+ // OWN property only. Every object inherits `constructor`, `valueOf` and
425
+ // `toString`, each of which is a function whose result is truthy — so a plain
426
+ // `hub[name]` lookup would let a mis-typed or mis-declared `alsoAdmits` admit
427
+ // EVERYONE. Unreachable today (the only value is a frozen literal), but a
428
+ // fail-OPEN default has no business sitting in an authorization path.
429
+ if (!hub || !Object.prototype.hasOwnProperty.call(hub, alsoAdmits)) return false;
430
+ const ask = hub[alsoAdmits];
431
+ if (typeof ask !== 'function') return false;
432
+ return !!(await ask(session.github_id));
433
+ } catch (err) {
434
+ // Message only — the answer came from a query that can carry row data.
435
+ require('./logger').logger.child({ src: 'auth' })
436
+ .error({ err: err && err.message }, `${name} ${alsoAdmits} check failed`);
437
+ return false;
438
+ }
439
+ }
440
+
396
441
  // A builder's effective permission set, or NULL when authority cannot be
397
442
  // ESTABLISHED (the `governance` port is absent or malformed, or the resolver
398
443
  // threw). Every caller must read null as a DENIAL: admitting when the authority
@@ -446,6 +491,15 @@ async function runPageGate(name, req, res, next) {
446
491
  }
447
492
  const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
448
493
  if (held === null || !held.has(permission)) {
494
+ // The rank door said no. A page whose data route admits a SECOND audience
495
+ // asks that door before redirecting (ADR 0210) — consulted here and nowhere
496
+ // else, so it can only widen. Asked on the authority-outage branch too,
497
+ // because the ownership fact does not come from the authority system: not
498
+ // asking would make the page narrower than the route it fronts.
499
+ if (await pageGateAlsoAdmits(name, session)) {
500
+ next();
501
+ return;
502
+ }
449
503
  // N-1 (task 1002613): a bare deniedTo rendered nothing — every denial read as
450
504
  // "I signed in and got dumped at the home page". Name the permission (and an
451
505
  // authority outage, distinctly) in the query so the hall shell can say why.
@@ -488,8 +542,10 @@ async function requireGovernmentManagePage(req, res, next) {
488
542
  // The /people builder-directory page (task 1002577) — gated on the
489
543
  // `project.curate` ATOM, the same key its one data route (GET /scouting,
490
544
  // modules/platform-identity) checks, so the page layer can never drift wider
491
- // than the API it fronts. Spec R14 (goal 1000045) widens the atom's audience
492
- // later; this gate follows it automatically.
545
+ // than the API it fronts. R14 (goal 1000045, ADR 0210) widened that route to a
546
+ // SECOND audience that is not a rank at all — a project owner — so this gate
547
+ // carries the same OR through `alsoAdmits` above: the two doors of the page are
548
+ // exactly the two doors of the route, and neither layer can drift from it.
493
549
  async function requireProjectCuratePage(req, res, next) {
494
550
  return runPageGate('requireProjectCuratePage', req, res, next);
495
551
  }
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.424'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.426'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -284,23 +284,33 @@ test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, na
284
284
  'the section ships hidden — it reveals only where the hub module answers');
285
285
  });
286
286
 
287
- test('each toggle claims exactly what is enforced — visibility in force, recruiting not yet', () => {
287
+ test('each toggle claims exactly what is enforced — and BOTH are now in force', () => {
288
288
  const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
289
289
  const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
290
- // A toggle may promise only what a read path actually enforces, and the two
291
- // rows are at different stages. Task 1002967 wired VISIBILITY into every
292
- // public read, so its row moved to the present tense here — and it must also
293
- // say the page 404s to the OWNER, because that is the surprising half of D2
294
- // (the owner reads their record through the own-scope side door instead).
295
- // DISCOVERABILITY is still stored-only: no recruiting surface reads it, so a
296
- // present-tense promise there would be the "protection you do not have" this
297
- // assertion pair exists to prevent.
290
+ // A toggle may promise only what a read path actually enforces. The two rows
291
+ // reached that point one task apart. Task 1002967 wired VISIBILITY into every
292
+ // public read, so its row moved to the present tense — and it must also say
293
+ // the page 404s to the OWNER, because that is the surprising half of D2 (the
294
+ // owner reads their record through the own-scope side door instead). Task
295
+ // 1002290 then wired RECRUITING REACH into listScoutingRollup, the one reader
296
+ // behind /scouting and the hall's People directory, so its row followed on the
297
+ // same terms. The assertions are unchanged in KIND — copy must track
298
+ // enforcement — only in which tense each row is now required to be in.
298
299
  assert.match(js, /Private hides the page from everyone — including you/,
299
300
  'visibility is enforced now, so the row says so — and names the 404-to-self');
300
301
  assert.doesNotMatch(js, /Private will hide the page/,
301
302
  'the future tense outlived its truth the moment resolveHandle joined the predicate');
302
- assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability is saved to your[\s\S]*starts applying as the recruiting surfaces land/,
303
- 'the section note splits the two: one in force, one recorded and waiting');
304
- assert.doesNotMatch(js, /Recruiters looking for builders (?:can|are able to) find you\b/,
305
- 'no present-tense promise about recruiting reach — nothing reads that flag yet');
303
+ assert.match(js, /Recruiters looking for builders can find you\b/,
304
+ 'recruiting reach is enforced now (task 1002290), so the row promises it in the present tense');
305
+ assert.doesNotMatch(js, /will be able to find you/,
306
+ 'the future tense outlived its truth the moment listScoutingRollup joined the reach predicate');
307
+ assert.match(js, /takes you out of the People directory straight away/,
308
+ 'and the row names the ONE surface that reads the flag today — a promise you can go and check');
309
+ assert.doesNotMatch(js, /every recruiting view/,
310
+ 'never a promise over surfaces that do not exist yet: the D3 recruiter sliver is unbuilt, so no '
311
+ + 'guard here could hold that claim, and the copy rule is that a toggle promises exactly what is enforced');
312
+ assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability applies now too/,
313
+ 'the section note now says both are live, rather than splitting them');
314
+ assert.doesNotMatch(html, /starts applying as the recruiting surfaces land/,
315
+ 'the "recorded and waiting" note must not outlive the surface that started reading it');
306
316
  });
@@ -77,6 +77,8 @@ import {
77
77
  rollupVisibleSql,
78
78
  isRollupVisible,
79
79
  isRecruiterDiscoverable,
80
+ recruiterReachSql,
81
+ scoutingListableSql,
80
82
  } from '../modules/platform-identity/account-visibility.js';
81
83
 
82
84
  const require = createRequire(import.meta.url);
@@ -244,7 +246,14 @@ function blankComments(src) {
244
246
  const SPELLINGS = [
245
247
  [/\bprofile_state\s*(?:=|==|===|!=|!==|<>)\s*['"]/g, "profile_state compared to a literal"],
246
248
  [/\baccount_visibility\s*(?:=|==|===|!=|!==|<>)\s*['"]/g, 'account_visibility compared to a literal'],
247
- [/\bCOALESCE\s*\(\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
249
+ // The reach COALESCE, in every form a real query writes it. The unqualified
250
+ // spelling alone was a hole: the ONE production copy of this predicate is
251
+ // written `COALESCE(${q}recruiter_discoverable, …)` and emits
252
+ // `COALESCE(pa.recruiter_discoverable, …)`, and NEITHER matched — so the rule
253
+ // that is supposed to make this file the only home for the reach answer was
254
+ // catching only the spelling nobody uses. A drift copy would most likely be
255
+ // aliased too (task 1002290, found in this task's adversarial review).
256
+ [/\bCOALESCE\s*\(\s*(?:[A-Za-z_$][\w$]*|\$\{[^}]*\})?\s*\.?\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
248
257
  [/\bNOT\s+(?:[A-Za-z_][\w]*\.)?hide_stats\b/g, 'a SQL NOT hide_stats'],
249
258
  [/\bhide_stats\s*(?:=|==|===)\s*(?:true|false)\b/g, 'hide_stats compared to a boolean'],
250
259
  ];
@@ -318,8 +327,14 @@ function accountStatements(code) {
318
327
  return out;
319
328
  }
320
329
 
321
- /** Does this function body route through one of the shared predicates? */
322
- const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
330
+ /**
331
+ * Does this function body route through one of the shared predicates?
332
+ * scoutingListableSql (task 1002290) is COMPOSED from rollupVisibleSql and
333
+ * recruiterReachSql inside the owner file, so a reader that joins it has adopted
334
+ * the shared rule — but it must be named here, or wiring the recruiting reach in
335
+ * would read as a reader that stopped adopting anything.
336
+ */
337
+ const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
323
338
 
324
339
  // ===========================================================================
325
340
  // Self-tests — the machinery is the guard, so it gets proved too.
@@ -342,6 +357,17 @@ t('SPELLINGS catch a hand-written variant in either language, and leave writes a
342
357
  assert.ok(caught("AND account_visibility = 'public'"), 'the existence axis in SQL');
343
358
  assert.ok(caught("acct.account_visibility === 'private'"), 'the existence axis in JS');
344
359
  assert.ok(caught('COALESCE(recruiter_discoverable, account_visibility)'), 'the recruiter COALESCE');
360
+ // The forms a REAL query carries — an alias, a template variable, and the two
361
+ // together. Testing only the bare spelling is how this rule came to match
362
+ // nothing that ships: the ONE production copy emits `COALESCE(pa.recruiter_-
363
+ // discoverable, …)` from source that reads `COALESCE(${q}recruiter_-
364
+ // discoverable, …)`, and the rule matched NEITHER (task 1002290, found in this
365
+ // task's adversarial review — and then again one form later, which is why all
366
+ // four are pinned rather than the one that failed first).
367
+ assert.ok(caught('COALESCE(pa.recruiter_discoverable, TRUE)'), 'the reach COALESCE, aliased');
368
+ assert.ok(caught('COALESCE(${q}recruiter_discoverable, x)'), 'the reach COALESCE, as the builder writes it');
369
+ assert.ok(caught('COALESCE(${alias}.recruiter_discoverable, TRUE)'), 'a template alias AND a dot — the drift copy most likely to be written');
370
+ assert.ok(!caught('COALESCE(hide_stats, false)'), 'and it is the REACH column it watches, not any COALESCE');
345
371
  assert.ok(caught('AND NOT pa.hide_stats'), 'the hide half of a rollup predicate');
346
372
  // A WRITE names the column but compares it to nothing — it decides no visibility.
347
373
  assert.ok(!caught("profile_state = CASE WHEN $5::boolean THEN 'active' ELSE x END"), 'an upsert is not a predicate');
@@ -390,10 +416,24 @@ t('rollupVisibleSql is COMPOSED from accountVisibleSql, never restated', () => {
390
416
  assert.match(rollupVisibleSql('pa'), /AND NOT pa\.hide_stats/, 'and add exactly the hide term');
391
417
  });
392
418
 
393
- t('the alias is the one interpolated thing, and both builders refuse a non-identifier', () => {
419
+ t('scoutingListableSql is COMPOSED from the rollup AND the reach halves (task 1002290)', () => {
420
+ // The recruiting directory asks BOTH questions; a leaderboard asks only the
421
+ // first. Composition is what keeps that distinction from becoming two rules.
422
+ assert.ok(scoutingListableSql('pa').includes(rollupVisibleSql('pa')),
423
+ 'the directory predicate must literally contain the rollup predicate');
424
+ assert.ok(scoutingListableSql('pa').includes(recruiterReachSql('pa')),
425
+ 'and the reach predicate — wiring reach in is the whole of task 1002290');
426
+ assert.ok(!rollupVisibleSql('pa').includes('recruiter_discoverable'),
427
+ 'and reach must NOT leak back down into the rollup rule: the community spec D3 '
428
+ + 'leaderboards join rollupVisibleSql and the opt-out must not empty them');
429
+ });
430
+
431
+ t('the alias is the one interpolated thing, and every builder refuses a non-identifier', () => {
394
432
  const inject = "pa; DROP TABLE platform_identity_accounts --";
395
433
  assert.throws(() => accountVisibleSql(inject), /bare SQL identifier/);
396
434
  assert.throws(() => rollupVisibleSql(inject), /bare SQL identifier/);
435
+ assert.throws(() => recruiterReachSql(inject), /bare SQL identifier/);
436
+ assert.throws(() => scoutingListableSql(inject), /bare SQL identifier/);
397
437
  });
398
438
 
399
439
  t('the JS ports answer exactly what the SQL asks, row for row', () => {
@@ -655,6 +695,11 @@ function accountFilter(where, params) {
655
695
  if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
656
696
  if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
657
697
  if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
698
+ // The recruiting-reach COALESCE (task 1002290). Modelled through the shared JS
699
+ // twin, not re-implemented — an interpreter that read this term its own way
700
+ // could agree with a WRONG query. Unmodelled it would degrade to no filter,
701
+ // which is precisely what this interpreter exists to refuse.
702
+ if (/COALESCE\((?:[a-z_]+\.)?recruiter_discoverable,/.test(where)) tests.push(isRecruiterDiscoverable);
658
703
 
659
704
  const byId = /\b(?:[a-z_]+\.)?github_id = \$(\d+)/.exec(where);
660
705
  if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
@@ -1676,3 +1676,59 @@ test('one column grammar: the table lays every row on one set of fixed tracks (e
1676
1676
  assert.match(COSMOS, /@media \(max-width:780px\)\{\.tbl \.colTags,\.tbl \.colType\{display:none;\}\.tbl\{min-width:600px;\}\}/, 'a phone scrolls the table inside its wrap');
1677
1677
  assert.match(COSMOS, /#sub-table-body\{overflow-x:auto;\}/, 'the wrap scrolls, never the page');
1678
1678
  });
1679
+
1680
+ /* ── the create wizard in the world (task 1003346; direction record §15, the pick: v1 the
1681
+ rail above, the bongo pair kept). Two sheets carry it: cosmos.css draws the FRAME by scope
1682
+ (#view-new), projects.html's own wizard block draws the wizard's structures. ── */
1683
+ test('the wizard takes the world by scope: no card ground, the six-step strip as the rail, the doors as pills (task 1003346)', () => {
1684
+ const at = COSMOS.indexOf('THE CREATE WIZARD, IN THE WORLD');
1685
+ assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
1686
+ const block = COSMOS.slice(at);
1687
+ assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
1688
+ assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(6,1fr\);/, 'the strip lays its six steps on six tracks');
1689
+ assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
1690
+ assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
1691
+ assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
1692
+ assert.match(block, /#view-new \.btn\{[^}]*text-transform:lowercase;[^}]*box-shadow:none;/, 'the doors are pills without a shadow');
1693
+ assert.match(block, /#view-new \.btn\.ghost\{[^}]*border:1px solid var\(--rule\);/, 'back and skip are hairline pills');
1694
+ assert.match(block, /#view-new #wizSignin\{background:var\(--ink\);color:var\(--bg\);/, 'the GitHub sign-in is the inverted pill');
1695
+ assert.match(block, /#view-new \.bongos \.glow\{display:none;\}/, 'the bongo pair stays (the owner\'s one mark on every surface), its halo goes');
1696
+ assert.match(block, /#view-new \.panel \.lede a\{display:inline-block;padding:3px 0;min-height:24px;/, 'the done clause\'s link clears the 24px floor (the sheet\'s first problem)');
1697
+ assert.doesNotMatch(block, /box-shadow:(?!none)/, 'halos are outlines in the world, never box-shadow');
1698
+ assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/i, 'every colour is a token');
1699
+ });
1700
+
1701
+ test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
1702
+ const at = STYLE.indexOf('the create wizard (task 1002763');
1703
+ assert.ok(at > -1, 'the wizard block is still the page\'s own');
1704
+ const block = STYLE.slice(at);
1705
+ assert.match(block, /#view-new input\[type=text\], #view-new textarea\{\s*width:100%;[^}]*border:0;border-bottom:1px solid var\(--rule\);border-radius:0;/, 'a field is one bottom hairline');
1706
+ assert.match(block, /#view-new input\[type=text\]:focus, #view-new textarea:focus\{border-bottom-color:var\(--ink\);box-shadow:none;\}/, 'focus takes ink, never a glow');
1707
+ assert.match(block, /\.typeCards\{display:grid;[^}]*border-top:1px solid var\(--rule\);border-bottom:1px solid var\(--rule\);/, 'the type and team choices are cells of one band');
1708
+ assert.match(block, /\.tpill\[aria-pressed=true\]::before\{background:var\(--accent\);/, 'the chosen cell carries the dot');
1709
+ assert.match(block, /\.choices\{display:grid;grid-template-columns:1fr 1fr;gap:0;border-top:1px solid var\(--rule\);/, 'start fresh / adopt are two cells of one band');
1710
+ assert.match(block, /\.choice svg\{display:none;\}/, 'the dot is the mark, the icon leaves');
1711
+ assert.match(block, /\.review\{list-style:none;display:grid;grid-template-columns:172px 1fr;/, 'the review is a ledger');
1712
+ assert.match(block, /\.review li\{display:contents;\}/, 'renderReview\'s li markup stands; the grid lays its k and v');
1713
+ assert.match(block, /\.humanSteps>li\{\s*display:grid;grid-template-columns:44px 1fr;/, 'the human steps are a numbered ledger');
1714
+ assert.match(block, /\.humanSteps>li>\*\{grid-column:2;/, 'every child of a step sits in the text column, never under the counter (the sheet\'s bug)');
1715
+ assert.match(block, /\.humanSteps>li\.sdone::before\{content:"done";/, 'a finished step reads DONE in the ok ink');
1716
+ assert.match(block, /\.prog\{background:none;border:0;border-radius:0;padding:0;/, 'the standup strip is drawn as the rail');
1717
+ assert.match(block, /\.prog \.stages li\.now \.sdot::after\{content:none;animation:none;\}/, 'no pulse on the rail');
1718
+ assert.match(block, /\.doneHead \.tick\{display:none;\}/, 'the tick leaves; the rail says filed');
1719
+ assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
1720
+ assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
1721
+ assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
1722
+ assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="5">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
1723
+ assert.match(HUB, /<h2 class="stepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style');
1724
+ // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
1725
+ assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
1726
+ assert.match(SCRIPT, /hosting_shape: 'standalone',/, 'the shape is unchanged');
1727
+ assert.match(SCRIPT, /target_ref: state\.owner \+ '\/' \+ state\.repo,/, 'the repo ref is unchanged');
1728
+ assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
1729
+ // the kit walks every step and files against the stub
1730
+ const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8')).states;
1731
+ for (const s of ['type', 'about', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
1732
+ const probes = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.probes.json'), 'utf8')).probes;
1733
+ assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name) && p.steps.some((st) => st[1] === '#doCreate')), 'the wizard driver files a project end to end');
1734
+ });