@bongos/core 1.19.457 → 1.19.459

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 (55) hide show
  1. package/.bongos-core.json +126 -46
  2. package/.claude/skills/design/SKILL.md +10 -0
  3. package/docs/adr/0219-a-look-is-a-branding-pack-the-style-library.md +30 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/branding-contract.md +1 -1
  6. package/docs/copy-inventory.md +160 -177
  7. package/docs/copy-registry.json +445 -606
  8. package/docs/file-map.md +2 -2
  9. package/docs/module-api-changelog.md +4 -0
  10. package/docs/recipes/ui-look-before-you-ship.md +2 -0
  11. package/modules/hall-ui/public/gate.css +20 -88
  12. package/modules/hall-ui/public/gate.html +24 -12
  13. package/modules/hall-ui/public/gate.js +77 -60
  14. package/modules/hall-ui/public/gate.states.json +19 -1
  15. package/modules/hall-ui/public/government.css +96 -154
  16. package/modules/hall-ui/public/government.html +80 -52
  17. package/modules/hall-ui/public/government.js +227 -136
  18. package/modules/hall-ui/public/government.states.json +71 -1
  19. package/modules/hall-ui/public/harbor.css +9 -120
  20. package/modules/hall-ui/public/harbor.html +23 -11
  21. package/modules/hall-ui/public/harbor.js +109 -83
  22. package/modules/hall-ui/public/harbor.states.json +19 -1
  23. package/modules/hall-ui/public/oversight.css +296 -0
  24. package/modules/hall-ui/public/watch.css +56 -170
  25. package/modules/hall-ui/public/watch.html +48 -37
  26. package/modules/hall-ui/public/watch.js +363 -298
  27. package/modules/hall-ui/public/watch.states.json +48 -1
  28. package/modules/ui-design/kit/README.md +7 -5
  29. package/modules/ui-design/kit/check-mock.js +12 -5
  30. package/modules/ui-design/kit/contrast.js +13 -6
  31. package/modules/ui-design/kit/lib.js +39 -2
  32. package/modules/ui-design/kit/probe.js +4 -1
  33. package/modules/ui-design/kit/render.js +7 -4
  34. package/modules/ui-design/kit/serve.js +9 -2
  35. package/modules/ui-design/styles/README.md +54 -2
  36. package/modules/ui-design/styles/chrome-world/DESIGN.md +56 -0
  37. package/modules/ui-design/styles/chrome-world/mock.html +275 -0
  38. package/modules/ui-design/styles/chrome-world/mock.states.json +11 -0
  39. package/modules/ui-design/styles/chrome-world/pack.json +33 -0
  40. package/modules/ui-design/styles/expedition/DESIGN.md +141 -0
  41. package/modules/ui-design/styles/expedition/mock.html +285 -0
  42. package/modules/ui-design/styles/expedition/mock.states.json +11 -0
  43. package/modules/ui-design/styles/expedition/pack.json +33 -0
  44. package/modules/ui-design/styles/grove/DESIGN.md +148 -0
  45. package/modules/ui-design/styles/grove/mock.html +294 -0
  46. package/modules/ui-design/styles/grove/mock.states.json +11 -0
  47. package/modules/ui-design/styles/grove/pack.json +33 -0
  48. package/package-lock.json +2 -2
  49. package/package.json +1 -1
  50. package/src/module-api.js +1 -1
  51. package/tests/cosmos_token_contrast.mjs +9 -2
  52. package/tests/hall_oversight_world.mjs +118 -0
  53. package/tests/hall_tokens.mjs +15 -6
  54. package/tests/ui_design_styles.mjs +218 -0
  55. package/tests/world_tokens.mjs +9 -2
@@ -45,6 +45,75 @@
45
45
  },
46
46
  "out": {
47
47
  "auth": false
48
+ },
49
+ "permissions": {
50
+ "auth": true,
51
+ "actions": [
52
+ [
53
+ "wait",
54
+ 600
55
+ ],
56
+ [
57
+ "click",
58
+ "#tab-ranks"
59
+ ],
60
+ [
61
+ "wait",
62
+ 300
63
+ ]
64
+ ],
65
+ "expect": {
66
+ "visible": [
67
+ "#gov-panel-ranks",
68
+ "#gov-rank-list"
69
+ ]
70
+ }
71
+ },
72
+ "board": {
73
+ "auth": true,
74
+ "actions": [
75
+ [
76
+ "wait",
77
+ 600
78
+ ],
79
+ [
80
+ "click",
81
+ "#tab-board-room"
82
+ ],
83
+ [
84
+ "wait",
85
+ 300
86
+ ]
87
+ ],
88
+ "expect": {
89
+ "visible": [
90
+ "#gov-panel-board",
91
+ "#gov-board"
92
+ ]
93
+ }
94
+ },
95
+ "constitution-tab": {
96
+ "auth": true,
97
+ "actions": [
98
+ [
99
+ "wait",
100
+ 600
101
+ ],
102
+ [
103
+ "click",
104
+ "#tab-constitution"
105
+ ],
106
+ [
107
+ "wait",
108
+ 300
109
+ ]
110
+ ],
111
+ "expect": {
112
+ "visible": [
113
+ "#gov-panel-constitution",
114
+ "#gov-constitution-live"
115
+ ]
116
+ }
48
117
  }
