@bongos/core 1.19.399 → 1.19.401

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 (37) hide show
  1. package/.bongos-core.json +60 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0199-restart-an-unreachable-active-project.md +49 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +74 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +233 -232
  12. package/docs/copy-registry.json +293 -284
  13. package/docs/design/projects-hub-direction-v2.md +38 -0
  14. package/docs/recipes/ui-look-before-you-ship.md +1 -1
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/provisioning/migrations/provisioning_011_restart.sql +28 -0
  17. package/modules/provisioning/provisioning.js +42 -4
  18. package/modules/provisioning/routes/provisioning.js +59 -0
  19. package/modules/ui-design/kit/lib.js +6 -2
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/public-landing/assets/cosmos.css +88 -35
  23. package/public-landing/index.html +13 -15
  24. package/public-landing/index.probes.json +8 -5
  25. package/public-landing/projects.html +111 -16
  26. package/public-landing/projects.probes.json +17 -10
  27. package/public-landing/projects.states.json +1 -1
  28. package/scripts/gds/provision.js +104 -0
  29. package/src/module-api.js +5 -1
  30. package/tests/landing_page.mjs +8 -4
  31. package/tests/projects_hub.mjs +28 -1
  32. package/tests/projects_hub_pre_uat.mjs +40 -0
  33. package/tests/provision_restart.mjs +242 -0
  34. package/tests/provisioning_restart.mjs +285 -0
  35. package/tests/provisioning_settings_apply.mjs +8 -3
  36. package/tests/provisioning_teardown_intent.mjs +9 -5
  37. 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
 
@@ -620,6 +620,8 @@ function createClient(opts = {}) {
620
620
  postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
621
621
  // POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
622
622
  postProvisioningInstancesIdGithubApp: (args) => request("POST", "/provisioning/instances/{id}/github-app", { hasBody: true }, args),
623
+ // POST /provisioning/instances/{id}/restart — rank: any-builder — POST /provisioning/instances/:id/restart
624
+ postProvisioningInstancesIdRestart: (args) => request("POST", "/provisioning/instances/{id}/restart", { hasBody: true }, args),
623
625
  // GET /provisioning/instances/{id}/settings — rank: any-builder — GET /provisioning/instances/:id/settings
624
626
  getProvisioningInstancesIdSettings: (args) => request("GET", "/provisioning/instances/{id}/settings", { hasBody: false }, args),
625
627
  // PATCH /provisioning/instances/{id}/settings — rank: any-builder — PATCH /provisioning/instances/:id/settings
@@ -0,0 +1,28 @@
1
+ -- provisioning_011_restart.sql — the restart intent action (task 1003238,
2
+ -- goal 1000058: an honest owner action for an ACTIVE instance whose probe is
3
+ -- down).
4
+ --
5
+ -- WHY. The liveness overlay (task 1002696) can say an instance is unreachable
6
+ -- while its lifecycle status is still 'active' — liveness is an overlay, never a
7
+ -- seventh state. Until now the owner had no action for that: PROVISION_ENQUEUE_FROM
8
+ -- deliberately excludes 'active' (task 1002691 — a blind re-enqueue left a stale
9
+ -- open provision intent that then blocked teardown), so a Restart button wired to
10
+ -- the provision path would have refused on every press. 'settings-apply' does
11
+ -- restart an active instance, but only as the tail of a change that must differ
12
+ -- from the stored row — the one case where restarting is useless.
13
+ --
14
+ -- So: its own action, whose whole job is the restart + the patient healthz verify
15
+ -- that settings-apply already performs (ADR 0190 §3), minus the env patch. The
16
+ -- web tier still only ENQUEUES; the control-plane runner remains the sole
17
+ -- token-holder (ADR 0111 §2).
18
+ --
19
+ -- Widening a CHECK enum is additive (permits MORE values; no row changes) — the
20
+ -- provisioning_003/005/010 precedent. Applied only when the provisioning module
21
+ -- is enabled. Safe to re-run. Keep INTENT_ACTIONS in provisioning.js in lockstep.
22
+
23
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
24
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
25
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart'));
26
+
27
+ INSERT INTO schema_migrations (version) VALUES ('provisioning_011_restart')
28
+ ON CONFLICT (version) DO NOTHING;
@@ -46,7 +46,11 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
46
46
  // per-project setting to an already-ACTIVE instance — patch its web.env settings
47
47
  // lines in place + restart, since the branding pack is memoized for the process
48
48
  // lifetime (task 1003139 built only the standup leg).
49
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply'];
49
+ // 'restart' (task 1003238, migration provisioning_011): restart an already-ACTIVE
50
+ // instance whose liveness probe says it is down — the settings-apply leg minus the
51
+ // env patch. Its own action because no existing one fits: provision refuses
52
+ // 'active' and settings-apply requires a value that actually changed.
53
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart'];
50
54
  // Every state a teardown can be asked for — i.e. everything except the one
51
55
  // state where there is provably nothing left to release. DERIVED from
52
56
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -84,6 +88,19 @@ const SETTINGS_APPLY_FROM = new Set(['active']);
84
88
  // to it (the placeManifestCreds refusal) — so the route refuses the push HONESTLY
85
89
  // rather than queue one that never lands.
86
90
  const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'standalone']);
