@bongos/core 1.19.400 → 1.19.402

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 +48 -38
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +3 -2
  6. package/docs/copy-inventory.md +233 -232
  7. package/docs/copy-registry.json +293 -284
  8. package/docs/design/projects-hub-direction-v2.md +38 -0
  9. package/docs/recipes/ui-look-before-you-ship.md +1 -1
  10. package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
  11. package/modules/provisioning/provisioning.js +68 -8
  12. package/modules/provisioning/routes/provisioning.js +50 -23
  13. package/modules/ui-design/kit/lib.js +6 -2
  14. package/package-lock.json +2 -2
  15. package/package.json +1 -1
  16. package/public-landing/assets/cosmos.css +90 -35
  17. package/public-landing/index.html +13 -15
  18. package/public-landing/index.probes.json +8 -5
  19. package/public-landing/projects.html +49 -20
  20. package/public-landing/projects.probes.json +17 -10
  21. package/public-landing/projects.states.json +1 -1
  22. package/scripts/gds/provision-net.js +33 -0
  23. package/scripts/gds/provision-units.js +18 -0
  24. package/scripts/gds/provision.js +97 -5
  25. package/src/module-api.js +5 -1
  26. package/tests/landing_page.mjs +8 -4
  27. package/tests/projects_hub.mjs +28 -1
  28. package/tests/projects_hub_pre_uat.mjs +28 -2
  29. package/tests/provision.mjs +185 -12
  30. package/tests/provisioning_default_domain.mjs +5 -3
  31. package/tests/provisioning_domain_attach.mjs +166 -17
  32. package/tests/public_landing_projects.mjs +5 -1
  33. package/tests/ui_design_kit.mjs +5 -4