49
118
  },
50
119
  "reducedMotion": {
@@ -55,5 +124,6 @@
55
124
  600
56
125
  ]
57
126
  ]
58
- }
127
+ },
128
+ "_states": "task 1003469: the three tabs are three states, so the Permissions two-panel layout, the Board Room sittings and the Constitution panels are each measured. `constitution` keeps its name for the report history; against --rank archon it lands on the Permissions tab (the Archon's working surface), so `permissions` is the same landing named honestly and `board` clicks through."
59
129
  }
@@ -1,120 +1,9 @@
1
- /* harbor.css — Page-local styles for the Harbor page (Archon-only).
2
- Lifted verbatim out of the page's inline <style> block (task 1002176);
3
- the ?v= cache-bust is stamped automatically from this file's content hash
4
- at boot (src/asset-version.js). */
5
-
6
- /* Page-local styles for the harbor — built on the shared design system's
7
- derived tokens only (no page-local colour values). Each builder's dev
8
- box renders as a card whose top strip + pill carry its lifecycle state. */
9
- .harbor-summary {
10
- display: grid;
11
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
12
- gap: 14px;
13
- margin: 6px 0 22px;
14
- }
15
- .harbor-stat {
16
- border: var(--line) solid var(--rule-soft);
17
- border-radius: var(--radius);
18
- background: var(--bg-card);
19
- padding: 12px 14px;
20
- }
21
- .harbor-stat__label {
22
- font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
23
- font-weight: 600; color: var(--ink-faint);
24
- }
25
- .harbor-stat__value {
26
- font-size: 22px; font-weight: 650; color: var(--ink);
27
- font-variant-numeric: tabular-nums; margin-top: 2px;
28
- }
29
-
30
- /* The dock — the strip the box cards sit on. */
31
- .harbor-dock {
32
- display: grid;
33
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
34
- gap: 16px;
35
- padding: 18px;
36
- border-radius: var(--radius);
37
- border: var(--line) solid var(--rule-soft);
38
- background: var(--glass);
39
- }
40
-
41
- .harbor-container {
42
- border: var(--line) solid var(--rule-soft);
43
- border-radius: var(--radius);
44
- background: var(--bg-card);
45
- overflow: hidden;
46
- display: flex; flex-direction: column;
47
- }
48
- /* The state strip along the card's top, coloured by lifecycle state. */
49
- .harbor-container__roof {
50
- height: 4px;
51
- background-color: var(--ok);
52
- }
53
- .harbor-container--parked .harbor-container__roof { background-color: var(--ink-faint); }
54
- .harbor-container--pending .harbor-container__roof { background-color: var(--warn); }
55
- .harbor-container--gone .harbor-container__roof { background-color: var(--rule); }
56
- .harbor-container--error .harbor-container__roof { background-color: var(--danger); }
57
- .harbor-container--blocked .harbor-container__roof { background-color: var(--danger); }
58
-
59
- .harbor-container__body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
60
- .harbor-container__head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
61
- .harbor-container__who { color: var(--ink); font-weight: 600; font-size: 14.5px; }
62
- .harbor-container__rank { color: var(--ink-faint); font-size: 12px; margin-left: 6px; font-weight: 400; }
63
-
64
- /* Base: the shell's .fact-pill (task 1003307). States are hairlines, not fills. */
65
- .harbor-pill--active { color: var(--ok); }
66
- .harbor-pill--pending { color: var(--warn); }
67
- .harbor-pill--blocked { color: var(--danger); }
68
-
69
- .harbor-rows { list-style: none; padding: 0; margin: 2px 0 0; }
70
- .harbor-rows li {
71
- display: flex; justify-content: space-between; gap: 12px;
72
- padding: 4px 0; border-bottom: var(--line) solid var(--rule-soft);
73
- font-size: 13px;
74
- }
75
- .harbor-rows li:last-child { border-bottom: none; }
76
- .harbor-rows li .k { color: var(--ink-soft); }
77
- .harbor-rows li .v { color: var(--ink); font-variant-numeric: tabular-nums; text-align: right; }
78
-
79
- .harbor-blocked-note {
80
- font-size: 12.5px; color: var(--danger);
81
- border-left: 2px solid var(--danger); padding-left: 8px; margin: 2px 0 0;
82
- }
83
-
84
- .harbor-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 8px; }
85
- .harbor-btn {
86
- font: inherit; font-size: 12.5px; font-weight: 500;
87
- padding: 5px 12px; border: var(--line) solid var(--rule);
88
- background: var(--bg-card); color: var(--ink-soft);
89
- border-radius: var(--radius-sm); cursor: pointer; flex: 1;
90
- }
91
- .harbor-btn:hover:not(:disabled) { background: var(--glass); color: var(--ink); }
92
- .harbor-btn:disabled { opacity: 0.45; cursor: default; }
93
- .harbor-btn--danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
94
-
95
- .harbor-empty, .harbor-sealed { color: var(--ink-soft); padding: 16px 0; }
96
-
97
- /* Lightweight modal for confirm / block dialogs. Viewport-fixed overlay on
98
- the shell's own --z-modal layer — the bare 200 that used to be here was a
99
- guess at "above the drawer", and gate.css guessed 50 for the same component
100
- (task 1003307). */
101
- .harbor-modal-bg {
102
- position: fixed; inset: 0; background: color-mix(in srgb, var(--ink) 45%, transparent);
103
- display: flex; align-items: center; justify-content: center; z-index: var(--z-modal); padding: 20px;
104
- }
105
- .harbor-modal {
106
- background: var(--bg-card); border: var(--line) solid var(--rule-soft); border-radius: var(--radius);
107
- max-width: 460px; width: 100%; padding: 20px 22px;
108
- box-shadow: var(--shadow-pop);
109
- }
110
- .harbor-modal h2 { color: var(--ink); margin: 0 0 8px; font-size: 16px; font-weight: 650; }
111
- .harbor-modal p { color: var(--ink); font-size: 13.5px; margin: 0 0 12px; }
112
- .harbor-modal label { display: block; font-size: 13px; color: var(--ink-soft); margin: 10px 0 4px; }
113
- .harbor-modal input[type=text], .harbor-modal textarea {
114
- width: 100%; box-sizing: border-box; font: inherit; font-size: 13.5px;
115
- padding: 7px 9px; border: var(--line) solid var(--rule); border-radius: var(--radius-sm);
116
- background: var(--bg-card); color: var(--ink);
117
- }
118
- .harbor-modal__check { display: flex; align-items: flex-start; gap: 8px; margin: 12px 0 0; font-size: 13px; color: var(--ink); }
119
- .harbor-modal__check input { margin-top: 3px; }
120
- .harbor-modal__actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
1
+ /* harbor.css — Page-local styles for the Harbor (task 1003469 rebuild).
2
+ The harbor is one panel of oversight rows grouped by lifecycle, over the
3
+ shell's glass summary strip; everything structural is oversight.css. What is
4
+ the harbor's own is the one line a barred builder carries. The ?v= cache-bust
5
+ is stamped from this file's content hash at boot (src/asset-version.js). */
6
+
7
+ /* A barred builder: the reason under the row's title, in the danger ink and at
8
+ the body measure. Not a coloured bar — the world marks state with a pill. */
9
+ .harbor-barred { margin: 6px 0 0; font-size: 13px; line-height: 1.5; color: var(--danger); max-width: var(--measure); overflow-wrap: anywhere; }
@@ -9,6 +9,7 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <link rel="stylesheet" href="/builders/oversight.css">
12
13
  <link rel="stylesheet" href="/builders/harbor.css">
