@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.
- package/.bongos-core.json +48 -38
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +233 -232
- package/docs/copy-registry.json +293 -284
- package/docs/design/projects-hub-direction-v2.md +38 -0
- package/docs/recipes/ui-look-before-you-ship.md +1 -1
- package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
- package/modules/provisioning/provisioning.js +68 -8
- package/modules/provisioning/routes/provisioning.js +50 -23
- package/modules/ui-design/kit/lib.js +6 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +90 -35
- package/public-landing/index.html +13 -15
- package/public-landing/index.probes.json +8 -5
- package/public-landing/projects.html +49 -20
- package/public-landing/projects.probes.json +17 -10
- package/public-landing/projects.states.json +1 -1
- package/scripts/gds/provision-net.js +33 -0
- package/scripts/gds/provision-units.js +18 -0
- package/scripts/gds/provision.js +97 -5
- package/src/module-api.js +5 -1
- package/tests/landing_page.mjs +8 -4
- package/tests/projects_hub.mjs +28 -1
- package/tests/projects_hub_pre_uat.mjs +28 -2
- package/tests/provision.mjs +185 -12
- package/tests/provisioning_default_domain.mjs +5 -3
- package/tests/provisioning_domain_attach.mjs +166 -17
- package/tests/public_landing_projects.mjs +5 -1
- 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
|
|
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
|
|
472
|
-
// changes stay setInstanceStatus's job;
|
|
473
|
-
// route can never drift an instance's
|
|
474
|
-
|
|
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
|
|
477
|
-
|
|
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,
|
|
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.
|
|
863
|
-
//
|
|
864
|
-
|
|
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
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
259
|
-
|
|
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,
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
.
|
|
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
|
-
/* ──
|
|
770
|
-
|
|
771
|
-
.
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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:
|
|
781
|
-
color:var(--ink-soft);vertical-align:middle;}
|
|
782
|
-
|
|
783
|
-
|
|
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
|
|
787
|
-
.tbl .
|
|
788
|
-
|
|
789
|
-
.tbl .ptitle{font-family:var(--font-display);font-weight:
|
|
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:
|
|
792
|
-
.tbl .num{font-family:var(--font-
|
|
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(--
|
|
796
|
-
.tbl .pill{display:inline-block;font-family:var(--
|
|
797
|
-
border-radius:999px;
|
|
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(--
|
|
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
|