91
+ // The ONE state a restart is meaningful from (task 1003238). Liveness is an
92
+ // OVERLAY, not a seventh state — an instance the heartbeat cannot reach is still
93
+ // 'active', and 'active' is exactly where a restart applies. Every earlier state
94
+ // has nothing running to restart and is already served by the provision path
95
+ // (PROVISION_ENQUEUE_FROM covers 'requested'/'provisioning'/'error'/'torn_down');
96
+ // 'tearing_down' is mid-reclaim. Kept as its own Set rather than reusing
97
+ // SETTINGS_APPLY_FROM: each action declaring its own gate is this module's
98
+ // convention, and the two answer different questions.
99
+ const RESTART_FROM = new Set(['active']);
100
+ // Same reachability rule as the settings push, for the same reason: a 'dedicated'
101
+ // droplet is stood up by cloud-init and the runner has no post-hoc exec path to
102
+ // it, so the route refuses HONESTLY rather than queue a restart that never lands.
103
+ const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
87
104
 
88
105
  // Per-project settings (task 1003040, goal 1000058): the known keys + their
89
106
  // vocabularies, enforced at the API layer — the jsonb `settings` column
@@ -632,7 +649,10 @@ async function enqueueIntent(db, instanceId, action, requestedBy) {
632
649
  // (the state CHECK has no 'superseded'; last_error says why) so the runner never
633
650
  // stands it up. A PENDING settings push is superseded the same way (task 1003140):
634
651
  // a push into a project about to be torn down is dead work, and letting it hold the
635
- // delete hostage would repeat 1002691. A RUNNING intent is mid-flight and never yanked —
652
+ // delete hostage would repeat 1002691. A pending 'restart' joins them for the same
653
+ // reason (task 1003238) — and more sharply, since the owner reaches for Restart
654
+ // precisely when a project looks broken, which is also when they give up and delete
655
+ // it. A RUNNING intent is mid-flight and never yanked —
636
656
  // the caller gets { conflict: 'open_intent' } with the blocking intent so the route
637
657
  // can answer truthfully. Each statement is atomic and the index stays the arbiter:
638
658
  // any interleaving with the runner or a concurrent enqueue lands on one of these
@@ -642,7 +662,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
642
662
  `UPDATE provisioning_intents
643
663
  SET state = 'error', last_error = 'superseded: teardown requested',
644
664
  updated_at = now(), resolved_at = now()
645
- WHERE instance_id = $1 AND action IN ('provision', 'settings-apply') AND state = 'pending'
665
+ WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart') AND state = 'pending'
646
666
  RETURNING id`,
647
667
  [instanceId]
648
668
  );
@@ -668,6 +688,23 @@ async function enqueueSettingsApply(db, instanceId, requestedBy) {
668
688
  return { intent, created: false, conflict: 'open_intent' };
669
689
  }
670
690
 
691
+ // Queue a restart for an ACTIVE instance (task 1003238). Idempotent against its
692
+ // own kind in EITHER open state, which is where it differs from the settings push:
693
+ // that one must refuse to ride a RUNNING sibling because the runner may have read
694
+ // the settings row before this write, so the in-flight run would not carry the
695
+ // change. A restart carries no payload — a restart already running IS the thing
696
+ // the owner asked for, so reporting "already under way" is the truth, not a
697
+ // swallowed request. Anything else holding the single open slot is reported as
698
+ // { conflict: 'open_intent' } so the route can name it. The state gate and the
699
+ // shape gate are the ROUTE's job (RESTART_FROM / RESTART_SHAPES); this only
700
+ // arbitrates the open slot, like enqueueTeardown.
701
+ async function enqueueRestart(db, instanceId, requestedBy) {
702
+ const { intent, created } = await enqueueIntent(db, instanceId, 'restart', requestedBy);
703
+ if (created) return { intent, created: true };
704
+ if (intent.action === 'restart') return { intent, created: false };
705
+ return { intent, created: false, conflict: 'open_intent' };
706
+ }
707
+
671
708
  // The instance's currently-open (pending|running) intent, if any.