13
14
  </head>
14
15
  <body data-page="harbor" data-page-title="Harbor">
@@ -19,24 +20,34 @@
19
20
  <div class="app-body">
20
21
  <header class="app-topbar" id="app-topbar"></header>
21
22
  <div class="app-content">
22
- <!-- task 1003307: the board page head. Harbor shipped with NO <h1> at all
23
- (one-header-main-h1 measured 0 on every render), so the re-skin
24
- carries the structural fix rather than porting the hole. The heading
25
- is the LABEL voice, not a display line: on a working surface the
26
- page's own name is the least interesting thing on it. -->
23
+ <!-- The board page head (task 1003307): the LABEL voice, not a display
24
+ line, because on a working surface the page's own name is the least
25
+ interesting thing on it. -->
27
26
  <div class="page-head page-head--board">
28
27
  <h1 class="page-head__h">Harbor</h1>
29
28
  <p class="page-head__sub" id="harbor-sub">Loading dev boxes…</p>
30
29
  </div>
31
30
 
32
31
  <main id="hall-main">
33
- <p class="harbor-sealed" id="harbor-sealed" hidden>Archons only.</p>
32
+ <!-- task 1003469: the sealed state is a panel with a heading, not a bare
33
+ line on the ground. harbor.js writes the real floor into the lede
34
+ from the pack's rank label, so the copy cannot drift from ADR 0157. -->
35
+ <section class="scroll" id="harbor-sealed" aria-labelledby="harbor-sealed-h" hidden>
36
+ <h2 class="scroll__h" id="harbor-sealed-h">Restricted</h2>
37
+ <p class="scroll__lede" id="harbor-sealed-body">This page opens at a higher rank.</p>
38
+ </section>
34
39
 