@@ -106,3 +106,41 @@ The criterion `apex-hub-chrome-and-card` moves to the builds: task 1003300 (the
106
106
  - **Task 1003300** builds v2's bar, menus (Sky / Table / Your projects / New project) and search, with Only mine beside the search and the toolbar retired, into `public-landing/projects.html` + `cosmos.css` (the hub's sheet moves onto the world's derived tier the way `world.css` did, keeping `--line` and the status vocabulary), retargets the tests in §6, regenerates the copy registry.
107
107
  - **Task 1003301** builds the picked card and its ladder, adds the enrichment fields (§6) inside the same `Promise.allSettled` with the shared null object, gates the hall entry on the new hall-address field and membership, and keeps the orbs and the hover card untouched.
108
108
  - The unpicked versions stay on disk as built and verified; what they leave behind is listed per version in §3.
109
+
110
+ ## 9. The owner's review of the shipped chrome, and the table in the world (task 1003332)
111
+
112
+ **Date:** 2026-08-28 · three corrections from the owner's screenshot review of `/projects` (the Projects band open, the table view). Each overrides §7 where they conflict. Rendered through the kit before and after (`node modules/ui-design/kit/render.js --page /projects`, both modes, 1440 / 390 / 320; the landing the same) — ALL CLEAN throughout; the probes 26/26 on the hub and 11/11 on the landing.
113
+
114
+ ### 9.1 About and Docs leave the Projects band: the footer row
115
+
116
+ They are site pages, not project views. Two placements were rendered as a before/after on both pages (dark and light, 1440 and 390): **(A)** beside the identity slot as two lowercase text triggers (`about docs projects`), folding away under 900; **(B)** the footer row the landing already had (About · Docs · Privacy · Terms · Contact · Builders' hall). **Pick: B, the footer, on both pages.** One home at every width (A needs the footer anyway under 900 — two homes for two links); the bar stays the Operate bar given 3 asked for ("this way we dont clutter the bar"); About and Docs are the same kind of thing as Privacy / Terms / Contact. Consequences: the `bandMore` rung and its rules left both sheets; the hub's footer row wraps (`flex-wrap`) and `--foot-h` was re-measured with six links in it (136px under 420, 154px under 340, so the sky still fits the viewport); the About anchor stays the landing's lifecycle band (`/#about` from the hub, `#about` on the landing) until goal 1000063's page takes it. Probes retargeted: the band's last item is New project, the signed-out band is three cells, the footer carries both hrefs on both pages.
117
+
118
+ ### 9.2 The bands overlay; the page does not reflow
119
+
120
+ `.menuBand` is `position:absolute; left:0; right:0; top:var(--top-h); z-index:49` against the document in both sheets. Everything else the disclosure did stays: full width on `bg-card` closed by one hairline, `aria-expanded` + `aria-controls` and no menu role, Escape / arrows / Enter / Tab-out / click-outside, one open at a time, the 220ms rise + fade on the world's ease-out, dead under the kill switch. The stack: band 49 < bar 50 < the folded search row 51 < the card scrim 60. The kit gained a `top[[sel,px]]` expectation for exactly this proof; the probes assert `#hubMain` at 64px (60 at 390) and the landing's `#main` at 72px both before and after a band opens. DESIGN.md's Navigation paragraph now says overlay.
121
+
122
+ ### 9.3 Found on the way: the Table deep link landed on the sky
123
+
124
+ `setView()` rewrites the URL to `?view=map` at boot, and the shipped boot read `sub=table` *after* it — so the landing's Table item (and any shared `/projects?view=map&sub=table`) opened the sky. The boot query is now read once, before `setView`; a probe opens the deep link and expects the table on stage with Table current; `tests/projects_hub.mjs` pins the order.
125
+
126
+ ### 9.4 The table in the world: one grammar, three mastheads
127
+
128
+ **Shared by every treatment (built):** the panel is gone — no `--card` ground, no 20px radius, no `--shadow-card`. The rows are DESIGN.md's ledger rows: 1px `rule-soft` hairlines, 17px of vertical air, the header a `rule` hairline under mono labels in `--label-face`. Builders is set in the figure voice (Manrope 300, 22px, tabular); the project name at 17px/500; the relationship pill is a mono label on a hairline — it states a fact and is not pressable, so the accent left it (the One Warm Thing Rule, §5); the row's door is `open →`, lowercase `accent-deep` text with an underline (the footer-link grammar, 24px tall) in place of the ghost pill; a row's hover is its hairline taking ink, the band cells' grammar, not an accent tint; the status chip keeps its fixed vocabulary; the view title is the display voice (`every project, as rows`). The objects are the landing's alpha **cutouts in both modes** — the view wrap is a stacking context, so a black plate under `lighten` could not reach the page ground the way the hero's do, and a cutout needs no blend — with the world's one contact shadow in light and none in dark. The plate tokens (`--plate-*-cut`) joined cosmos's derived tier by root-absolute path; the mode mapping (`--mast-shadow`, `--mast-scrim`) is in both dark blocks.
129
+
130
+ Three mastheads, rendered dark and light at 1440 / 390 / 320 (ALL CLEAN) and measured:
131
+
132
+ | # | Masthead | Height 1440 / 390 | Honest risk |
133
+ |---|---|---|---|
134
+ | **T1 the ledger** | the title at the left, the set's small bead (the landing's `.o3` crop) at the right | 218 / 121 px | the least new information — a chrome object beside a word; also the quietest and the cheapest |
135
+ | **T2 the field** | the sweeping form (the landing's `.form` crop) crossing the masthead behind the title, its left edge masked, a black scrim under the title in dark | 300 / 150 px (+5px over the viewport at 1440) | the crop is cut flat by the wrap's right edge and the masthead's bottom: on black the bottom edge shows, on paper both read as a torn photograph — DESIGN.md's own warning about a cropped form; a pick needs a bottom mask and a right feather, and the title wraps to two lines at 14ch |
136
+ | **T3 the specimen** | the bongo pair (the hero object, whole) at the left, the title beside it, two leader-line callouts with the rows' own numbers (projects, builders) — the landing's hero grammar | 294 / 283 px (+360px of scroll at 390) | the most height on a phone; the figures are live code, and the builders figure summed the rows before the platform's own count had landed (13, not 26) — a pick wires it to refresh when `#selfBuilders` lands and pins it in `tests/projects_hub.mjs` |
137
+
138
+ **Recommendation: T1.** It carries the world's material into the view at the smallest cost, holds at 390 and 320 without an edge problem in either mode, and is the treatment the mine view and the wizard can repeat (a title and one object). The owner picked from the rendered screens, not from this table.
139
+
140
+ **The owner's pick (2026-08-28): T1, the ledger — with the title cut to two words.** "t1, but I dont want the ', as rows'": the view title is `every project`. The two unpicked mastheads, the `?tbl=` switch, the three render-round states and T3's figure writes left with the pick; `tests/projects_hub.mjs` pins the masthead, the ledger rules, the row's door and the absence of the switch.
141
+
142
+ ### 9.5 What carries forward
143
+
144
+ - Task 1003301 (the poster card): the ledger grammar above is the card's facts strip, too.
145
+ - Task 1003319 (the planets): the masthead object rides the same crop maths the orbs will take.
146
+ - The mine view and the wizard are the next views to take a masthead in the picked treatment.
@@ -69,7 +69,7 @@ Then a state's own `expect` (a band open, a dialog present, a field's value, a c
69
69
  }
70
70
  ```
71
71
 
72
- Actions: `click`, `focus`, `press`, `fill`, `wait`, `clickAt x y`, `clickIfVisible`, `dispatchClick`, `check`, `back`, `forward`. Expectations: `expanded`, `dialog`, `visible[]`, `hidden[]`, `open`, `closed`, `activeId`, `activeText`, `expandedIds[]`, `param{}`, `attr[[sel,name,value]]`, `text[[sel,value]]`, `href[[sel,value]]`, `count[[sel,n]]`, `value[[sel,v]]`, `hasClass[[sel,class,bool]]`, `checked`, `noOverflow`. The kit validates the file before it opens a browser and names every problem.
72
+ Actions: `click`, `focus`, `press`, `fill`, `wait`, `clickAt x y`, `clickIfVisible`, `dispatchClick`, `check`, `back`, `forward`. Expectations: `expanded`, `dialog`, `visible[]`, `hidden[]`, `open`, `closed`, `activeId`, `activeText`, `expandedIds[]`, `param{}`, `attr[[sel,name,value]]`, `text[[sel,value]]`, `href[[sel,value]]`, `count[[sel,n]]`, `value[[sel,v]]`, `hasClass[[sel,class,bool]]`, `checked`, `noOverflow`, `top[[sel,px]]` (an element's viewport top — assert it closed, open a band, assert it again: the proof an overlay did not push the page). The kit validates the file before it opens a browser and names every problem.
73
73
 
74
74
  The worked examples: `public-landing/projects.states.json` (the hub's thirteen), `public-landing/index.states.json` (the landing's four), `modules/hall-ui/public/ranks.states.json` (a second surface under its own prefix — it renders **red**, deliberately: the hall predates the 24px floor and Ranks has no `<h1>`, and the states file declares the shell's un-fixtured feeds rather than hiding the findings; idea 1000863 carries them).
75
75
 
@@ -0,0 +1,35 @@
1
+ -- provisioning_012_domain_repoint.sql — the addresses a re-point still owes a
2
+ -- release on (task 1003237, goal 1000058: a mistyped bring-your-own domain can
3
+ -- be corrected).
4
+ --
5
+ -- WHY. Re-pointing an instance overwrites `domain`, so the address it moved OFF
6
+ -- is gone from the row the instant the web tier records the new one — and the
7
+ -- runner, which is the only tier holding a DNS token, would never learn that a
8
+ -- hostname it once UPSERTed at the platform's IP is now abandoned. ADR 0181 §2
9
+ -- keeps a torn-down project's address deliberately; an address the owner has
10
+ -- explicitly moved off is the opposite case, and leaving its A record aimed at
11
+ -- platform infra is the leak this column closes.
12
+ --
13
+ -- An ARRAY, not a single column: a founder correcting a typo can correct it
14
+ -- twice before the runner drains the first attach, and a scalar would silently
15
+ -- drop the middle address. The runner releases each entry and empties the list.
16
+ --
17
+ -- Only a BYO address is ever appended here, and only from a state that actually
18
+ -- STOOD UP (DOMAIN_RELEASE_FROM = {error, active}). The platform-assigned
19
+ -- <slug>.<apex> is the platform's own name for this slug and the revive path
20
+ -- re-uses it (ADR 0181 §2); a 'requested' instance never had an A record written
21
+ -- for the address it is leaving, so parking that name would CREATE a junk record
22
+ -- rather than release one.
23
+ --
24
+ -- BOUNDED at MAX_RELEASED_DOMAINS by updateInstanceDomain's own SQL. The runner
25
+ -- drains this list synchronously against the SHARED control-plane Cloudflare
26
+ -- token, so an unbounded array would be a platform-wide resource-exhaustion
27
+ -- vector reachable by any owner re-pointing their own project in a loop.
28
+ --
29
+ -- OVERLAY, NOT A STATE (the provisioning_006 rule): defaulted, never written by
30
+ -- the lifecycle. Empty = nothing outstanding. Safe to re-run.
31
+ ALTER TABLE provisioning_instances
32
+ ADD COLUMN IF NOT EXISTS released_domains text[] NOT NULL DEFAULT '{}';
33
+
34
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_012_domain_repoint')
35
+ ON CONFLICT (version) DO NOTHING;
@@ -77,6 +77,17 @@ const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 't
77
77
  // 'requested'/'error' only record it — the (re)provision run wires the domain
78
78
  // itself. Mid-flight or torn-down states refuse truthfully.
79
79
  const DOMAIN_ATTACH_FROM = new Set(['requested', 'error', 'active']);
80
+ // The states in which a re-point has an address worth RELEASING (task 1003237).
81
+ // A 'requested' instance has never stood up, so nothing ever wrote an A record for
82
+ // the address it is leaving — parking that name at the blackhole would CREATE a
83
+ // junk record in the owner's zone rather than release one. An 'error' row is
84
+ // frequently a PARTIAL standup that did write it (ADR 0181 §1), so it counts.
85
+ const DOMAIN_RELEASE_FROM = new Set(['error', 'active']);
86
+ // How many abandoned addresses one instance may have queued for release at once.
87
+ // The runner drains them synchronously against the shared control-plane Cloudflare
88
+ // token, so this is a platform-wide bound, not a per-owner nicety. A real
89
+ // correction queues one; the cap only ever binds on abuse.
90
+ const MAX_RELEASED_DOMAINS = 8;
80
91
  // The ONE state a settings push is meaningful from (task 1003140). A pre-active
81
92
  // instance needs no push — standup writes the same values from the same
82
93
  // effectiveSettings() row — and a torn-down one has nothing to restart. The
@@ -468,13 +479,61 @@ async function updateInstanceSettings(db, id, patch) {
468
479
  return rows[0] || null;
469
480
  }
470
481
 
471
- // Record an instance's domain — the ONLY column this touches (task 1002692). Status
472
- // changes stay setInstanceStatus's job; keeping this narrow means the domain-attach
473
- // route can never drift an instance's lifecycle as a side effect.
474
- async function updateInstanceDomain(db, id, domain) {
482
+ // Record an instance's domain — the ONLY columns this touches (task 1002692;
483
+ // `releasing` added by task 1003237). Status changes stay setInstanceStatus's job;
484
+ // keeping this narrow means the domain-attach route can never drift an instance's
485
+ // lifecycle as a side effect.
486
+ //
487
+ // `releasing` is the BYO address this instance is moving OFF, appended to the
488
+ // release queue in the SAME statement as the new domain — a re-point that
489
+ // recorded the new address but crashed before queueing the old one would leave a
490
+ // hostname pointed at platform infra with nothing left in the row to say so.
491
+ // Appended, never replaced: two corrections before the runner drains the first
492
+ // must both get released (provisioning_012). null/undefined ⇒ the queue is left
493
+ // exactly as it stands, so a first attach touches nothing.
494
+ //
495
+ // CAPPED at MAX_RELEASED_DOMAINS, enforced here in SQL rather than trusted from
496
+ // the caller. The runner drains this list SYNCHRONOUSLY, one shared-Cloudflare-token
497
+ // call per entry, so an unbounded array is a resource-exhaustion vector against the
498
+ // whole platform — not just the owner's own project — and every re-point is a write
499
+ // an authenticated owner can repeat. Past the cap the append is dropped: the entries
500
+ // already queued are the ones most likely to name a hostname the platform really
501
+ // wired, and a real correction never comes close to the cap (the route also throttles
502
+ // the caller). Dropping is the deliberate failure mode — growing without bound is not.
503
+ //
504
+ // A CHANGED domain also clears the liveness overlay. The overlay is a verdict on
505
+ // an ADDRESS (task 1002696), so carrying it across a re-point would let the card
506
+ // say "answering at <the new address>, last answered 2 minutes ago" when only the
507
+ // OLD address was ever probed — the exact false claim the F16 rule forbids. NULL
508
+ // is "never probed", which is the truth for a name nothing has asked yet; the
509
+ // sweep re-probes within minutes. Guarded in SQL on the value actually changing,
510
+ // so an idempotent re-record can never blank a good probe.
511
+ async function updateInstanceDomain(db, id, domain, releasing = null) {
512
+ const host = releasing ? String(releasing).toLowerCase() : null;
475
513
  const { rows } = await db.query(
476
- `UPDATE provisioning_instances SET domain = $2, updated_at = now() WHERE id = $1 RETURNING *`,
477
- [id, domain]
514
+ `UPDATE provisioning_instances
515
+ SET domain = $2,
516
+ released_domains = CASE
517
+ WHEN $3::text IS NULL OR $3 = ANY(released_domains) THEN released_domains
518
+ WHEN coalesce(array_length(released_domains, 1), 0) >= $4 THEN released_domains
519
+ ELSE array_append(released_domains, $3)
520
+ END,
521
+ last_verified_at = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_at END,
522
+ last_verified_up = CASE WHEN domain IS DISTINCT FROM $2 THEN NULL ELSE last_verified_up END,
523
+ updated_at = now()
524
+ WHERE id = $1 RETURNING *`,
525
+ [id, domain, host, MAX_RELEASED_DOMAINS]
526
+ );
527
+ return rows[0] || null;
528
+ }
529
+
530
+ // Empty the release queue once the runner has actually released every address on
531
+ // it (task 1003237). Same narrowness rule as clearErrorNote: no status, no
532
+ // updated_at — draining a queue is not an owner-visible change to the project.
533
+ async function clearReleasedDomains(db, id) {
534
+ const { rows } = await db.query(
535
+ `UPDATE provisioning_instances SET released_domains = '{}' WHERE id = $1 RETURNING id`,
536
+ [id]
478
537
  );
479
538
  return rows[0] || null;
480
539
  }
@@ -896,7 +955,8 @@ module.exports = {
896
955
  // constants
897
956
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
898
957
  settingsEnvLines, settingsEnvVars,
899
- PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
958
+ PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, DOMAIN_RELEASE_FROM, MAX_RELEASED_DOMAINS,
959
+ SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
900
960
  RESTART_FROM, RESTART_SHAPES,
901
961
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
902
962
  SETTINGS_VOCAB, DEFAULT_SETTINGS,
@@ -910,7 +970,7 @@ module.exports = {
910
970
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
911
971
  // data access — verification overlay (task 1002696)
912
972
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
913
- createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings,
973
+ createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
914
974
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
915
975
  recordEvent, instanceStateForSlug, costLedger,
916
976
  // data access — GitHub App Manifest attempts (task 2080)
@@ -265,6 +265,17 @@ module.exports = function provisioningRoutes() {
265
265
  max: 30,
266
266
  key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
267
267
  });
268
+ // Limiter for the domain attach/re-point write (task 1003237). Signed-in and
269
+ // own-scoped, but since a re-point became possible each call can queue an
270
+ // address the RUNNER later releases with a call against the SHARED control-plane
271
+ // Cloudflare token — so an unthrottled owner can spend a platform-wide budget,
272
+ // not merely their own. Keyed on the BUILDER for the same reason as the slug
273
+ // check: per-IP would starve co-located builders while an attacker rotates IPs.
274
+ // A founder gives a project an address, or corrects it, a handful of times ever.
275
+ const domainWriteRateLimit = makeAskRateLimiter({
276
+ max: 10,
277
+ key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
278
+ });
268
279
 
269
280
  // Route ORDER is deliberate (the box.js precedent): the Archon-gated routes are
270
281
  // declared FIRST, then the any-builder routes. The route-rank auditor
@@ -859,9 +870,11 @@ module.exports = function provisioningRoutes() {
859
870
  // Validates + records the domain; for an already-ACTIVE instance it also enqueues
860
871
  // a 'domain-attach' intent the control-plane runner drains (web tier holds no
861
872
  // cloud tokens — ADR 0111 §2). A requested/error instance only records it: the
862
- // (re)provision run wires the domain itself. Re-pointing an existing address is
863
- // out of scope — refuse truthfully. Body: { domain (required) }.
864
- router.post('/provisioning/instances/:id/domain', auth.requireBuilder, async (req, res) => {
873
+ // (re)provision run wires the domain itself. A bring-your-own address may be
874
+ // RE-POINTED (task 1003237, ADR 0200): the same guards the first attach applies
875
+ // decide it, and the address it moved off is queued for release.
876
+ // Body: { domain (required) }.
877
+ router.post('/provisioning/instances/:id/domain', auth.requireBuilder, domainWriteRateLimit, async (req, res) => {
865
878
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
866
879
  if (!id) return;
867
880
  if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
@@ -919,15 +932,20 @@ module.exports = function provisioningRoutes() {
919
932
  return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
920
933
  message: 'This instance already has that address.' });
921
934
  }
922
- // A platform-ASSIGNED address may be upgraded away to a bring-your-own
923
- // domain — that upgrade path is half the task-1002704 decision (the old
924
- // record lingers in DNS, the idea-1000710 class). A genuine BYO address
925
- // stays un-re-pointable, as before.
926
- if (current && current !== assignedDefault) {
927
- return res.fail('domain_already_set', { status: 409,
928
- message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
929
- details: { current: inst.domain } });
930
- }
935
+ // A mistyped bring-your-own address can be CORRECTED (task 1003237, owner
936
+ // decision 2026-08-28 / ADR 0200): the address is the founder's, and the
937
+ // guards above — reserved, in-apex squat — plus the uniqueness check below
938
+ // are exactly the ones the first attach applied, so a re-point needs no
939
+ // weaker rule and gets no stronger one. The address it moves OFF is queued
940
+ // for release (`releasing`): leaving a hostname UPSERTed at platform infra
941
+ // for a project that no longer answers there is the leak ADR 0181 §2 named.
942
+ // Only a BYO address is released, and only from a state that actually STOOD
943
+ // UP: a platform-ASSIGNED <slug>.<apex> is the platform's own name for this
944
+ // slug and the revive path re-uses it (ADR 0181 §2), while a 'requested'
945
+ // instance never had an A record written for the address it is leaving —
946
+ // parking that name would CREATE a junk record rather than release one.
947
+ const releasing = current && current !== assignedDefault
948
+ && provisioning.DOMAIN_RELEASE_FROM.has(inst.status) ? current : null;
931
949
  const holder = await provisioning.getInstanceByDomain(pool, domain);
932
950
  if (holder && String(holder.id) !== String(inst.id)) {
933
951
  return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
@@ -938,8 +956,9 @@ module.exports = function provisioningRoutes() {
938
956
  details: { status: inst.status } });
939
957
  }
940
958
  // Record BEFORE enqueueing: the runner reads the row's domain, so the intent
941
- // must never race ahead of the column it wires.
942
- await provisioning.updateInstanceDomain(pool, id, domain);
959
+ // must never race ahead of the column it wires. The release queue rides the
960
+ // SAME statement — see updateInstanceDomain.
961
+ await provisioning.updateInstanceDomain(pool, id, domain, releasing);
943
962
  let queued = false;
944
963
  if (inst.status === 'active') {
945
964
  const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
@@ -957,22 +976,30 @@ module.exports = function provisioningRoutes() {
957
976
  instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
958
977
  detail: `domain ${domain} recorded (attach already queued)`, actor: 'api:self',
959
978
  }).catch(() => {});
960
- return res.json({ ok: true, changed: true, queued: false, domain,
979
+ return res.json({ ok: true, changed: true, queued: false, domain, replaced: releasing,
961
980
  message: 'That address is already being wired for this instance.' });
962
981
  }
963
982
  }
983
+ const moved = releasing ? ` (moved off ${releasing}, queued for release)` : '';
964
984
  await provisioning.recordEvent(pool, {
965
985
  instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
966
- detail: queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`,
986
+ detail: (queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`) + moved,
967
987
  actor: 'api:self',
968
988
  }).catch(() => {});
969
- res.json({ ok: true, changed: true, queued, domain,
970
- // State-only sentence (task 1002720 / F16): the web tier holds no cloud
971
- // tokens and cannot know whether a DNS hook exists on the runner, so
972
- // "it will answer there shortly" was a promise it could not keep.
973
- message: queued
974
- ? 'Your instance is being wired to its new address — this card will show whether it answers.'
975
- : 'Address recorded — it will be wired when the instance is provisioned.' });
989
+ // State-only sentences (task 1002720 / F16): the web tier holds no cloud
990
+ // tokens and cannot know whether a DNS hook exists on the runner, so "it
991
+ // will answer there shortly" was a promise it could not keep. The re-point
992
+ // sentences name the old address in the same voice — what they say has
993
+ // ALREADY happened is the recording, not the unwiring. One lookup rather
994
+ // than a nested ternary, so each of the four states reads as its own line.
995
+ const MESSAGES = {
996
+ 'queued:repoint': `Your project is being wired to ${domain}; ${releasing} is being let go.`,
997
+ 'queued:first': 'Your instance is being wired to its new address — this card will show whether it answers.',
998
+ 'recorded:repoint': `Address changed to ${domain} — it will be wired, and ${releasing} let go, when the instance is provisioned.`,
999
+ 'recorded:first': 'Address recorded — it will be wired when the instance is provisioned.',
1000
+ };
1001
+ res.json({ ok: true, changed: true, queued, domain, replaced: releasing,
1002
+ message: MESSAGES[`${queued ? 'queued' : 'recorded'}:${releasing ? 'repoint' : 'first'}`] });
976
1003
  } catch (err) {
977
1004
  log.error('[provisioning] POST /provisioning/instances/:id/domain', err);
978
1005
  res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
@@ -123,7 +123,9 @@ async function runActions(page, actions = []) {
123
123
  // A state may EXPECT (render.js) and a probe must EXPECT (probe.js). Selectors are
124
124
  // evaluated in the page; scalars compared here. `open`/`visible` = present and not
125
125
  // hidden; `closed`/`hidden` = absent or hidden.
126
- const EXPECT_KEYS = Object.freeze(['expanded', 'dialog', 'visible', 'hidden', 'open', 'closed', 'activeId', 'activeText', 'expandedIds', 'param', 'attr', 'text', 'href', 'count', 'checked', 'value', 'hasClass', 'noOverflow']);
126
+ // `top` pins an element's viewport top in px (rounded) — the probe that proves an overlay
127
+ // did not push the page (task 1003332): assert it closed, open it, assert it again.
128
+ const EXPECT_KEYS = Object.freeze(['expanded', 'dialog', 'visible', 'hidden', 'open', 'closed', 'activeId', 'activeText', 'expandedIds', 'param', 'attr', 'text', 'href', 'count', 'checked', 'value', 'hasClass', 'noOverflow', 'top']);
127
129
  function validateExpect(expect, at) {
128
130
  if (expect === undefined) return [];
129
131
  if (!expect || typeof expect !== 'object' || Array.isArray(expect)) return [`${at}: an object`];
@@ -134,7 +136,7 @@ function validateExpect(expect, at) {
134
136
  for (const k of ['expanded', 'dialog', 'noOverflow']) if (expect[k] !== undefined && typeof expect[k] !== 'boolean') errors.push(`${at}.${k}: a boolean`);
135
137
  if (expect.expandedIds !== undefined && !Array.isArray(expect.expandedIds)) errors.push(`${at}.expandedIds: an array of ids`);
136
138
  if (expect.param !== undefined && (typeof expect.param !== 'object' || Array.isArray(expect.param))) errors.push(`${at}.param: an object of name → value (null = absent)`);
137
- for (const k of ['attr', 'text', 'href', 'count', 'value', 'hasClass']) if (expect[k] !== undefined && !(Array.isArray(expect[k]) && expect[k].every(Array.isArray))) errors.push(`${at}.${k}: an array of [selector, ...]`);
139
+ for (const k of ['attr', 'text', 'href', 'count', 'value', 'hasClass', 'top']) if (expect[k] !== undefined && !(Array.isArray(expect[k]) && expect[k].every(Array.isArray))) errors.push(`${at}.${k}: an array of [selector, ...]`);
138
140
  return errors;
139
141
  }
140
142
  // Runs IN THE PAGE: collects the facts the expectations compare against, plus the
@@ -183,6 +185,7 @@ function factsInPage(spec) {
183
185
  if (e.checked) { const el = q(e.checked); out.checked = el ? !!el.checked : null; }
184
186
  if (e.value) out.value = e.value.map(([s]) => { const el = q(s); return el ? el.value : null; });
185
187
  if (e.hasClass) out.hasClass = e.hasClass.map(([s, c]) => { const el = q(s); return el ? el.classList.contains(c) : null; });
188
+ if (e.top) out.top = e.top.map(([s]) => { const el = q(s); return el ? Math.round(el.getBoundingClientRect().top) : null; });
186
189
  return out;
187
190
  }
188
191
  // Pure: compare collected facts against an expectation. Returns problem strings.
@@ -208,6 +211,7 @@ function expectProblems(facts, expect = {}) {
208
211
  if (expect.checked !== undefined && facts.checked !== true) p.push(`${expect.checked} is not checked`);
209
212
  (expect.value || []).forEach(([s, v], i) => { if (facts.value[i] !== v) p.push(`${s} value "${facts.value[i]}", expected "${v}"`); });
210
213
  (expect.hasClass || []).forEach(([s, c, want = true], i) => { if (facts.hasClass[i] !== want) p.push(`${s} ${want ? 'lacks' : 'still has'} class "${c}"`); });
214
+ (expect.top || []).forEach(([s, v], i) => { if (facts.top[i] !== v) p.push(`${s} top ${facts.top[i]}px, expected ${v}px`); });
211
215
  if (expect.noOverflow && facts.scrollWidth > facts.clientWidth + 1) p.push(`horizontal overflow ${facts.scrollWidth} > ${facts.clientWidth}`);
212
216
  return p;
213
217
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.400",
3
+ "version": "1.19.402",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.400",
9
+ "version": "1.19.402",
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.19.400",
3
+ "version": "1.19.402",
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",
@@ -130,6 +130,8 @@
130
130
  --label-face:var(--font-body);
131
131
  --label-size:11.5px;
132
132
  --label-space:.1em;
133
+ /* the view masthead's object (task 1003332): dark drops the contact shadow */
134
+ --mast-shadow:none;
133
135
  color-scheme:dark;
134
136
  }
135
137
  @media (prefers-color-scheme: dark){
@@ -165,6 +167,8 @@
165
167
  --label-face:var(--font-body);
166
168
  --label-size:11.5px;
167
169
  --label-space:.1em;
170
+ /* the view masthead's object (task 1003332): dark drops the contact shadow */
171
+ --mast-shadow:none;
168
172
  color-scheme:dark;
169
173
  }
170
174
  }
@@ -246,6 +250,15 @@
246
250
  --radius:16px; --radius-sm:10px; --radius-pill:999px;
247
251
  --top-h:64px;
248
252
  --gutter:clamp(16px,4vw,34px);
253
+ /* the world's object for the hub's view masthead (task 1003332): the apex
254
+ shares the landing's plates because it is the same world (DESIGN.md
255
+ "Materials"); the hub takes the alpha CUTOUT in both modes — the view
256
+ wraps are stacking contexts, so a black plate under `lighten` could not
257
+ reach the page ground the way the hero's do, and a cutout needs no blend.
258
+ Root-absolute path: a url() in a custom property resolves against the
259
+ DOCUMENT. Light gives the object the world's one contact shadow. */
260
+ --plate-set-cut:url(/assets/world/r2-chrome-blobs-set-cut.webp);
261
+ --mast-shadow:drop-shadow(0 26px 30px color-mix(in srgb,var(--ink) 20%,transparent));
249
262
  /* the footer's measured height per width — the sky is 100vh minus the bar and
250
263
  this, so the map fits the viewport with no scrollbar (T2's rule); the
251
264
  toolbar row that used to be in that sum is gone (task 1003300) */
@@ -255,8 +268,13 @@
255
268
  @media (max-width:900px){
256
269
  :root{--top-h:60px;--foot-h:108px;}
257
270
  }
258
- @media (max-width:360px){
259
- :root{--foot-h:126px;}
271
+ /* with About + Docs in the row (task 1003332) the six links wrap to a second
272
+ row under ~420px, and the sentence takes a third line under ~340px — measured */
273
+ @media (max-width:420px){
274
+ :root{--foot-h:136px;}
275
+ }
276
+ @media (max-width:340px){
277
+ :root{--foot-h:154px;}
260
278
  }
261
279
 
262
280
  *{box-sizing:border-box;margin:0;padding:0;}
@@ -386,6 +404,8 @@ a.orb{cursor:pointer;}
386
404
  .attachFold{margin-top:12px;}
387
405
  .attachFold summary{cursor:pointer;width:fit-content;font-size:13px;font-weight:700;color:var(--ink-faint);}
388
406
  .attachFold summary:hover,.attachFold[open] summary{color:var(--ink-soft);}
407
+ .attachNote{margin:8px 0 0;font-size:13px;line-height:1.45;color:var(--ink-faint);}
408
+ .attachNote code{font-family:var(--font-mono);}
389
409
  .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
390
410
  .projMsg.ok{color:var(--ok);}
391
411
  .projMsg.err{color:var(--err);}
@@ -413,7 +433,7 @@ a.orb{cursor:pointer;}
413
433
  .foot a:hover{text-decoration:underline;}
414
434
  /* the footer doors (task 1003251): privacy · terms · contact on one row under the
415
435
  line, on every landing surface — the same link ink, the current page underlined */
416
- .foot .footNav{display:flex;justify-content:center;gap:16px;margin-top:8px;}
436
+ .foot .footNav{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 16px;margin-top:8px;}
417
437
  .foot .footNav a[aria-current="page"]{text-decoration:underline;}
418
438
 
419
439
  /* ── progress strips: one visual, per-flow vocabularies (direction §4).
@@ -611,13 +631,17 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
611
631
  .switch:has(input:checked){color:var(--ink);}
612
632
 
613
633
  /* ── THE BANDS: a menu is not a floating card. It unfolds under the bar's edge,
614
- full width on the card ground, closed by one hairline, and PUSHES the page
615
- down. Cells in a hairline-separated row, each a lowercase display title over
616
- one clause; the whole cell is the target. The current view carries a 2px
617
- accent hairline on its top edge — the rail-step-lit grammar from DESIGN.md,
618
- moved onto a menu. One authored move, a 6px rise + fade on the world's
619
- exponential ease-out, dead under the kill switch at the end of this file. ── */
620
- .menuBand{position:relative;z-index:49;background:var(--bg-card);border-bottom:1px solid var(--rule);
634
+ full width on the card ground, closed by one hairline, as a LAYER OVER the
635
+ page: the page does not reflow (the owner's correction, task 1003332 — the
636
+ shipped bands pushed the page down). Absolute against the document, top at
637
+ the bar's height, so the sky, the table and the footer keep their place; the
638
+ bar (50) stays above it, the card scrim (60) above both. Cells in a
639
+ hairline-separated row, each a lowercase display title over one clause; the
640
+ whole cell is the target. The current view carries a 2px accent hairline on
641
+ its top edge — the rail-step-lit grammar from DESIGN.md, moved onto a menu.
642
+ One authored move, a 6px rise + fade on the world's exponential ease-out,
643
+ dead under the kill switch at the end of this file. ── */
644
+ .menuBand{position:absolute;left:0;right:0;top:var(--top-h);z-index:49;background:var(--bg-card);border-bottom:1px solid var(--rule);
621
645
  animation:bandIn .22s cubic-bezier(.16,1,.3,1) both;}
622
646
  @keyframes bandIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
623
647
  .bandIn{padding:20px var(--gutter) 28px;}
@@ -642,9 +666,6 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
642
666
  /* the deferred Settings slot: a place it would go, rendering nothing at all —
643
667
  not even the hairline that declares a list item in this band */
644
668
  .bandSlot{border:0;}
645
- /* the row under the hairline: About and Docs, a rung smaller than the views */
646
- .bandMore{margin-top:18px;}
647
- .bandMore .bandT{font-size:17px;font-weight:400;min-height:24px;display:inline-flex;align-items:center;}
648
669
 
649
670
  @media (max-width:900px){
650
671
  .bar{gap:10px;padding:0 12px;}
@@ -766,35 +787,63 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
766
787
  .hintbar kbd{font-family:var(--font-mono);background:color-mix(in srgb,var(--accent) 24%,transparent);
767
788
  border-radius:5px;padding:1px 6px;font-size:11px;color:var(--chrome-accent);}
768
789
 
769
- /* ── the directory table: the same rows, the same filter, and the shell is
770
- still overhead — the gate's table dropped the projects door entirely. ── */
771
- .tbl{width:100%;border-collapse:collapse;background:var(--card);border-radius:20px;overflow:hidden;
772
- box-shadow:var(--shadow-card);}
773
- .tbl th{text-align:left;font-family:var(--font-display);font-size:11.5px;font-weight:700;
774
- letter-spacing:.9px;text-transform:uppercase;color:var(--ink-faint);padding:15px 18px;
775
- border-bottom:2px solid var(--line);white-space:nowrap;}
790
+ /* ── THE TABLE IN THE WORLD (task 1003332; owner's review of the shipped chrome).
791
+ The table is a VIEW of the same world as the sky, not a panel beside it: a
792
+ masthead in the world's material, then LEDGER ROWS (DESIGN.md "Ledger rows")
793
+ — hairlines, no ground, no radius, no shadow; mono labels on the columns;
794
+ the figure voice on the numbers; the accent only on what is pressable (the
795
+ open link) or live (the status chip); the display voice on the view title.
796
+ The relationship pill states a fact and is not pressable, so it lost the
797
+ accent (the One Warm Thing Rule); a row's hover is the hairline taking ink,
798
+ the bands' grammar. The masthead is the owner's pick from three rendered
799
+ treatments (docs/design/projects-hub-direction-v2.md §9): "the ledger" —
800
+ the view title in the display voice at the left, one chrome object at the
801
+ right, nothing else. ── */
802
+ .tblMast{display:grid;grid-template-columns:1fr auto;position:relative;align-items:end;gap:26px;padding:clamp(26px,3.6vw,44px) 0 30px;}
803
+ .tblTitle{font-family:var(--font-display);font-weight:300;font-size:clamp(30px,3.6vw,48px);line-height:1;
804
+ letter-spacing:-.035em;text-transform:lowercase;color:var(--ink);text-shadow:var(--type-halo);margin:0;}
805
+ /* the object, on the world's crop maths (DESIGN.md "The crop maths"): one
806
+ rectangle of a plate, expressed against the object's own width so it scales
807
+ with the row — the set's small bead, r2-chrome-blobs-set u[.810,.955] v[.556,.840] */
808
+ .mastObj{display:block;--dia:clamp(96px,10vw,132px);width:var(--dia);aspect-ratio:.91476;background-image:var(--plate-set-cut);
809
+ background-repeat:no-repeat;background-size:calc(var(--dia) * 6.89655) auto;
810
+ background-position:calc(var(--dia) * -5.58621) calc(var(--dia) * -2.14014);filter:var(--mast-shadow);}
811
+
812
+ .tbl{width:100%;border-collapse:collapse;background:none;}
813
+ .tbl th{text-align:left;font-family:var(--label-face);font-size:var(--label-size);font-weight:400;
814
+ letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);padding:0 14px 12px;
815
+ border-bottom:1px solid var(--rule);white-space:nowrap;}
816
+ .tbl th:first-child,.tbl td:first-child{padding-left:0;}
817
+ .tbl th:last-child,.tbl td:last-child{padding-right:0;}
776
818
  /* the sort control is a real button so the keyboard can reach it; it must look
777
819
  like the header it replaced, so it inherits everything the th declares. */
778
820
  .tbl th button{all:unset;cursor:pointer;}
779
821
  .tbl th button:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
780
- .tbl td{padding:15px 18px;border-bottom:1px solid var(--line);font-size:14px;font-weight:600;
781
- color:var(--ink-soft);vertical-align:middle;}
782
- .tbl tr:last-child td{border-bottom:none;}
783
- .tbl tr:hover td{background:color-mix(in srgb,var(--accent) 5%,transparent);}
784
- /* the whole row opens the project card, so it must look like it does */
822
+ .tbl td{padding:17px 14px;border-bottom:1px solid var(--rule-soft);font-size:15px;font-weight:400;
823
+ color:var(--ink-soft);vertical-align:middle;transition:border-color .2s cubic-bezier(.2,.7,.3,1);}
824
+ /* the whole row opens the project card, so it must look like it does: the
825
+ hairline under it takes ink, as a band cell's does */
785
826
  .tbl tbody tr{cursor:pointer;}
786
- .tbl .pname{display:flex;align-items:center;gap:11px;}
787
- .tbl .pdot{width:11px;height:11px;border-radius:50%;background:var(--acc);flex:0 0 auto;
788
- box-shadow:0 0 0 3px color-mix(in srgb,var(--acc) 22%,transparent);}
789
- .tbl .ptitle{font-family:var(--font-display);font-weight:700;font-size:15px;color:var(--ink);text-decoration:none;}
827
+ .tbl tbody tr:hover td{border-bottom-color:var(--ink-faint);}
828
+ .tbl .pname{display:flex;align-items:center;gap:12px;}
829
+ .tbl .pdot{width:10px;height:10px;border-radius:50%;background:var(--acc);flex:0 0 auto;}
830
+ .tbl .ptitle{font-family:var(--font-display);font-weight:500;font-size:17px;letter-spacing:-.015em;color:var(--ink);text-decoration:none;}
790
831
  .tbl a.ptitle:hover{text-decoration:underline;}
791
- .tbl .ptag{font-size:12.5px;color:var(--ink-faint);font-weight:600;}
792
- .tbl .num{font-family:var(--font-mono);font-weight:500;}
832
+ .tbl .ptag{font-size:13.5px;color:var(--ink-soft);font-weight:400;}
833
+ .tbl .num{font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;color:var(--ink);
834
+ font-variant-numeric:tabular-nums;}
793
835
  .tbl .tags{display:flex;gap:6px;flex-wrap:wrap;}
794
836
  .tbl .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;border-radius:999px;
795
- border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
796
- .tbl .pill{display:inline-block;font-family:var(--font-mono);font-size:11px;padding:3px 10px;
797
- border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);}
837
+ border:1px solid var(--rule);color:var(--ink-faint);white-space:nowrap;}
838
+ .tbl .pill{display:inline-block;font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);
839
+ text-transform:uppercase;padding:3px 9px;border-radius:999px;border:1px solid var(--rule);color:var(--ink-faint);}
840
+ /* the row's door: the accent on the one pressable thing in the row, as text
841
+ (the footer-link grammar), lowercase like every control in the world */
842
+ .rowGo{display:inline-flex;align-items:center;min-height:24px;padding:3px 0;font-family:var(--font-body);font-size:15px;
843
+ text-transform:lowercase;color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px;
844
+ text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);white-space:nowrap;
845
+ transition:text-decoration-color .2s cubic-bezier(.2,.7,.3,1);}
846
+ .rowGo:hover{text-decoration-color:var(--accent-deep);}
798
847
  .tbl td.right{text-align:right;}
799
848
  /* Nothing in an action or status cell may wrap: "Manage →" breaking after the
800
849
  space and the chip's recency dropping to a second line both read as a broken
@@ -807,8 +856,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
807
856
  .tbl .colType{width:1%;white-space:nowrap;}
808
857
  /* the declared-type tag (task 1002772) — its own vocabulary: not the relationship
809
858
  .pill, not the liveness .chip. Two different facts never wear the same chrome. */
810
- .typeTag{font-family:var(--font-mono);font-size:10.5px;font-weight:500;color:var(--ink-soft);border:1px solid var(--line);border-radius:999px;padding:2px 9px;white-space:nowrap;}
859
+ .typeTag{font-family:var(--font-mono);font-size:10.5px;font-weight:500;color:var(--ink-soft);border:1px solid var(--rule);border-radius:999px;padding:2px 9px;white-space:nowrap;}
811
860
  @media (max-width:780px){.tbl .colTags,.tbl .colType{display:none;}}
861
+ @media (max-width:900px){
862
+ .tblMast{gap:16px;padding:22px 0 20px;}
863
+ .tblTitle{font-size:clamp(28px,7.4vw,34px);}
864
+ .mastObj{--dia:clamp(72px,18vw,96px);}
865
+ .tbl th,.tbl td{padding-left:10px;padding-right:10px;}
866
+ }
812
867
 
813
868
  /* ── the living sky (task 1002762): camera, spotlight, hover card ──
814
869
  The camera is ONE transform on .skyCam; orbs carry only their own physics