672
709
  async function getOpenIntent(db, instanceId) {
673
710
  const { rows } = await db.query(
@@ -860,6 +897,7 @@ module.exports = {
860
897
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
861
898
  settingsEnvLines, settingsEnvVars,
862
899
  PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
900
+ RESTART_FROM, RESTART_SHAPES,
863
901
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
864
902
  SETTINGS_VOCAB, DEFAULT_SETTINGS,
865
903
  // pure
@@ -873,7 +911,7 @@ module.exports = {
873
911
  // data access — verification overlay (task 1002696)
874
912
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
875
913
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings,
876
- enqueueIntent, enqueueTeardown, enqueueSettingsApply, getOpenIntent, claimNextIntent, resolveIntent,
914
+ enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
877
915
  recordEvent, instanceStateForSlug, costLedger,
878
916
  // data access — GitHub App Manifest attempts (task 2080)
879
917
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
@@ -795,6 +795,65 @@ module.exports = function provisioningRoutes() {
795
795
  }
796
796
  });
797
797
 
798
+ // POST /provisioning/instances/:id/restart — restart an ACTIVE project the
799
+ // heartbeat cannot reach (task 1003238). rank: any authenticated builder (own
800
+ // resource; archon may restart any — the override is audited via the actor).
801
+ //
802
+ // WHY it is its own route. The liveness overlay (task 1002696) can say a project
803
+ // is unreachable while its lifecycle status is still 'active' — and until now
804
+ // that reading came with no action the owner could take. Re-provision is not it:
805
+ // PROVISION_ENQUEUE_FROM deliberately excludes 'active' (task 1002691), so a
806
+ // Restart wired to that path would have refused on every press — the finding this
807
+ // route closes. The web tier only ENQUEUES; the control-plane runner performs the
808
+ // restart and the patient healthz verify (ADR 0111 §2).
809
+ //
810
+ // Every refusal names the action that IS available in that state rather than
811
+ // hiding behind a code, and no branch promises a duration — a restart's downtime
812
+ // is exactly what this route cannot know (the F16 rule, ADR 0190 §7).
813
+ router.post('/provisioning/instances/:id/restart', auth.requireBuilder, async (req, res) => {
814
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
815
+ if (!id) return;
816
+ try {
817
+ const inst = await provisioning.getInstanceById(pool, id);
818
+ if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
819
+ // The archon override carries WHICH archon (the force-teardown convention).
820
+ const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
821
+ if (!provisioning.RESTART_FROM.has(inst.status)) {
822
+ const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
823
+ return res.fail('not_restartable', { status: 409, details: { status: inst.status }, message: offline
824
+ ? 'This project is offline, so there is nothing running to restart — stand it up again instead.'
825
+ : inst.status === 'error'
826
+ ? 'This project’s standup stopped partway, so there is nothing running to restart — stand it up again to run the standup afresh.'
827
+ : 'This project hasn’t finished standing up yet, so there is nothing to restart.' });
828
+ }
829
+ // A dedicated droplet is stood up by cloud-init and the runner has no post-hoc
830
+ // exec path to it — refuse honestly rather than queue a restart that never
831
+ // lands (the F16 lie), and name the command whoever runs that machine needs.
832
+ if (!provisioning.RESTART_SHAPES.has(inst.hosting_shape)) {
833
+ return res.fail('restart_unreachable', { status: 409, details: { hosting_shape: inst.hosting_shape },
834
+ message: `This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to run 'systemctl restart ${inst.slug}' there.` });
835
+ }
836
+ const r = await provisioning.enqueueRestart(pool, id, actor);
837
+ if (r.conflict) {
838
+ return res.fail('intent_conflict', { status: 409,
839
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this project — the restart could not be queued; try again once that settles.`,
840
+ details: { action: r.intent.action, state: r.intent.state } });
841
+ }
842
+ if (r.created) {
843
+ await provisioning.recordEvent(pool, {
844
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'restart',
845
+ detail: 'restart requested by owner', actor,
846
+ }).catch(() => {});
847
+ }
848
+ res.json({ ok: true, queued: r.created, action: 'restart', status: inst.status, message: r.created
849
+ ? 'Restart queued. Your project will stop and start again — this page says whether it answered afterwards.'
850
+ : 'A restart is already under way for this project.' });
851
+ } catch (err) {
852
+ log.error('[provisioning] POST /provisioning/instances/:id/restart', err);
853
+ res.fail('restart_failed', { status: 500, message: 'internal error' });
854
+ }
855
+ });
856
+
798
857
  // POST /provisioning/instances/:id/domain — give the caller's OWN instance its
799
858
  // address (task 1002692, gap 5: no path existed to add a domain after creation).
800
859
  // Validates + records the domain; for an already-ACTIVE instance it also enqueues
@@ -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.399",
3
+ "version": "1.19.401",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.399",
9
+ "version": "1.19.401",
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.399",
3
+ "version": "1.19.401",
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;}
@@ -413,7 +431,7 @@ a.orb{cursor:pointer;}
413
431
  .foot a:hover{text-decoration:underline;}
414
432
  /* the footer doors (task 1003251): privacy · terms · contact on one row under the
415
433
  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;}
434
+ .foot .footNav{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 16px;margin-top:8px;}
417
435
  .foot .footNav a[aria-current="page"]{text-decoration:underline;}
418
436
 
419
437
  /* ── progress strips: one visual, per-flow vocabularies (direction §4).
@@ -611,13 +629,17 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
611
629
  .switch:has(input:checked){color:var(--ink);}
612
630
 
613
631
  /* ── 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);
632
+ full width on the card ground, closed by one hairline, as a LAYER OVER the
633
+ page: the page does not reflow (the owner's correction, task 1003332 — the
634
+ shipped bands pushed the page down). Absolute against the document, top at
635
+ the bar's height, so the sky, the table and the footer keep their place; the
636
+ bar (50) stays above it, the card scrim (60) above both. Cells in a
637
+ hairline-separated row, each a lowercase display title over one clause; the
638
+ whole cell is the target. The current view carries a 2px accent hairline on
639
+ its top edge — the rail-step-lit grammar from DESIGN.md, moved onto a menu.
640
+ One authored move, a 6px rise + fade on the world's exponential ease-out,
641
+ dead under the kill switch at the end of this file. ── */
642
+ .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
643
  animation:bandIn .22s cubic-bezier(.16,1,.3,1) both;}
622
644
  @keyframes bandIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
623
645
  .bandIn{padding:20px var(--gutter) 28px;}
@@ -642,9 +664,6 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
642
664
  /* the deferred Settings slot: a place it would go, rendering nothing at all —
643
665
  not even the hairline that declares a list item in this band */
644
666
  .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
667
 
649
668
  @media (max-width:900px){
650
669
  .bar{gap:10px;padding:0 12px;}
@@ -766,35 +785,63 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
766
785
  .hintbar kbd{font-family:var(--font-mono);background:color-mix(in srgb,var(--accent) 24%,transparent);
767
786
  border-radius:5px;padding:1px 6px;font-size:11px;color:var(--chrome-accent);}
768
787
 
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;}
788
+ /* ── THE TABLE IN THE WORLD (task 1003332; owner's review of the shipped chrome).
789
+ The table is a VIEW of the same world as the sky, not a panel beside it: a
790
+ masthead in the world's material, then LEDGER ROWS (DESIGN.md "Ledger rows")
791
+ — hairlines, no ground, no radius, no shadow; mono labels on the columns;
792
+ the figure voice on the numbers; the accent only on what is pressable (the
793
+ open link) or live (the status chip); the display voice on the view title.
794
+ The relationship pill states a fact and is not pressable, so it lost the
795
+ accent (the One Warm Thing Rule); a row's hover is the hairline taking ink,
796
+ the bands' grammar. The masthead is the owner's pick from three rendered
797
+ treatments (docs/design/projects-hub-direction-v2.md §9): "the ledger" —
798
+ the view title in the display voice at the left, one chrome object at the
799
+ right, nothing else. ── */
800
+ .tblMast{display:grid;grid-template-columns:1fr auto;position:relative;align-items:end;gap:26px;padding:clamp(26px,3.6vw,44px) 0 30px;}
801
+ .tblTitle{font-family:var(--font-display);font-weight:300;font-size:clamp(30px,3.6vw,48px);line-height:1;
802
+ letter-spacing:-.035em;text-transform:lowercase;color:var(--ink);text-shadow:var(--type-halo);margin:0;}
803
+ /* the object, on the world's crop maths (DESIGN.md "The crop maths"): one
804
+ rectangle of a plate, expressed against the object's own width so it scales
805
+ with the row — the set's small bead, r2-chrome-blobs-set u[.810,.955] v[.556,.840] */
806
+ .mastObj{display:block;--dia:clamp(96px,10vw,132px);width:var(--dia);aspect-ratio:.91476;background-image:var(--plate-set-cut);
807
+ background-repeat:no-repeat;background-size:calc(var(--dia) * 6.89655) auto;
808
+ background-position:calc(var(--dia) * -5.58621) calc(var(--dia) * -2.14014);filter:var(--mast-shadow);}
809
+
810
+ .tbl{width:100%;border-collapse:collapse;background:none;}
811
+ .tbl th{text-align:left;font-family:var(--label-face);font-size:var(--label-size);font-weight:400;
812
+ letter-spacing:var(--label-space);text-transform:uppercase;color:var(--ink-faint);padding:0 14px 12px;
813
+ border-bottom:1px solid var(--rule);white-space:nowrap;}
814
+ .tbl th:first-child,.tbl td:first-child{padding-left:0;}
815
+ .tbl th:last-child,.tbl td:last-child{padding-right:0;}
776
816
  /* the sort control is a real button so the keyboard can reach it; it must look
777
817
  like the header it replaced, so it inherits everything the th declares. */
778
818
  .tbl th button{all:unset;cursor:pointer;}
779
819
  .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 */
820
+ .tbl td{padding:17px 14px;border-bottom:1px solid var(--rule-soft);font-size:15px;font-weight:400;
821
+ color:var(--ink-soft);vertical-align:middle;transition:border-color .2s cubic-bezier(.2,.7,.3,1);}
822
+ /* the whole row opens the project card, so it must look like it does: the
823
+ hairline under it takes ink, as a band cell's does */
785
824
  .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;}
825
+ .tbl tbody tr:hover td{border-bottom-color:var(--ink-faint);}
826
+ .tbl .pname{display:flex;align-items:center;gap:12px;}
827
+ .tbl .pdot{width:10px;height:10px;border-radius:50%;background:var(--acc);flex:0 0 auto;}
828
+ .tbl .ptitle{font-family:var(--font-display);font-weight:500;font-size:17px;letter-spacing:-.015em;color:var(--ink);text-decoration:none;}
790
829
  .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;}
830
+ .tbl .ptag{font-size:13.5px;color:var(--ink-soft);font-weight:400;}
831
+ .tbl .num{font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;color:var(--ink);
832
+ font-variant-numeric:tabular-nums;}
793
833
  .tbl .tags{display:flex;gap:6px;flex-wrap:wrap;}
794
834
  .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);}
835
+ border:1px solid var(--rule);color:var(--ink-faint);white-space:nowrap;}
836
+ .tbl .pill{display:inline-block;font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);
837
+ text-transform:uppercase;padding:3px 9px;border-radius:999px;border:1px solid var(--rule);color:var(--ink-faint);}
838
+ /* the row's door: the accent on the one pressable thing in the row, as text
839
+ (the footer-link grammar), lowercase like every control in the world */
840
+ .rowGo{display:inline-flex;align-items:center;min-height:24px;padding:3px 0;font-family:var(--font-body);font-size:15px;
841
+ text-transform:lowercase;color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px;
842
+ text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);white-space:nowrap;
843
+ transition:text-decoration-color .2s cubic-bezier(.2,.7,.3,1);}
844
+ .rowGo:hover{text-decoration-color:var(--accent-deep);}
798
845
  .tbl td.right{text-align:right;}
799
846
  /* Nothing in an action or status cell may wrap: "Manage →" breaking after the
800
847
  space and the chip's recency dropping to a second line both read as a broken
@@ -807,8 +854,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
807
854
  .tbl .colType{width:1%;white-space:nowrap;}
808
855
  /* the declared-type tag (task 1002772) — its own vocabulary: not the relationship
809
856
  .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;}
857
+ .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
858
  @media (max-width:780px){.tbl .colTags,.tbl .colType{display:none;}}
859
+ @media (max-width:900px){
860
+ .tblMast{gap:16px;padding:22px 0 20px;}
861
+ .tblTitle{font-size:clamp(28px,7.4vw,34px);}
862
+ .mastObj{--dia:clamp(72px,18vw,96px);}
863
+ .tbl th,.tbl td{padding-left:10px;padding-right:10px;}
864
+ }
812
865
 
813
866
  /* ── the living sky (task 1002762): camera, spotlight, hover card ──
814
867
  The camera is ONE transform on .skyCam; orbs carry only their own physics
@@ -191,10 +191,11 @@ main:focus{outline:none;box-shadow:none}
191
191
  @media (prefers-color-scheme:dark){:root:not([data-mode="light"]) .sun{display:block}
192
192
  :root:not([data-mode="light"]) .moon{display:none}}
193
193
 
194
- /* the menu bands (.menuBand — .band is this page's content sections): not floating cards. Full width on the card ground, closed by one hairline, pushing
195
- the page down. Cells in a hairline-separated row, a lowercase display title over one clause;
196
- the whole cell is the target. One authored move, dead under world.css's kill switch. */
197
- .menuBand{position:relative;z-index:49;background:var(--bg-card);border-bottom:1px solid var(--rule);
194
+ /* the menu bands (.menuBand — .band is this page's content sections): not floating cards. Full width on the card ground, closed by one hairline, a LAYER
195
+ over the page under the bar — the page does not reflow (owner, 2026-08-28; task 1003332). Cells in a
196
+ hairline-separated row, a lowercase display title over one clause; the whole cell is the target.
197
+ One authored move, dead under world.css's kill switch. */
198
+ .menuBand{position:absolute;left:0;right:0;top:var(--top-h);z-index:49;background:var(--bg-card);border-bottom:1px solid var(--rule);
198
199
  animation:bandIn .22s cubic-bezier(.16,1,.3,1) both}
199
200
  @keyframes bandIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
200
201
  .bandIn{padding:20px 26px 28px}
@@ -207,8 +208,6 @@ main:focus{outline:none;box-shadow:none}
207
208
  .bandCell:hover .bandT{color:var(--ink)}
208
209
  .bandCell:hover{border-top-color:var(--ink-faint)}
209
210
  .bandC{display:block;margin-top:5px;font-size:13.5px;line-height:1.45;color:var(--ink-soft)}
210
- .bandMore{margin-top:18px}
211
- .bandMore .bandT{font-size:17px;font-weight:400;min-height:24px;display:inline-flex;align-items:center}
212
211
  .bandProfile{display:flex;align-items:flex-end;justify-content:space-between;gap:34px;flex-wrap:wrap}
213
212
  .bandWho{display:flex;align-items:center;gap:10px;padding-top:15px;border-top:1px solid var(--rule-soft);
214
213
  flex:0 1 auto;font-family:var(--label-face);font-size:var(--label-size);letter-spacing:var(--label-space);
@@ -843,9 +842,10 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
843
842
  </header>
844
843
 
845
844
  <!-- the two bands: full-width, on the card ground, closed by one hairline; they unfold under the
846
- bar's edge and push the page down. One disclosure component, two instances (the script at the
847
- foot of the page): Escape closes and returns focus to the trigger, the arrows walk the items,
848
- Tab out closes, a click outside closes, opening one closes the other. -->
845
+ bar's edge as a layer over the page, which does not reflow (task 1003332). One disclosure
846
+ component, two instances (the script at the foot of the page): Escape closes and returns focus
847
+ to the trigger, the arrows walk the items, Tab out closes, a click outside closes, opening one
848
+ closes the other. -->
849
849
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
850
850
  <div class="bandIn">
851
851
  <ul class="bandRow">
@@ -854,12 +854,8 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
854
854
  <li class="bandCell" id="mineCell" hidden><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own or build on</span></li>
855
855
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
856
856
  </ul>
857
- <!-- under a hairline: what this is, and the docs (§5's About cell; goal 1000063's About page
858
- takes the anchor over when it lands) -->
859
- <ul class="bandRow bandMore">
860
- <li class="bandCell"><a class="bandT" href="#about">About</a></li>
861
- <li class="bandCell"><a class="bandT" href="/docs">Docs</a></li>
862
- </ul>
857
+ <!-- About and Docs are site pages, not project views: they live in the footer row with
858
+ Privacy / Terms / Contact (owner, 2026-08-28; task 1003332). -->
863
859
  </div>
864
860
  </div>
865
861
  <div class="menuBand" id="menuProfile" data-menu="profile" hidden>
@@ -1096,6 +1092,8 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
1096
1092
  <footer>
1097
1093
  <div class="footrow">
1098
1094
  <p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.</p>
1095
+ <a href="#about">About</a>
1096
+ <a href="/docs">Docs</a>
1099
1097
  <a href="/privacy">Privacy</a>
1100
1098
  <a href="/terms">Terms</a>
1101
1099
  <a href="/contact">Contact</a>