@bongos/core 1.19.440 → 1.19.442

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 (33) hide show
  1. package/.bongos-core.json +71 -31
  2. package/clients/bongos-client/index.d.ts +1 -2
  3. package/docs/api/openapi.json +3 -20
  4. package/docs/api-reference.md +1 -1
  5. package/docs/copy-inventory.md +180 -173
  6. package/docs/copy-registry.json +300 -226
  7. package/docs/design/mocks/hub/mine.html +89 -1
  8. package/docs/design/projects-hub-direction-v2.md +59 -0
  9. package/docs/module-api-changelog.md +4 -0
  10. package/modules/platform-identity/routes/projects.js +2 -1
  11. package/modules/provisioning/provisioning.js +11 -2
  12. package/modules/provisioning/routes/provisioning.js +10 -5
  13. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
  14. package/modules/ui-design/kit/serve.js +29 -4
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/public-landing/assets/cosmos.css +89 -65
  18. package/public-landing/assets/world/r4-card-chrome.jpg +0 -0
  19. package/public-landing/assets/world/r4-card-chrome.jpg.json +14 -0
  20. package/public-landing/assets/world/r4-card-deepfield.jpg +0 -0
  21. package/public-landing/assets/world/r4-card-deepfield.jpg.json +14 -0
  22. package/public-landing/assets/world/r4-card-dust.jpg +0 -0
  23. package/public-landing/assets/world/r4-card-dust.jpg.json +14 -0
  24. package/public-landing/assets/world/r4-card-nebula.jpg +0 -0
  25. package/public-landing/assets/world/r4-card-nebula.jpg.json +14 -0
  26. package/public-landing/projects-floor.states.json +28 -1
  27. package/public-landing/projects.html +201 -69
  28. package/public-landing/projects.probes.json +16 -2
  29. package/public-landing/projects.states.json +3 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/projects_hub.mjs +173 -65
  32. package/tests/projects_hub_pre_uat.mjs +11 -9
  33. package/tests/provisioning_settings.mjs +49 -4
@@ -384,9 +384,9 @@ test('the platform has a self entry in the TABLE, not only in the sky', () => {
384
384
  // reason: stripApexOnly() is a string transform and cannot reach JS output
385
385
  assert.match(HUB, /id="selfOrb"/, 'the sky keeps its self orb');
386
386
  assert.match(HUB, /id="selfRow"/, 'the table needs one too — this is the bug');
387
- // and the row must sit INSIDE the tbody the renderer owns
388
- const tbody = (HUB.match(/<tbody id="mapRows">([\s\S]*?)<\/tbody>/) || [, ''])[1];
389
- assert.match(tbody, /id="selfRow"/, 'the self row belongs in #mapRows');
387
+ // and the row must sit INSIDE the list the renderer owns
388
+ const rows = (HUB.match(/<ul class="tbl" id="mapRows">([\s\S]*?)<\/ul>/) || [, ''])[1];
389
+ assert.match(rows, /id="selfRow"/, 'the self row belongs in #mapRows');
390
390
  });
391
391
 
392
392
  test('both self entries are apex-only — a child instance has no "this platform"', () => {
@@ -404,9 +404,9 @@ test('the renderer preserves the self row exactly as it preserves the self orb',
404
404
  // delete a static row on the first catalog paint
405
405
  assert.doesNotMatch(SCRIPT, /\$\('mapRows'\)\.innerHTML\s*=/,
406
406
  'a wholesale innerHTML= on #mapRows destroys the static self row');
407
- assert.match(SCRIPT, /querySelectorAll\('tr\[data-generated\]'\)/,
407
+ assert.match(SCRIPT, /querySelectorAll\('li\[data-generated\]'\)/,
408
408
  'generated rows must be removed selectively, like .worldOrb[data-generated]');
409
- assert.match(SCRIPT, /<tr data-generated[ >]/, 'generated rows must be markable');
409
+ assert.match(SCRIPT, /<li class="tblRow" data-generated[ >]/, 'generated rows must be markable');
410
410
  });
411
411
 
412
412
  test('"only mine" no longer hides the platform from a signed-in builder', () => {
@@ -558,7 +558,7 @@ test('the ladder: each region hides on null and the other figure stands alone',
558
558
  assert.match(art, /classList\.toggle\('has-art', !!url\)/, 'art switches the whole layout');
559
559
  assert.match(SCRIPT, /\$\('cardArt'\)\.addEventListener\('error', function \(\) \{ cardArt\(''\); \}\);/,
560
560
  'a broken image demotes to the no-art layout, never a broken glyph');
561
- assert.match(SCRIPT, /cardArt\(p\.isSelf \? '' : safeHttpUrl\(p\.art_url\)\)/, 'the art URL is scheme-checked client-side too');
561
+ assert.match(SCRIPT, /safeHttpUrl\(p\.art_url\)\)\);/, 'the art URL is scheme-checked client-side too (behind the owner\'s plate since task 1003352)');
562
562
  });
563
563
 
564
564
  test('exactly one hall-URL derivation: the DOM for the hub, the feed for a project, safeHttpUrl for both, and only where the hall is elsewhere', () => {
@@ -573,11 +573,11 @@ test('exactly one hall-URL derivation: the DOM for the hub, the feed for a proje
573
573
  assert.equal((SCRIPT.match(/isMember\(rel\) \? hallUrlOf\(p\) : ''/g) || []).length, 2, 'member-gated on the card AND on the row, one rule');
574
574
  assert.match(SCRIPT, /'<a class="cardHall" id="cardHall" href="' \+ esc\(hall\)/, 'the card\'s hall door is escaped');
575
575
  assert.match(SCRIPT, /'<a class="rowHall" href="' \+ esc\(hall\) \+ '" target="_blank" rel="noopener"/, 'the row\'s hall glyph is escaped and opens a new tab');
576
- assert.match(SCRIPT, /: ''\) \+ hallGlyphHtml\(p, rel\) \+ '<\/td>'/, 'the glyph rides the existing door cell — no seventh column');
576
+ assert.match(SCRIPT, /: ''\) \+ hallGlyphHtml\(p, rel\) \+ '<\/div>'/, 'the glyph is the row\'s SECOND door, inside the one door slot — never a third');
577
577
  });