35
40
  <section id="harbor-body" hidden>
36
- <div class="harbor-summary" id="harbor-summary"></div>
37
- <div class="harbor-dock" id="harbor-dock">
38
- <p class="harbor-empty">Loading dev boxes…</p>
39
- </div>
41
+ <!-- The glass summary strip (the shell's .stat-row), then ONE panel of
42
+ oversight rows grouped by lifecycle — the 280px card deck with a
43
+ coloured roof band per box is gone (task 1003469). -->
44
+ <div class="stat-row ov-summary" id="harbor-summary" aria-label="Fleet summary"></div>
45
+
46
+ <section class="scroll" aria-labelledby="harbor-dock-h">
47
+ <h2 class="scroll__h" id="harbor-dock-h">Dev boxes</h2>
48
+ <p class="scroll__lede">Every builder's box, running ones first. Close a box to reclaim it; block a builder to keep them from opening another.</p>
49
+ <div id="harbor-dock"><span class="kit-skel"></span></div>
50
+ </section>
40
51
  </section>
41
52
  </main>
42
53
 
@@ -52,6 +63,7 @@
52
63
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
53
64
  <script src="/builders/palette.js" defer></script>
54
65
  <script src="/builders/dom-utils.js?v=2026-06-16-harbor"></script>
55
- <script src="/builders/harbor.js?v=2026-06-16-harbor"></script>
66
+ <script src="/builders/hall-kit.js"></script>
67
+ <script src="/builders/harbor.js?v=2026-09-02-harbor-rows"></script>
56
68
  </body>
57
69
  </html>
@@ -1,9 +1,9 @@
1
1
  // public-builders/harbor.js — the Harbor (/builders/harbor).
2
2
  //
3
3
  // Metic+ dev-box management view (idea 257 / task 1163; Archon-only until ADR 0157
4
- // moved `box.fleet.manage` to the working rank). Every builder's dev box is rendered
5
- // as a card on the dock; a Metic or Archon can close any box and block a builder from
6
- // opening new ones.
4
+ // moved `box.fleet.manage` to the working rank). Every builder's dev box is one
5
+ // oversight row in a panel, grouped by lifecycle (task 1003469); a Metic or Archon
6
+ // can close any box and block a builder from opening new ones.
7
7
  //
8
8
  // Like the Watch page, this reads /api/bongos/me to learn the viewer's rank and
9
9
  // shows a restricted notice below the Metic floor — DEFENCE IN DEPTH only. The data +
@@ -21,6 +21,7 @@
21
21
  // through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
22
22
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
23
23
  const { escapeHtml, fmtInt } = window.OTB;
24
+ const { emptyStateHtml } = window.OTBKit;
24
25
  const rankLabel = (rank) => window.OTB.rankLabel(rank, { empty: '' });
25
26
  const toast = (msg) => window.OTB.toast(msg);
26
27
 
@@ -69,18 +70,16 @@
69
70
  return `$${v.toFixed(2)}`;
70
71
  }
71
72
 
72
- // Map a box state (+ block flag) to its card styling + pill label.
73
- function stateStyle(state, blocked) {
74
- if (blocked) return { mod: 'blocked', pill: 'blocked', pillMod: 'blocked' };
75
- switch (state) {
76
- case 'active': return { mod: 'active', pill: 'active', pillMod: 'active' };
77
- case 'parked': return { mod: 'parked', pill: 'parked', pillMod: '' };
78
- case 'provisioning':
79
- case 'waking':
80
- case 'parking': return { mod: 'pending', pill: state, pillMod: 'pending' };
81
- case 'error': return { mod: 'error', pill: 'error', pillMod: '' };
82
- default: return { mod: 'gone', pill: state || 'none', pillMod: '' };
83
- }
73
+ // The lifecycle state as a hairline pill in the state's colour — the shell's
74
+ // .fact-pill states, never a coloured band on the row (task 1003469 retired the
75
+ // 4px "roof" strip each card wore).
76
+ function statePill(state) {
77
+ const s = state || 'none';
78
+ const mod = s === 'active' ? 'ok'
79
+ : (s === 'provisioning' || s === 'waking' || s === 'parking') ? 'warn'
80
+ : s === 'error' ? 'danger'
81
+ : 'quiet';
82
+ return `<span class="fact-pill fact-pill--${mod}">${escapeHtml(s)}</span>`;
84
83
  }
85
84
 
86
85
  // ---- modal ---------------------------------------------------------------
@@ -90,26 +89,30 @@
90
89
  if (root) root.innerHTML = '';
91
90
  }