578
578
 
579
579
  test('the platform row carries a stamped, hidden hall glyph the session unhides — same tab, no target', () => {
580
- const selfRow = (HUB.match(/<tr id="selfRow">([\s\S]*?)<\/tr>/) || [, ''])[1];
580
+ const selfRow = (HUB.match(/<li class="tblRow" id="selfRow">([\s\S]*?)\r?\n\s*<\/li>/) || [, ''])[1];
581
581
  assert.match(selfRow, /<a class="rowHall" id="selfHall" href="\{\{buildersOrigin\}\}"[^>]*hidden>/, 'server-stamped and hidden until the session says member');
582
582
  assert.match(SCRIPT, /if \(sh\) sh\.hidden = !badge \|\| !hallUrlOf\(SELF_PROJECT\);/, 'member-gated and address-gated, and null-safe: the stamp drops the anchor whole when a pack names no hall');
583
583
  assert.match(SCRIPT, /if \(p\.isSelf\) \{\r?\n\s*var a = \$\('selfHall'\);/);
@@ -614,7 +614,7 @@ test('the poster is in the world: 560 wide, one hairline, no shadow, the disc su
614
614
  assert.match(COSMOS, /\.projCard \.cardMeta dd\{[^}]*text-align:right[^}]*border-top:1px solid var\(--rule-soft\)/, 'the value right and on the same hairline');
615
615
  assert.match(COSMOS, /\.projCard \.cardRel\{[^}]*font-family:var\(--label-face\)/, 'the relationship is a mono label, not an accent pill');
616
616
  assert.doesNotMatch(COSMOS, /\.projCard \.cardMeta \.tags/, 'the card no longer renders the module list');
617
- assert.match(COSMOS, /\.rowHall\{[^}]*width:28px;height:28px/, 'the row glyph clears the 24px floor');
617
+ assert.match(COSMOS, /\.rowHall\{[^}]*width:44px;height:40px/, 'the row\'s second door is a door, not a glyph: the same 40px the "view" pill stands at');
618
618
  });
619
619
 
620
620
  test('the row keeps its own Open link working underneath the card', () => {
@@ -631,14 +631,16 @@ test('the row keeps its own Open link working underneath the card', () => {
631
631
  task 1002772 when a project began DECLARING it at creation — a nullable
632
632
  declared field, so an em dash now means "not declared", never a guess. ── */
633
633
 
634
- test('every table column has a source — Type is declared, never guessed', () => {
635
- const thead = (HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1];
636
- assert.match(thead, />Type</, 'Type has a source now: the project declares it at creation (task 1002772)');
637
- assert.match(SCRIPT, /typeHtml\(p\.type\)/, 'the Type cell must read the declared field through the one formatter');
638
- assert.match(thead, />Builders</);
639
- assert.match(thead, />Runs</);
640
- assert.match(SCRIPT, /p\.builders/, 'the Builders cell must read real data');
641
- assert.match(SCRIPT, /tagsHtml\(p\.tags\)/, 'the Runs cell must read real data');
634
+ test('every fact the row states has a source — Type is declared, never guessed', () => {
635
+ // the header row left with the table (task 1003356): a fact is a line now, so
636
+ // what once had to be a column with a source is a line with a source
637
+ const facts = (SCRIPT.match(/function factLines\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
638
+ assert.ok(facts, 'factLines() must exist');
639
+ assert.match(facts, /typeHtml\(p\.type\)/, 'the Type line must read the declared field through the one formatter');
640
+ assert.match(facts, /p\.builders/, 'the Builders line must read real data');
641
+ assert.match(facts, /shippedFigure\(p\)/, 'shipped-in-30-days is the feed\'s own figure, capped mark and all');
642
+ assert.match(facts, /listedDate\(p\.created_at\)/, 'the Listed date is the catalog\'s');
643
+ assert.match(SCRIPT, /tagsHtml\(p\.tags\)/, 'the Runs line must read real data');
642
644
  // the wizard files the declaration with the create — the source end of the pipe
643
645
  assert.match(SCRIPT, /type: state\.projType/, 'the create request carries the declared type');
644
646
  });
@@ -712,13 +714,20 @@ test('a renumbering of the wizard invalidates old drafts — the key carries the
712
714
  assert.equal(count, 8, 'gate + six numbered steps + done — bump DRAFT_KEY when this changes');
713
715
  });
714
716
 
715
- test('header and body agree on the column count', () => {
716
- // a stray <td> shifts every cell after it — silent, and only visible on screen
717
- const n = ((HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1].match(/<th/g) || []).length;
718
- const selfRow = (HUB.match(/<tr id="selfRow">([\s\S]*?)<\/tr>/) || [, ''])[1];
719
- const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
720
- assert.equal((selfRow.match(/<td/g) || []).length, n, 'the self row must match the header');
721
- assert.equal((generated.match(/<td/g) || []).length, n, 'a generated row must match the header');
717
+ test('every row lays the same five parts — the ledger has no ragged row', () => {
718
+ // a stray <td> used to shift every cell after it; the ledger's version of that
719
+ // bug is a row that skips one grid area, which silently collapses its track
720
+ const selfRow = (HUB.match(/<li class="tblRow" id="selfRow">([\s\S]*?)\r?\n\s*<\/li>/) || [, ''])[1];
721
+ const generated = (SCRIPT.match(/return '<li class="tblRow" data-generated([\s\S]*?)'<\/li>';/) || [, ''])[1];
722
+ assert.ok(selfRow && generated, 'both row templates must keep their extractable shape');
723
+ // the generated row builds its planet into a local first, so the parts test
724
+ // reads that expression too — otherwise dropping the planet would go unseen
725
+ const planet = (SCRIPT.match(/var planet = '<span class="worldOrb"[\s\S]*?;\r?\n/) || [''])[0];
726
+ for (const part of ['worldOrb', 'tWho', 'facts', 'ptag', 'runs', 'rowDoors']) {
727
+ assert.match(selfRow, new RegExp('class="[^"]*\\b' + part + '\\b'), `the self row is missing its ${part}`);
728
+ assert.match(generated + planet, new RegExp('class="[^"]*\\b' + part + '\\b'), `a generated row is missing its ${part}`);
729
+ }
730
+ assert.match(generated, /' \+ planet \+/, 'the generated row plants the planet it built');
722
731
  });
723
732
 
724
733
  test('no sort control is offered for a column that cannot sort', () => {
@@ -735,22 +744,25 @@ test('no sort control is offered for a column that cannot sort', () => {
735
744
  test('the table is in the world: a masthead, then ledger rows — no panel, no shadow, the accent only on the row\'s door (task 1003332)', () => {
736
745
  // the owner's pick from three rendered treatments: the view title in the display
737
746
  // voice and one chrome object; the title is two words ("every project", the owner cut ", as rows")
738
- assert.match(HUB, /<div class="tblMast">\s*<h2 class="tblTitle">every project<\/h2>\s*<span class="mastObj" aria-hidden="true"><\/span>\s*<\/div>\s*<table class="tbl">/, 'one masthead ahead of the table');
747
+ assert.match(HUB, /<div class="tblMast">\s*<h2 class="tblTitle">every project<\/h2>\s*<span class="mastObj" aria-hidden="true"><\/span>\s*<\/div>/, 'one masthead ahead of the rows');
739
748
  assert.doesNotMatch(HUB, /tblMast--t[123]|data-tbl|mastCallouts|mastField/, 'the render round\'s switch and the unpicked mastheads left with the pick');
740
- assert.match(COSMOS, /\.tbl\{width:100%;border-collapse:collapse;background:none;\}/, 'the rows sit on the page ground — no card');
741
- assert.doesNotMatch((COSMOS.match(/\.tbl\{[^}]*\}/) || [''])[0], /border-radius|box-shadow/, 'no radius, no shadow on the table');
742
- assert.match(COSMOS, /\.tbl td\{padding:17px 14px;border-bottom:1px solid var\(--rule-soft\);/, 'ledger rows: hairlines');
743
- assert.match(COSMOS, /\.tbl tbody tr:hover td\{border-bottom-color:var\(--ink-faint\);\}/, 'hover is the hairline taking ink, not an accent tint');
744
- assert.match(COSMOS, /\.rowGo\{[^}]*color:var\(--accent-deep\);text-decoration:underline;/, 'the row\'s door is accent text');
745
- assert.match(HUB, /<a class="rowGo" href="\/">Open →<\/a>/, 'the self row\'s door');
749
+ // the table itself left with task 1003356: a list of ledger rows, no header
750
+ // row, no cell borders, no <table> wearing a role it no longer has
751
+ assert.doesNotMatch(HUB, /<table class="tbl">|<thead>|<th class="col/, 'the table markup, its header row and its cells are gone');
752
+ assert.match(COSMOS, /\.tbl\{list-style:none;margin:0;padding:0;\}/, 'the rows sit on the page ground — no card, no table');
753
+ assert.doesNotMatch((COSMOS.match(/\.tbl\{[^}]*\}/) || [''])[0], /border-radius|box-shadow/, 'no radius, no shadow on the list');
754
+ assert.match(COSMOS, /\.tbl \.tblRow:last-child\{border-bottom:1px solid var\(--rule-soft\);\}/, 'ledger rows: hairlines, and the last one closes');
755
+ assert.match(COSMOS, /\.tbl \.tblRow:hover\{border-top-color:var\(--ink-faint\);\}/, 'hover is the hairline taking ink, not an accent tint');
756
+ assert.match(COSMOS, /\.rowGo\{[^}]*border:1px solid var\(--rule\);border-radius:var\(--radius-pill\);/, 'the row\'s door is the mine row\'s hairline pill');
757
+ assert.match(HUB, /<a class="rowGo" href="\/">view<\/a>/, 'the self row\'s door');
746
758
  assert.match(SCRIPT, /'<a class="rowGo" href="' \+ esc\(safeHttpUrl\(p\.origin\)\)/, 'the generated rows\' door');
747
759
  assert.match(COSMOS, /\.mastObj\{display:block;--dia:clamp\(96px,10vw,132px\);[^}]*background-image:var\(--plate-set-cut\);/, 'the object is the set\'s small bead by token, in both modes');
748
760
  assert.doesNotMatch(COSMOS, /mix-blend-mode:var\(--mast/, 'the cutout needs no blend — the view wrap is a stacking context');
749
761
  });
750
762
 
751
- test('every live column keeps its styling — .colType included since task 1002772', () => {
752
- for (const live of ['.tbl .num', '.tbl .tags', '.colTags', '.colType', '.typeTag']) {
753
- assert.ok(COSMOS.includes(live), `${live} styles a column that renders real data`);
763
+ test('every live part of the row keeps its styling — .typeTag included since task 1002772', () => {
764
+ for (const live of ['.tbl .facts', '.tbl .runs', '.tbl .tags', '.tbl .ptag', '.typeTag']) {
765
+ assert.ok(COSMOS.includes(live), `${live} styles a part of the row that renders real data`);
754
766
  }
755
767
  // the type tag is its own vocabulary: not the relationship .pill, not the
756
768
  // liveness .chip — two different facts must not wear the same chrome
@@ -766,8 +778,11 @@ test('every live column keeps its styling — .colType included since task 10027
766
778
  test('unknown and zero are rendered as different things', () => {
767
779
  // 0 builders is a real answer — "nobody has joined yet". null is not an
768
780
  // answer at all. Collapsing them would restate the lie 1002764 removed.
769
- assert.match(SCRIPT, /p\.builders == null \? '—'/,
770
- 'only null becomes a dash; 0 must render as 0');
781
+ // since task 1003356 the fact is a line, so the two answers are two sentences
782
+ assert.match(SCRIPT, /p\.builders == null \? 'builders not known'/,
783
+ 'only null says "not known"');
784
+ assert.match(SCRIPT, /esc\(String\(p\.builders\)\) \+ \(p\.builders === 1 \? ' builder' : ' builders'\)/,
785
+ '0 must render as "0 builders" — a real answer, not an absence');
771
786
  });
772
787
 
773
788
  test('a project that cannot be reached degrades to a dash, not to a wrong number', () => {
@@ -796,7 +811,7 @@ test('each card fact is wired to its data, not just present in the markup', () =
796
811
  assert.match(SCRIPT, /cardFact\('cardRuns', runsOf\(p\)\)/, 'runs is the address on the poster');
797
812
  const runs = (SCRIPT.match(/function runsOf\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
798
813
  assert.match(runs, /hostOf\(p\.origin\)/, 'the address is the catalog origin\'s host');
799
- assert.match(SCRIPT, /'<td class="colTags">' \+ tagsHtml\(p\.tags\)/, 'the table row keeps the module list');
814
+ assert.match(SCRIPT, /'<div class="runs"><span class="rLbl">runs<\/span>' \+ tagsHtml\(p\.tags\)/, 'the table row keeps the module list, under its own label');
800
815
  assert.match(SCRIPT, /cardFact\('cardOwner', rel === 'owner' \? '' : ownerHtml\(p\), true\)/,
801
816
  'owner comes from the enrichment and yields to the rel chip saying "yours"');
802
817
  const oh = (SCRIPT.match(/function ownerHtml\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
@@ -907,21 +922,21 @@ test('the card metadata block lives inside the card, not a hideable sub-view', (
907
922
  ignores clicks inside an <a> so real links keep their jobs — but this link
908
923
  had no job, so the click was swallowed AND went nowhere. ── */
909
924
 
910
- test('the self row Open is a real link home, not an unwired hook', () => {
911
- const selfRow = (HUB.match(/<tr id="selfRow">([\s\S]*?)<\/tr>/) || [, ''])[1];
912
- assert.match(selfRow, /<a[^>]*href="\/"[^>]*>Open/,
913
- 'the platform project IS this site — its Open navigates to /');
925
+ test('the self row\'s door is a real link home, not an unwired hook', () => {
926
+ const selfRow = (HUB.match(/<li class="tblRow" id="selfRow">([\s\S]*?)\r?\n\s*<\/li>/) || [, ''])[1];
927
+ assert.match(selfRow, /<a[^>]*href="\/"[^>]*>view/,
928
+ 'the platform project IS this site — its door navigates to /');
914
929
  assert.doesNotMatch(selfRow, /target=/,
915
930
  'a new tab for the page you are already on is a bug, not a courtesy');
916
931
  assert.doesNotMatch(HUB, /data-self-open/, 'the never-wired hook must not survive');
917
932
  });
918
933
 
919
- test('no anchor in the table ships a placeholder href', () => {
934
+ test('no anchor in the every-project list ships a placeholder href', () => {
920
935
  // href="#" is the shape of this bug: a dead link wearing button clothes
921
- const table = (HUB.match(/<table[\s\S]*?<\/table>/) || [''])[0];
922
- assert.ok(table, 'the table must be findable');
923
- assert.doesNotMatch(table, /href="#"/, 'no anchor in the table may ship href="#"');
924
- const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
936
+ const rows = (HUB.match(/<ul class="tbl" id="mapRows">[\s\S]*?<\/ul>/) || [''])[0];
937
+ assert.ok(rows, 'the list must be findable');
938
+ assert.doesNotMatch(rows, /href="#"/, 'no anchor in the list may ship href="#"');
939
+ const generated = (SCRIPT.match(/return '<li class="tblRow" data-generated([\s\S]*?)'<\/li>';/) || [, ''])[1];
925
940
  assert.doesNotMatch(generated, /href="#"/, 'generated rows must not ship placeholders either');
926
941
  });
927
942
 
@@ -958,11 +973,11 @@ test('a project both featured and owned renders once, marked owned', () => {
958
973
  test('owned truth reaches the TABLE too — same list, same chip, same manage job', () => {
959
974
  // the count, the sky and the table read ONE merged browse list; an owned row
960
975
  // carries chipFor()'s truth in Status and routes its click to manage
961
- const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
976
+ const generated = (SCRIPT.match(/return '<li class="tblRow" data-generated([\s\S]*?)'<\/li>';/) || [, ''])[1];
962
977
  assert.ok(generated, 'the generated-row template must keep its extractable shape');
963
978
  assert.match(generated, /data-manage/, 'an owned row carries the manage route key');
964
- assert.match(generated, /p\._inst \? chipFor\(p\._inst\)/, 'an owned row\'s Status is the overlay truth');
965
- assert.match(generated, /chip calm/, 'an unprobed catalog row keeps the honest calm dash');
979
+ assert.match(generated, /p\._inst \? chipFor\(p\._inst\)/, 'an owned row\'s chip is the overlay truth');
980
+ assert.match(generated, /chip calm[\s\S]{0,80}not checked/, 'an unprobed catalog row says so in words beside the name');
966
981
  assert.match(SCRIPT, /tr\.hasAttribute\('data-manage'\)\) return goToManage/,
967
982
  'an owned row click goes to manage, exactly like its orb');
968
983
  });
@@ -1767,10 +1782,10 @@ test('the kit proves the picker: a state renders it on the owner\'s feed-carried
1767
1782
  assert.match(stub, /planet: PLANETS\.get\(String\(id\)\) \|\| NO_PLANET/, 'and answers it on the settings read and the receipt');
1768
1783
  });
1769
1784
 
1770
- test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads both keys on every project (task 1003347)', () => {
1785
+ test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads every platform-drawn key on every project (tasks 1003347, 1003352)', () => {
1771
1786
  const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
1772
1787
  assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(projects\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
1773
- assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null \};/, 'the wire always carries both keys');
1788
+ assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null,\r?\n\s*card_backdrop: c\.card_backdrop \|\| null \};/, 'the wire always carries every key — null = nothing chosen');
1774
1789
  assert.match(route, /\.\.\.planetOf\(p\),/, 'spread beside the stats');
1775
1790
  assert.doesNotMatch(route, /require\(['"][^'"]*provisioning/, 'never an import across modules');
1776
1791
  const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'module.json'), 'utf8'));
@@ -1783,25 +1798,118 @@ test('the feed carries the owner\'s choice through a seam: platform-identity res
1783
1798
 
1784
1799
  /* ── the table's columns on FIXED tracks (task 1003348; the owner, 2026-08-29: "the
1785
1800
  columns in that projects table view dont look aligned") ── */
1786
- test('one column grammar: the table lays every row on one set of fixed tracks (each header over its cells), and the mine list shares the rule', () => {
1787
- assert.match(COSMOS, /\.tbl\{table-layout:fixed;min-width:860px;\}/, 'the header row\'s widths are the table\'s — no per-row content sizing');
1788
- // the task's other half — the Your projects list — landed with task 1003345 and is pinned above
1789
- // ('the mine list lays every row on one set of FIXED tracks'); it is asserted here too so the two
1790
- // halves of the grammar can never drift apart: fixed planet + doors, ratio tracks with no content minimum
1801
+ test('one row grammar: the every-project row and the Your-projects row lay the SAME five tracks', () => {
1802
+ // task 1003348 aligned the two views on one set of fixed tracks while one was
1803
+ // still a table; task 1003356 gave the table the mine row's whole grammar, so
1804
+ // the tracks must now match line for line or the two ledgers drift apart
1791
1805
  assert.match(COSMOS, /#view-mine \.proj\{display:grid;grid-template-columns:72px minmax\(0,1fr\);/, 'the mine row: the planet track is a width');
1792
1806
  assert.match(COSMOS, /#view-mine \.projBody\{display:grid;grid-template-columns:minmax\(0,1\.1fr\) minmax\(0,1\.3fr\) minmax\(0,1fr\) 232px;/, 'the mine row: name, facts and rail by ratio with no content minimum, the doors a width');
1793
- assert.match(COSMOS, /the same column grammar the every-project table takes/, 'the mine block names the table as its twin');
1794
- for (const [col, w] of [['colType', 88], ['colBuilders', 96], ['colTags', 168], ['colStatus', 224], ['colGo', 112]]) {
1795
- assert.match(COSMOS, new RegExp(`\\.tbl th\\.${col}\\{width:${w}px;\\}`), `${col} is a width, not a 1% hug`);
1796
- }
1797
- assert.doesNotMatch(COSMOS, /\.tbl \.col\w+\{width:1%/, 'no column hugs its widest cell any more');
1798
- assert.match(COSMOS, /\.tbl th\.colName\{padding-left:22px;\}/, 'the Project header sits over the name, past the dot and its gap');
1799
- assert.match(COSMOS, /\.tbl th\.right\{text-align:right;\}/, 'a right-aligned column\'s header is right-aligned too');
1800
- assert.match(HUB, /<th class="right colBuilders" data-sort="builders">/, 'the builders header names its column so its width can be set');
1801
- 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');
1807
+ assert.match(COSMOS, /\.tbl \.tblRow\{display:grid;\r?\n\s*grid-template-columns:72px minmax\(0,1\.1fr\) minmax\(0,1\.3fr\) minmax\(0,1fr\) 232px;/, 'the table row: the same five tracks, composed into one grid');
1808
+ assert.match(COSMOS, /grid-template-areas:"orb who facts runs doors" "orb who tag runs doors";/, 'runs where the mine row draws its rail, the tagline where it puts its address');
1809
+ assert.match(COSMOS, /the SAME five tracks in its \S\.tbl \.tblRow\S rule/, 'the mine block names the table as its twin');
1810
+ assert.doesNotMatch(COSMOS, /table-layout:fixed|\.tbl th|\.tbl td/, 'no table rule outlived the table');
1802
1811
  assert.match(COSMOS, /#sub-table-body\{overflow-x:auto;\}/, 'the wrap scrolls, never the page');
1803
1812
  });
1804
1813
 
1814
+ test('the sort left the column head for a control the keyboard and a screen reader can both read (task 1003356)', () => {
1815
+ // aria-sort is only valid on a columnheader; with no header row the order has
1816
+ // to be stated by the control itself and by a sentence in a live region
1817
+ assert.doesNotMatch(HUB, /aria-sort=/, 'aria-sort has no host once the header row is gone');
1818
+ assert.match(HUB, /<div class="tblSort" role="group" aria-label="Sort the projects">/, 'the two orders are one group');
1819
+ for (const k of ['name', 'builders']) {
1820
+ assert.match(HUB, new RegExp(`<button type="button" class="sortPill" data-sort="${k}"`), `${k} is a real button`);
1821
+ }
1822
+ assert.match(HUB, /<p class="finePrint" id="mapSorted" role="status" aria-live="polite">/, 'the live order is announced, not only drawn');
1823
+ assert.match(SCRIPT, /b\.setAttribute\('aria-pressed', on \? 'true' : 'false'\)/, 'pressed IS the live order');
1824
+ assert.match(SCRIPT, /mapSort\.dir = \(mapSort\.key === k\) \? -mapSort\.dir : 1;/, 'pressing the pressed one reverses, exactly as the header did');
1825
+ assert.match(SCRIPT, /\$\('mapSorted'\)\.textContent = 'Sorted by '/, 'the sentence is written from the same state the pills paint from');
1826
+ assert.match(COSMOS, /\.tblSort button\[aria-pressed="true"\]\{background:var\(--ink\);border-color:var\(--ink\);color:var\(--bg\);\}/, 'the pressed pill inverts to ink, the mine filters\' rule');
1827
+ assert.match(COSMOS, /\.tblSort button:focus-visible\{outline:2px solid var\(--accent\);outline-offset:2px;\}/, 'keyboard focus on the sort is visible');
1828
+ });
1829
+
1830
+ /* ── the card's backdrop (task 1003352): the owner's plate, beside the planet ── */
1831
+
1832
+ test('the kit stub refuses what the real route refuses, and its copied vocabulary cannot rot', async () => {
1833
+ // A kit module may not import core, so serve.js copies the closed lists. The
1834
+ // copy is what matters: it stayed permissive through a whole probe run while
1835
+ // the real PATCH would have 400'd on card_backdrop (task 1003352, grade round
1836
+ // 2), so the stub now validates — and this pins the copy to its source.
1837
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
1838
+ assert.match(stub, /function settingsRejection\(body\)/, 'the stub validates a settings body');
1839
+ assert.match(stub, /if \(bad\) return json\(res, 400, \{ error: \{ code: 'validation_failed'/, 'and answers the real code');
1840
+ const provisioning = createRequire(import.meta.url)('../modules/provisioning/provisioning.js');
1841
+ const block = (stub.match(/const STUB_VOCAB = \{([\s\S]*?)\n\};/) || [, ''])[1];
1842
+ assert.ok(block, 'STUB_VOCAB must keep its extractable shape');
1843
+ const real = { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB };
1844
+ for (const [key, values] of Object.entries(real)) {
1845
+ assert.match(block, new RegExp(`\\b${key}:`), `the stub must know ${key}`);
1846
+ if (key === 'planet_template') continue; // the template set is long: shape is checked, not membership
1847
+ for (const v of values) assert.match(block, new RegExp(`'${v}'`), `the stub's ${key} is missing ${v}`);
1848
+ }
1849
+ });
1850
+
1851
+ test('the card backdrop is the same KIND of key as the planet: a closed platform-drawn list on the same channel, pushing nothing', () => {
1852
+ const prov = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'), 'utf8');
1853
+ assert.match(prov, /const CARD_BACKDROPS = Object\.freeze\(\['nebula', 'dust', 'deepfield', 'chrome'\]\);/, 'a closed list, frozen');
1854
+ // riding PLANET_VOCAB is the whole point: planetChoice() loops it, the settings
1855
+ // route builds its validator and its settingKeys from it, and publicInstance
1856
+ // spreads it — so the key reaches the row, the read and the wire with no new branch
1857
+ assert.match(prov, /planet_template: PLANET_TEMPLATES, planet_accent: PLANET_ACCENTS, card_backdrop: CARD_BACKDROPS,/, 'it rides the platform-drawn vocab');
1858
+ // and the route's SCHEMA is that vocab, not a copy of it: a hand-written list
1859
+ // is what let card_backdrop reach settingKeys while strict validation still
1860
+ // 400'd every real save (grade round 2)
1861
+ const settingsRoute = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'), 'utf8');
1862
+ assert.match(settingsRoute, /const vocabSchema = \(vocab\) => Object\.fromEntries\(/, 'the schema is derived');
1863
+ assert.match(settingsRoute, /\.\.\.vocabSchema\(provisioning\.SETTINGS_VOCAB\),\r?\n\s*\.\.\.vocabSchema\(provisioning\.PLANET_VOCAB\),/, 'from both vocabularies');
1864
+ assert.doesNotMatch(settingsRoute, /planet_template: \{ type: 'string', enum: provisioning\.PLANET_VOCAB/, 'no hand-written key survives to drift');
1865
+ assert.match(prov, /if \(Object\.values\(choice\)\.some\(Boolean\)\) out\[r\.host\] = choice;/, 'a host that chose ONLY a backdrop still reaches the feed');
1866
+ const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
1867
+ assert.match(route, /card_backdrop: c\.card_backdrop \|\| null/, 'the wire always carries the key — null = nothing chosen');
1868
+ });
1869
+
1870
+ test("the card paints the owner's plate before the project's own picture, and a key the platform does not draw paints nothing", () => {
1871
+ assert.match(SCRIPT, /cardArt\(p\.isSelf \? '' : \(backdropPlate\(p\.card_backdrop\) \|\| safeHttpUrl\(p\.art_url\)\)\);/,
1872
+ "the plate wins; the project's own art is the fallback, which is all the card read before");
1873
+ const fn = (SCRIPT.match(/function backdropPlate\(key\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1874
+ assert.ok(fn, 'backdropPlate() must exist');
1875
+ assert.match(fn, /CARD_PLATES\[key\] \? '\/assets\/world\/' \+ CARD_PLATES\[key\] : ''/,
1876
+ 'an unknown key — a row written before a plate was retired — must never become a broken src');
1877
+ const plates = (SCRIPT.match(/var CARD_PLATES = \{([^}]*)\}/) || [, ''])[1];
1878
+ const offered = (SCRIPT.match(/var CARD_BACKDROPS = ([\s\S]*?);\r?\n/) || [, ''])[1];
1879
+ for (const k of ['nebula', 'dust', 'deepfield', 'chrome']) {
1880
+ assert.match(plates, new RegExp(`\\b${k}\\b`), `${k} has a plate`);
1881
+ assert.match(offered, new RegExp(`'${k}'`), `${k} is offered in the picker`);
1882
+ }
1883
+ });
1884
+
1885
+ test('every plate the picker offers ships as a real file with its provenance sidecar', () => {
1886
+ for (const k of ['nebula', 'dust', 'deepfield', 'chrome']) {
1887
+ const file = path.join(ROOT, 'public-landing', 'assets', 'world', `r4-card-${k}.jpg`);
1888
+ assert.ok(fs.existsSync(file), `r4-card-${k}.jpg must ship — the picker offers it`);
1889
+ const side = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8'));
1890
+ assert.equal(side.task, 1003352, 'the sidecar names the task that made it');
1891
+ assert.ok(side.model && side.prompt, 'and the model and the prompt, so it can be remade');
1892
+ assert.equal(side.aspect_ratio, '5:2', "a card band, not a field ground — the reason this round existed");
1893
+ }
1894
+ });
1895
+
1896
+ test("its card is the planet section's twin: its own heading, the same channel, no restart", () => {
1897
+ // beside the planet rather than inside it, so each heading stays true: one is
1898
+ // how the project is drawn everywhere, the other is one surface's backdrop
1899
+ assert.match(HUB, /<div class="card mSec" id="mCard">\s*<h2>Its card<\/h2>/, 'its own section');
1900
+ assert.ok(HUB.indexOf('id="mCard"') > HUB.indexOf('id="mPlanetPick"'), 'and it stands after the planet, where the owner asked for it');
1901
+ assert.match(SCRIPT, /body: JSON\.stringify\(\{ card_backdrop: mCard \|\| null \}\)/, "the save is the settings PATCH; '' hands the pick back as null");
1902
+ assert.match(SCRIPT, /manageInst\.card_backdrop = \(data\.planet \|\| \{\}\)\.card_backdrop \|\| null;/,
1903
+ 'the reply is a receipt — the page repaints from the row, never from words in the reply');
1904
+ assert.match(SCRIPT, /renderPick\(inst\);\r?\n\s*renderCardPick\(inst\);/, 'both sections fill from the same manage read');
1905
+ const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
1906
+ assert.ok(states.states['in-manage-card'], 'the section has a rendered state');
1907
+ const probes = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.probes.json'), 'utf8'));
1908
+ const names = probes.probes.map((p) => p.name);
1909
+ assert.ok(names.some((n) => n.startsWith('manage flows: its card (task 1003352)')), 'the save probe');
1910
+ assert.ok(names.some((n) => n.includes("its card — handing it back")), 'the hand-back probe');
1911
+ });
1912
+
1805
1913
  /* ── the create wizard in the world (task 1003346; direction record §15, the pick: v1 the
1806
1914
  rail above, the bongo pair kept). Two sheets carry it: cosmos.css draws the FRAME by scope
1807
1915
  (#view-new), projects.html's own wizard block draws the wizard's structures. ── */
@@ -600,20 +600,22 @@ test('the done panel’s progress region is stable, change-only, and says each s
600
600
  assert.match(fn('stripMarkup'), /role="status" aria-live="polite"/, 'the mine-card strip is left alone this pass');
601
601
  });
602
602
 
603
- test('the map table sorts from the keyboard and an owned row’s name is a real link', () => {
604
- assert.match(HUB, /<th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project<\/button><\/th>/);
605
- assert.match(HUB, /<th class="right colBuilders" data-sort="builders"><button type="button" class="thSort">Builders<\/button><\/th>/);
606
- assert.match(SCRIPT, /var btn = th\.querySelector\('button'\);\n\s*if \(!btn\) return;\n\s*btn\.addEventListener\('click'/, 'the listener sits on the button, not the th');
607
- assert.doesNotMatch(SCRIPT, /th\.style\.cursor/, 'no JS-set cursor on the th');
608
- assert.match(COSMOS, /\.tbl th button\{all:unset;cursor:pointer;\}/);
609
- assert.match(COSMOS, /\.tbl th button:focus-visible\{outline:2px solid var\(--accent\);outline-offset:2px;\}/, 'keyboard focus on the sort button is visible');
610
- const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
603
+ test('the map list sorts from the keyboard and an owned row’s name is a real link', () => {
604
+ // the sort was a button inside a <th> until task 1003356 took the header row
605
+ // away with the table; it is a pill in a group now, and still a real button
606
+ assert.match(HUB, /<button type="button" class="sortPill" data-sort="name" aria-pressed="true">name <span class="dir">a to z<\/span><\/button>/);
607
+ assert.match(HUB, /<button type="button" class="sortPill" data-sort="builders" aria-pressed="false">builders<\/button>/);
608
+ assert.match(SCRIPT, /document\.querySelectorAll\('\.tblSort button\[data-sort\]'\)\.forEach\(function \(btn\) \{\r?\n\s*btn\.addEventListener\('click'/, 'the listener sits on the button itself');
609
+ assert.doesNotMatch(SCRIPT, /th\.style\.cursor/, 'no JS-set cursor on a header that no longer exists');
610
+ assert.match(COSMOS, /\.tblSort button\{[^}]*min-height:36px;/, 'the sort pill clears the 24px floor on its own, with no all:unset to undo');
611
+ assert.match(COSMOS, /\.tblSort button:focus-visible\{outline:2px solid var\(--accent\);outline-offset:2px;\}/, 'keyboard focus on the sort button is visible');
612
+ const generated = (SCRIPT.match(/return '<li class="tblRow" data-generated([\s\S]*?)'<\/li>';/) || [, ''])[1];
611
613
  assert.match(generated, /<a class="ptitle" data-manage="' \+ esc\(String\(p\._inst\.id\)\) \+ '" href="\?view=manage&id=' \+ esc\(String\(p\._inst\.id\)\) \+ '">/, 'an owned row’s title is a manage anchor');
612
614
  const rows = SCRIPT.slice(SCRIPT.indexOf("$('mapRows').addEventListener('click'"));
613
615
  const branch = rows.indexOf("closest('a[data-manage]')");
614
616
  const bail = rows.indexOf("if (ev.target.closest && ev.target.closest('a')) return;");
615
617
  assert.ok(branch > 0 && bail > branch, 'the manage branch runs before the anchor bail-out');
616
- assert.match(HUB, /Sort by Project or Builders\./, 'the hint names the two sortable columns');
618
+ assert.match(HUB, /Sorted by name, A to Z\./, 'the live order is stated where the hint used to name the columns');
617
619
  assert.match(COSMOS, /\.tbl \.ptitle\{[^}]*text-decoration:none;\}/, 'the link wears the title’s ink, not the UA underline');
618
620
  });
619
621
 
@@ -166,10 +166,13 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
166
166
  assert.equal(r.status, 200, r.raw);
167
167
  assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
- assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
169
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
171
171
  platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
172
172
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
173
+ // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
174
+ // closed list, no default, no push — so it rides the same vocab
175
+ card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
173
176
  });
174
177
  });
175
178
 
@@ -177,7 +180,7 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
177
180
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'purple' } } });
178
181
  const r = await get('/provisioning/instances/18/settings');
179
182
  assert.equal(r.status, 200, r.raw);
180
- assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null });
183
+ assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
181
184
  assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
182
185
  });
183
186
 
@@ -268,7 +271,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
268
271
  reset();
269
272
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
270
273
  assert.equal(r.status, 200, r.raw);
271
- assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
274
+ assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
272
275
  assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
273
276
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
274
277
  assert.equal(r.body.push.queued, false);
@@ -283,7 +286,7 @@ await ta('null hands the pick back to the platform (stored as null, read back as
283
286
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } } });
284
287
  const r = await patch('/provisioning/instances/18/settings', { planet_template: null, planet_accent: null });
285
288
  assert.equal(r.status, 200, r.raw);
286
- assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
289
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
287
290
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: null, planet_accent: null } }]);
288
291
  assert.equal(enqueueCalls, 0);
289
292
  });
@@ -316,6 +319,48 @@ await ta('a policy change and a planet change in one save: the push queues for t
316
319
  assert.equal(enqueueCalls, 1);
317
320
  });
318
321
 
322
+ await ta('the owner picks a card backdrop: the REAL router accepts it, echoes it, and still queues no push (task 1003352)', async () => {
323
+ // the regression this exists for: card_backdrop was added to PLANET_VOCAB (so
324
+ // settingKeys copied it) while the route's validator was a hand-written list
325
+ // that did not declare it — and strict mode 400s an undeclared field, so every
326
+ // real save from the manage page failed while the kit's probes stayed green
327
+ reset();
328
+ const r = await patch('/provisioning/instances/18/settings', { card_backdrop: 'nebula' });
329
+ assert.equal(r.status, 200, r.raw);
330
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: 'nebula' });
331
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { card_backdrop: 'nebula' } }]);
332
+ assert.equal(r.body.push.queued, false, 'the card is the platform\'s to draw — nothing is pushed');
333
+ assert.equal(r.body.push.restart, false);
334
+ assert.equal(enqueueCalls, 0, 'no settings-apply intent for a backdrop change');
335
+ });
336
+
337
+ await ta('null hands the card backdrop back to the platform, like the planet', async () => {
338
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { card_backdrop: 'chrome' } } });
339
+ const r = await patch('/provisioning/instances/18/settings', { card_backdrop: null });
340
+ assert.equal(r.status, 200, r.raw);
341
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
342
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { card_backdrop: null } }]);
343
+ });
344
+
345
+ await ta('an off-vocabulary card backdrop → 400 validation_failed (a closed list, not a free string)', async () => {
346
+ reset();
347
+ const r = await patch('/provisioning/instances/18/settings', { card_backdrop: 'sunset' });
348
+ assert.equal(r.status, 400, r.raw);
349
+ assert.equal(r.body.error.code, 'validation_failed');
350
+ assert.equal(updateCalls.length, 0);
351
+ });
352
+
353
+ await ta('the route\'s schema IS its vocabularies — every declared key is accepted, so one can never drift out', async () => {
354
+ // the guard against this whole class: a key added to SETTINGS_VOCAB or
355
+ // PLANET_VOCAB must be accepted by the validator without anyone remembering
356
+ const every = { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB };
357
+ for (const [key, values] of Object.entries(every)) {
358
+ reset();
359
+ const r = await patch('/provisioning/instances/18/settings', { [key]: values[0] });
360
+ assert.equal(r.status, 200, `${key}: ${r.raw}`);
361
+ }
362
+ });
363
+
319
364
  await ta('an off-vocabulary planet → 400 validation_failed (a colour is a token NAME, never a hex)', async () => {
320
365
  reset();
321
366
  for (const body of [{ planet_accent: '#fb9569' }, { planet_accent: 'purple' }, { planet_template: 'cube-gold-wash' }]) {