92
91
 
93
- // Build a modal. `fields` is optional extra HTML (inputs). onConfirm receives
94
- // the modal element so the caller can read its inputs.
95
- function openModal({ title, bodyHtml, confirmLabel, danger, onConfirm }) {
92
+ // Build the confirm dialog on the shell's --z-modal layer: the oversight
93
+ // family's .ov-modal (oversight.css). `bodyHtml` is optional extra HTML
94
+ // (inputs); onConfirm receives the dialog so the caller can read them.
95
+ function openModal({ title, bodyHtml, confirmLabel, onConfirm }) {
96
96
  const root = document.getElementById('harbor-modal-root');
97
97
  root.innerHTML = '';
98
98
  const bg = document.createElement('div');
99
- bg.className = 'harbor-modal-bg';
99
+ bg.className = 'ov-modal-bg';
100
100
  bg.innerHTML =
101
- `<div class="harbor-modal" role="dialog" aria-modal="true">
102
- <h2>${escapeHtml(title)}</h2>
101
+ `<div class="ov-modal" role="dialog" aria-modal="true" aria-labelledby="harbor-modal-h">
102
+ <h2 class="scroll__h" id="harbor-modal-h">${escapeHtml(title)}</h2>
103
103
  ${bodyHtml}
104
- <div class="harbor-modal__actions">
105
- <button class="harbor-btn" data-act="cancel">Cancel</button>
106
- <button class="harbor-btn ${danger ? 'harbor-btn--danger' : ''}" data-act="confirm">${escapeHtml(confirmLabel)}</button>
104
+ <div class="ov-modal__actions">
105
+ <button type="button" class="btn-ghost" data-act="cancel">Cancel</button>
106
+ <button type="button" class="btn-accent" data-act="confirm">${escapeHtml(confirmLabel)}</button>
107
107
  </div>
108
108
  </div>`;
109
109
  bg.addEventListener('click', (e) => { if (e.target === bg) closeModal(); });
110
+ bg.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
110
111
  bg.querySelector('[data-act=cancel]').addEventListener('click', closeModal);
111
112
  bg.querySelector('[data-act=confirm]').addEventListener('click', () => onConfirm(bg));
112
113
  root.appendChild(bg);
114
+ const first = bg.querySelector('textarea, input, [data-act=confirm]');
115
+ if (first) first.focus();
113
116
  }
114
117
 
115
118
  // ---- actions -------------------------------------------------------------
@@ -122,7 +125,6 @@
122
125
  bodyHtml: `<p>This will ${verb} <strong>${escapeHtml(who)}</strong>'s dev box (state: ${escapeHtml(box.state)}). Any uncommitted work on that box is lost. The box is destroyed; the builder can open a fresh one unless they are also blocked.</p>
123
126
  <p>A fresh box has a <strong>new SSH identity</strong>. If their Claude Desktop later says &ldquo;Host denied&rdquo; or asks for a password, tell them to fully restart Desktop (it re-pins) or press Repair — not to keep rebuilding, which only rotates the identity again.</p>`,
124
127
  confirmLabel: 'Close box',
125
- danger: true,
126
128
  onConfirm: async () => {
127
129
  try {
128
130
  const r = await sendJson('POST', `/boxes/${box.builder.id}/close`, {});
@@ -146,9 +148,8 @@
146
148
  `<p>${escapeHtml(who)} will not be able to open or wake a dev box until you unblock them. A box they already have running is left alone unless you also close it below.</p>
147
149
  <label for="harbor-block-reason">Reason (shown to the builder)</label>
148
150
  <textarea id="harbor-block-reason" rows="2" placeholder="e.g. runaway cost — paused pending review"></textarea>
149
- ${hasLiveBox ? `<label class="harbor-modal__check"><input type="checkbox" id="harbor-block-close"> Also close the box they have running now (state: ${escapeHtml(box.state)})</label>` : ''}`,
151
+ ${hasLiveBox ? `<label class="ov-modal__check"><input type="checkbox" id="harbor-block-close"> Also close the box they have running now (state: ${escapeHtml(box.state)})</label>` : ''}`,
150
152
  confirmLabel: 'Block builder',
151
- danger: true,
152
153
  onConfirm: async (modal) => {
153
154
  const reason = (modal.querySelector('#harbor-block-reason') || {}).value || '';
154
155
  const closeEl = modal.querySelector('#harbor-block-close');
@@ -175,7 +176,6 @@
175
176
  title: `Lift the block on ${who}?`,
176
177
  bodyHtml: `<p>${escapeHtml(who)} will be able to open dev boxes again.</p>`,
177
178
  confirmLabel: 'Unblock',
178
- danger: false,
179
179
  onConfirm: async () => {
180
180
  try {
181
181
  await sendJson('PATCH', `/boxes/${box.builder.id}/block`, { blocked: false });
@@ -192,56 +192,56 @@
192
192
 
193
193
  // ---- render --------------------------------------------------------------
194
194
 
195
- function renderContainer(box, costByBuilder) {
196
- const blocked = box.blocked && box.blocked.is;
197
- const st = stateStyle(box.state, blocked);
198
- const who = escapeHtml(box.builder.github_login || `#${box.builder.id}`);
195
+ // One box, one oversight row (oversight.css .ov-row): who and their rank, the
196
+ // state pill, then the facts a fleet operator scans for — make, address, how
197
+ // long it has been up, how long idle, what it has cost — the barred note when
198
+ // there is one, and the two actions on the right.
199
+ function renderRow(box, costByBuilder) {
200
+ const blocked = !!(box.blocked && box.blocked.is);
201
+ const who = escapeHtml(box.builder.display_name || box.builder.github_login || `#${box.builder.id}`);
202
+ const login = box.builder.github_login ? `<span class="ov-mono">@${escapeHtml(box.builder.github_login)}</span>` : '';
199
203
  const rank = rankLabel(box.builder.rank);
200
204
  const cost = costByBuilder.get(String(box.builder.id));
201
205
 
202
- const rows = [];
206
+ const facts = [statePill(box.state)];
207
+ if (blocked) facts.push('<span class="fact-pill fact-pill--danger">blocked</span>');
208
+ if (rank) facts.push(`<span class="fact-chip">${escapeHtml(rank)}</span>`);
203
209
  if (box.region || box.size_slug) {
204
- rows.push(['Make', escapeHtml([box.region, box.size_slug].filter(Boolean).join(' · '))]);
210
+ facts.push(`<span class="ov-fact">${escapeHtml([box.region, box.size_slug].filter(Boolean).join(' · '))}</span>`);
205
211
  }
206
- if (box.ip && box.state === 'active') rows.push(['IP', escapeHtml(box.ip)]);
212
+ if (box.ip && box.state === 'active') facts.push(`<span class="ov-mono">${escapeHtml(box.ip)}</span>`);
207
213
  if (box.state === 'active') {
208
- rows.push(['Up for', fmtAgo(box.provisioned_at)]);
209
- rows.push(['Idle', `${fmtAgo(box.last_activity_at)}${box.claude_active ? ' · claude live' : ''}`]);
214
+ facts.push(`<span class="ov-fact">up <b>${fmtAgo(box.provisioned_at)}</b></span>`);
215
+ facts.push(`<span class="ov-fact">idle <b>${fmtAgo(box.last_activity_at)}</b>${box.claude_active ? ' · claude live' : ''}</span>`);
210
216
  } else if (box.state === 'parked') {
211
- rows.push(['Parked', fmtAgo(box.parked_at)]);
212
- } else if (CLOSEABLE.has(box.state)) {
213
- rows.push(['State since', fmtAgo(box.last_activity_at || box.provisioned_at)]);
217
+ facts.push(`<span class="ov-fact">parked <b>${fmtAgo(box.parked_at)}</b> ago</span>`);
218
+ } else if (CLOSEABLE.has(box.state) || box.state === 'provisioning') {
219
+ facts.push(`<span class="ov-fact">in this state <b>${fmtAgo(box.last_activity_at || box.provisioned_at)}</b></span>`);
214
220
  }
215
- rows.push(['Cost', cost != null ? fmtUsd(cost) : fmtUsd(box.compute_cost_usd)]);
216
-
217
- const rowsHtml = rows.map(([k, v]) =>
218
- `<li><span class="k">${escapeHtml(k)}</span><span class="v">${v}</span></li>`).join('');
221
+ facts.push(`<span class="ov-fact">cost <b>${cost != null ? fmtUsd(cost) : fmtUsd(box.compute_cost_usd)}</b></span>`);
219
222
 
220
- const blockedNote = blocked
221
- ? `<p class="harbor-blocked-note">Barred from opening boxes${box.blocked.reason ? `: ${escapeHtml(box.blocked.reason)}` : '.'}</p>`
223
+ const barred = blocked
224
+ ? `<p class="harbor-barred">Barred from opening boxes${box.blocked.reason ? `: ${escapeHtml(box.blocked.reason)}` : '.'}</p>`
222
225
  : '';
223
226
 
224
227
  const closeable = CLOSEABLE.has(box.state);
225
- const closeLabel = box.state === 'parked' ? 'Destroy' : 'Close';
226
228
  const actions = [];
227
- if (closeable) actions.push(`<button class="harbor-btn harbor-btn--danger" data-act="close">${closeLabel}</button>`);
229
+ if (closeable) {
230
+ actions.push(`<button type="button" class="btn-ghost btn-ghost--danger" data-act="close">${box.state === 'parked' ? 'Destroy' : 'Close'}</button>`);
231
+ }
228
232
  actions.push(blocked
229
- ? `<button class="harbor-btn" data-act="unblock">Unblock</button>`
230
- : `<button class="harbor-btn" data-act="block">Block</button>`);
233
+ ? '<button type="button" class="btn-ghost" data-act="unblock">Unblock</button>'
234
+ : '<button type="button" class="btn-ghost" data-act="block">Block</button>');
231
235
 
232
- const el = document.createElement('article');
233
- el.className = `harbor-container harbor-container--${st.mod}`;
236
+ const el = document.createElement('li');
237
+ el.className = 'ov-row';
234
238
  el.innerHTML =
235
- `<div class="harbor-container__roof" aria-hidden="true"></div>
236
- <div class="harbor-container__body">
237
- <div class="harbor-container__head">
238
- <span class="harbor-container__who">${who}<span class="harbor-container__rank">${escapeHtml(rank)}</span></span>
239
- <span class="harbor-pill harbor-pill--${st.pillMod}">${escapeHtml(st.pill)}</span>
240
- </div>
241
- <ul class="harbor-rows">${rowsHtml}</ul>
242
- ${blockedNote}
243
- <div class="harbor-actions">${actions.join('')}</div>
244
- </div>`;
239
+ `<div class="ov-row__main">
240
+ <span class="ov-row__title">${who}${login}</span>
241
+ <div class="ov-row__facts">${facts.join('')}</div>
242
+ ${barred}
243
+ </div>
244
+ <div class="ov-row__right"><div class="ov-row__actions">${actions.join('')}</div></div>`;
245
245
 
246
246
  const closeBtn = el.querySelector('[data-act=close]');
247
247
  if (closeBtn) closeBtn.addEventListener('click', () => confirmClose(box));
@@ -252,26 +252,50 @@
252
252
  return el;
253
253
  }
254
254
 
255
+ // The fleet reads in three runs — running, parked or in transition, no box —
256
+ // so the operator's eye lands on the boxes that cost money first. Within a run
257
+ // the API's order holds (most recently touched first). An empty run is not
258
+ // drawn: a head over nothing is a promise the page cannot keep.
259
+ const GROUPS = [
260
+ { name: 'Running', test: (b) => b.state === 'active' },
261
+ { name: 'Parked or in transition', test: (b) => CLOSEABLE.has(b.state) || b.state === 'provisioning' },
262
+ { name: 'No box', test: () => true },
263
+ ];
264
+
265
+ function renderDock(boxes, costByBuilder) {
266
+ const dock = document.getElementById('harbor-dock');
267
+ dock.innerHTML = '';
268
+ let left = boxes.slice();
269
+ for (const g of GROUPS) {
270
+ const mine = left.filter(g.test);
271
+ left = left.filter((b) => !g.test(b));
272
+ if (!mine.length) continue;
273
+ const sec = document.createElement('section');
274
+ sec.className = 'ov-group';
275
+ sec.innerHTML = `<div class="ov-group__head"><span class="ov-group__name">${escapeHtml(g.name)}</span><span class="ov-group__n">${fmtInt(mine.length)}</span></div>`;
276
+ const list = document.createElement('ul');
277
+ list.className = 'ov-rows';
278
+ for (const box of mine) list.appendChild(renderRow(box, costByBuilder));
279
+ sec.appendChild(list);
280
+ dock.appendChild(sec);
281
+ }
282
+ }
283
+
284
+ // The glass strip above the panel: the shell's .stat-row, each tile a figure
285
+ // with the one clause that makes it a fact rather than a number.
255
286
  function renderSummary(boxes, totalUsd) {
256
287
  const afloat = boxes.filter((b) => CLOSEABLE.has(b.state)).length;
257
288
  const active = boxes.filter((b) => b.state === 'active').length;
289
+ const live = boxes.filter((b) => b.state === 'active' && b.claude_active).length;
258
290
  const blocked = boxes.filter((b) => b.blocked && b.blocked.is).length;
259
- const stats = [
260
- ['Boxes live', fmtInt(afloat)],
261
- ['Active now', fmtInt(active)],
262
- ['Blocked builders', fmtInt(blocked)],
263
- ['Total cost', fmtUsd(totalUsd)],
291
+ const tiles = [
292
+ ['Boxes live', fmtInt(afloat), `of ${fmtInt(boxes.length)} builder${boxes.length === 1 ? '' : 's'}`],
293
+ ['Active now', fmtInt(active), `${fmtInt(live)} with claude live`],
294
+ ['Blocked builders', fmtInt(blocked), blocked ? 'barred from opening a box' : 'nobody barred'],
295
+ ['Total cost', fmtUsd(totalUsd), 'across every box'],
264
296
  ];
265
- document.getElementById('harbor-summary').innerHTML = stats.map(([label, value]) =>
266
- `<div class="harbor-stat"><div class="harbor-stat__label">${escapeHtml(label)}</div><div class="harbor-stat__value">${value}</div></div>`).join('');
267
- }
268
-
269
- // Sort: live boxes first, then parked, then everything else;
270
- // within a group, most-recently-touched first (the API already orders by
271
- // updated_at desc, so a stable sort by group preserves that).
272
- function sortBoxes(boxes) {
273
- const rank = (b) => (b.state === 'active' ? 0 : CLOSEABLE.has(b.state) ? 1 : (b.blocked && b.blocked.is) ? 2 : 3);
274
- return boxes.map((b, i) => [b, i]).sort((a, z) => (rank(a[0]) - rank(z[0])) || (a[1] - z[1])).map((p) => p[0]);
297
+ document.getElementById('harbor-summary').innerHTML = tiles.map(([label, num, sub]) =>
298
+ `<div class="stat-tile"><span class="stat-tile__label">${escapeHtml(label)}</span><span class="stat-tile__num">${num}</span><span class="stat-tile__sub">${escapeHtml(sub)}</span></div>`).join('');
275
299
  }
276
300
 
277
301
  async function load() {
@@ -285,7 +309,7 @@
285
309
  } catch (err) {
286
310
  if (err && err.kind === '401') return;
287
311
  if (err && err.kind === '403') { showSealed(); return; }
288
- dock.innerHTML = `<p class="harbor-empty">Could not load dev boxes just now.</p>`;
312
+ dock.innerHTML = emptyStateHtml('Could not load dev boxes just now.');
289
313
  return;
290
314
  }
291
315
 
@@ -301,21 +325,23 @@
301
325
  renderSummary(boxes, totalUsd);
302
326
 
303
327
  if (boxes.length === 0) {
304
- dock.innerHTML = `<p class="harbor-empty">No dev boxes yet — no builder has opened one.</p>`;
328
+ dock.innerHTML = emptyStateHtml('No dev boxes yet. No builder has opened one.');
305
329
  return;
306
330
  }
307
- dock.innerHTML = '';
308
- for (const box of sortBoxes(boxes)) dock.appendChild(renderContainer(box, costByBuilder));
331
+ renderDock(boxes, costByBuilder);
309
332
  }
310
333
 
311
334
  function showSealed() {
312
335
  document.getElementById('harbor-sealed').hidden = false;
313
336
  document.getElementById('harbor-body').hidden = true;
314
- document.getElementById('harbor-sub').textContent = 'Archons only.';
337
+ document.getElementById('harbor-sub').textContent = 'Restricted.';
338
+ // The floor in the pack's own words (ADR 0157: metic), never a rank spelled into the source.
339
+ const body = document.getElementById('harbor-sealed-body');
340
+ if (body) body.textContent = `Available from the ${rankLabel('metic')} rank up.`;
315
341
  }
316
342
 
317
343
  async function init() {
318
- // Confirm rank=archon for a friendly Archons-only notice; the server gates
344
+ // Confirm the viewer's rank for a friendly restricted notice; the server gates
319
345
  // the page + every endpoint regardless (this is cosmetic defence-in-depth).
320
346
  let me;
321
347
  try {
@@ -45,6 +45,23 @@
45
45
  },
46
46
  "out": {
47
47
  "auth": false
48
+ },
49
+ "block": {
50
+ "auth": true,
51
+ "actions": [
52
+ [
53
+ "wait",
54
+ 700
55
+ ],
56
+ [
57
+ "clickIfVisible",
58
+ "[data-act=block]"
59
+ ],
60
+ [
61
+ "wait",
62
+ 250
63
+ ]
64
+ ]
48
65
  }
49
66
  },
50
67
  "reducedMotion": {
@@ -55,5 +72,6 @@
55
72
  600
56
73
  ]
57
74
  ]
58
- }
75
+ },
76
+ "_states": "task 1003469: `block` opens the confirm modal on the first row that has a Block action. clickIfVisible, because the kit's own stub carries no /boxes fixture and renders the empty dock — the modal is measured against the harness, where the fixture fleet renders."
59
77
  }