@bongos/core 1.19.386 → 1.19.388

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 (63) hide show
  1. package/.bongos-core.json +138 -53
  2. package/config/branding.neutral.json +19 -19
  3. package/config/design-tokens.neutral.json +31 -36
  4. package/docs/adr/0196-chrome-world-token-contract-and-the-landing-sheets.md +31 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/branding-contract.md +1 -1
  7. package/docs/copy-inventory.md +156 -133
  8. package/docs/copy-registry.json +537 -286
  9. package/docs/file-map.md +14 -0
  10. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  11. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  12. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  13. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  14. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  15. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  16. package/modules/dev-box/app/build-resources/icon.png +0 -0
  17. package/modules/dev-box/app/renderer/index.html +1 -1
  18. package/modules/dev-box/app/renderer/styles.css +3 -1
  19. package/modules/dev-box/app/renderer/theme.generated.css +13 -13
  20. package/modules/dev-box/app/scripts/gen-brand.js +13 -13
  21. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +3 -3
  22. package/modules/dev-box/app/scripts/gen-icons.js +3 -3
  23. package/modules/dev-box/app/src/brand.generated.json +1 -1
  24. package/modules/hall-ui/public/government.css +70 -0
  25. package/modules/hall-ui/public/government.html +9 -2
  26. package/modules/hall-ui/public/government.js +175 -32
  27. package/modules/provisioning/routes/provisioning.js +4 -4
  28. package/modules/status-ui/public/explore.html +12 -6
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/public-landing/assets/cosmos.css +82 -64
  32. package/public-landing/assets/world/og-card.jpg +0 -0
  33. package/public-landing/assets/world/r2-bongos-chrome-a-cut.webp +0 -0
  34. package/public-landing/assets/world/r2-bongos-chrome-a-cut.webp.json +12 -0
  35. package/public-landing/assets/world/r2-bongos-chrome-b.jpg +0 -0
  36. package/public-landing/assets/world/r2-bongos-chrome-b.jpg.json +12 -0
  37. package/public-landing/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  38. package/public-landing/assets/world/r2-chrome-blob-one-cut.webp.json +13 -0
  39. package/public-landing/assets/world/r2-chrome-blob-one.jpg +0 -0
  40. package/public-landing/assets/world/r2-chrome-blob-one.jpg.json +12 -0
  41. package/public-landing/assets/world/r2-chrome-blobs-set-cut.webp +0 -0
  42. package/public-landing/assets/world/r2-chrome-blobs-set-cut.webp.json +13 -0
  43. package/public-landing/assets/world/r2-chrome-blobs-set.jpg +0 -0
  44. package/public-landing/assets/world/r2-chrome-blobs-set.jpg.json +12 -0
  45. package/public-landing/assets/world.css +241 -0
  46. package/public-landing/contact.html +1 -1
  47. package/public-landing/index.html +1139 -580
  48. package/public-landing/privacy.html +1 -1
  49. package/public-landing/projects.html +2 -2
  50. package/public-landing/terms.html +1 -1
  51. package/src/bongos/platform-visibility-gate.js +8 -7
  52. package/src/bongos/stealth-gate.js +9 -8
  53. package/src/module-api.js +5 -1
  54. package/src/ui/adapters/claude-design/index.js +2 -2
  55. package/tests/cloud_bongos_pack.mjs +22 -18
  56. package/tests/cosmos_token_contrast.mjs +23 -8
  57. package/tests/government_board_room_copy.mjs +131 -0
  58. package/tests/landing_legal_pages.mjs +8 -5
  59. package/tests/landing_page.mjs +295 -166
  60. package/tests/platform_boot.mjs +3 -1
  61. package/tests/projects_hub.mjs +9 -5
  62. package/tests/projects_hub_pre_uat.mjs +3 -1
  63. package/tests/world_tokens.mjs +351 -0
package/docs/file-map.md CHANGED
@@ -9,6 +9,9 @@
9
9
  /
10
10
  ├── CLAUDE.md ← index + framing (descriptive; DB is source of truth for state)
11
11
  ├── README.md ← public-facing one-paragraph summary
12
+ ├── PRODUCT.md ← durable product truth for the apex/hall/status surfaces (impeccable init, task 1003296): users, positioning, constraints, brand commitments, principles — no palette, no type
13
+ ├── DESIGN.md ← the visual rulebook written FROM the built landing (task 1003298, ADR 0196): palette + derived recipes, type, materials (the chrome plates), components, the motion grammar, both modes; what H1 (hall) and S1 (status) build against. Sidecar .impeccable/design.json
14
+ ├── .impeccable/ ← impeccable skill artifacts: config.json (buildPath: code), surfaces/<route>.md (per-route briefs), mocks/world-assets/ (Higgsfield renders + prompt sidecars; the SOURCE of public-landing/assets/world/), mocks/decision/ (A1 comps), design.json (DESIGN.md sidecar), review/ (finish captures)
12
15
  ├── .gitignore
13
16
  ├── package.json / package-lock.json
14
17
  ├── CODEOWNERS ← owner review on the merge-gate surfaces (real since task 1002611, ADR 0159); inert until branch protection is on (scripts/gds/gating.js) — keep aligned with gate-review.js HARD_FLOOR_GLOBS
@@ -246,6 +249,17 @@
246
249
  │ ├── assets/tray/ + build-resources/ ← generated icons (scripts/gen-icons.js; build-resources name dodges the global build/ gitignore)
247
250
  │ └── test/unit.mjs ← hermetic unit tests (run by the CI workflow, not the root gate)
248
251
  │
252
+ ├── public-landing/ ← the APEX front door + hub surfaces served by src/platform-server.js (landingPage(): apex-only strip → brand-token stamp → theme injection → ?v= asset stamp). Ships to EVERY instance; <!--apex-only:start/end--> regions are removed on a child
253
+ │ ├── index.html ← the landing (/) — v3 "the template's page" of the chrome world (task 1003298, ADR 0196): server-stamped head, the breath (CSS-only signature), live figures/ledger/orbs from the public feeds baked inside sentinels; page-local <style> only (no :root), links /assets/world.css
254
+ │ ├── projects.html ← the hub (/projects?view=map|mine|manage|new) on cosmos.css (A4, task 1003299, rebuilds it in the world)
255
+ │ ├── privacy.html / terms.html / contact.html ← the footer doors (task 1003251), on cosmos.css
256
+ │ ├── assets/
257
+ │ │ ├── world.css ← the WORLD token layer (ADR 0196): contract tier = the pack, derived tier both modes, root-absolute plate token pairs, the reduced-motion kill switch; no page composition
258
+ │ │ ├── world/ ← the six production plates + <file>.json provenance sidecars (embed-prompt.mjs --scan clean): r2-chrome-blobs-set.jpg, r2-chrome-blob-one.jpg, r2-bongos-chrome-b.jpg (black plates, dark mode, lighten) and their -cut.webp alpha twins (light mode); og-card.jpg (the social card, captured from the page)
259
+ │ │ ├── cosmos.css ← the HUB component sheet (task 1002696): contract tier synced to the pack, its own derived + dark tiers, the .btn/.chip/.strip/.proj/.tbl vocabulary; measured by tests/cosmos_token_contrast.mjs
260
+ │ │ └── verro-medallion.png ← the Verro Labs mark (apex-only block)
261
+ │ └── world/assets/ ← the RETIRED gate's clouds/creatures/plates (task 1002407; the gate folded into / in 1003233) — not the chrome plates
262
+ │
249
263
  ├── public/ ← example.com — the game (product)
250
264
  │ ├── index.html
251
265
  │ ├── assets/
@@ -7,7 +7,7 @@
7
7
  <title>Dev Box</title>
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet">
10
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400&family=Manrope:wght@300;400;500;600&display=swap" rel="stylesheet">
11
11
  <link rel="stylesheet" href="theme.generated.css">
12
12
  <link rel="stylesheet" href="styles.css">
13
13
  </head>
@@ -154,7 +154,9 @@ main {
154
154
  width: 100%;
155
155
  margin-top: 10px;
156
156
  }
157
- .btn:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); }
157
+ /* the hover fill is the raw accent; the chrome world's accent carries a light ink
158
+ at 2.19:1, so the label flips to --ink on it (8.12:1) — task 1003298 */
159
+ .btn:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--ink); }
158
160
  .btn:disabled { opacity: 0.55; cursor: default; }
159
161
  .btn--ghost {
160
162
  background: transparent;
@@ -1,16 +1,16 @@
1
1
  /* GENERATED by scripts/gen-brand.js from config/branding.json — do not edit by hand. */
2
2
  :root {
3
- --bg: #f9d9a8;
4
- --bg-card: #fffaf2;
5
- --bg-deep: #f6d8ae;
6
- --ink: #2b170e;
7
- --ink-soft: #6d4a33;
8
- --ink-faint: #785a41;
9
- --rule: #e8a463;
10
- --rule-soft: #f6d8ae;
11
- --accent: #ce4806;
12
- --accent-deep: #9e3406;
13
- --terracotta: #855b3d;
14
- --font-display: "Baloo 2", "Quicksand", system-ui, sans-serif;
15
- --font-body: "Quicksand", system-ui, -apple-system, "Segoe UI", sans-serif;
3
+ --bg: #edecee;
4
+ --bg-card: #fbfafc;
5
+ --bg-deep: #e2e1e4;
6
+ --ink: #17181a;
7
+ --ink-soft: #55565a;
8
+ --ink-faint: #65656a;
9
+ --rule: #c6c6c8;
10
+ --rule-soft: #dcdbdd;
11
+ --accent: #fb9569;
12
+ --accent-deep: #a3481a;
13
+ --terracotta: #c9512c;
14
+ --font-display: "Manrope", "Segoe UI", system-ui, -apple-system, sans-serif;
15
+ --font-body: "Manrope", "Segoe UI", system-ui, -apple-system, sans-serif;
16
16
  }
@@ -73,7 +73,7 @@ function buildBrand(branding) {
73
73
  // Panel header epigraph (branding copy.tagline).
74
74
  TAGLINE: (branding.copy && branding.copy.tagline) || '',
75
75
  // Pre-CSS window background (main.js) — the theme bg so first paint is on-brand.
76
- BG_COLOR: colors.bg || '#fffaf2',
76
+ BG_COLOR: colors.bg || '#fbfafc',
77
77
  };
78
78
  }
79
79
 
@@ -85,19 +85,19 @@ function buildThemeCss(branding) {
85
85
  const v = (x, fb) => x || fb;
86
86
  return `/* GENERATED by scripts/gen-brand.js from config/branding.json — do not edit by hand. */
87
87
  :root {
88
- --bg: ${v(c.bg, '#f9d9a8')};
89
- --bg-card: ${v(c.bgCard, '#fffaf2')};
90
- --bg-deep: ${v(c.bgDeep, '#f6d8ae')};
91
- --ink: ${v(c.ink, '#2b170e')};
92
- --ink-soft: ${v(c.inkSoft, '#6d4a33')};
88
+ --bg: ${v(c.bg, '#edecee')};
89
+ --bg-card: ${v(c.bgCard, '#fbfafc')};
90
+ --bg-deep: ${v(c.bgDeep, '#e2e1e4')};
91
+ --ink: ${v(c.ink, '#17181a')};
92
+ --ink-soft: ${v(c.inkSoft, '#55565a')};
93
93
  --ink-faint: ${v(c.inkFaint, '#785a41')};
94
- --rule: ${v(c.rule, '#e8a463')};
95
- --rule-soft: ${v(c.ruleSoft, '#f6d8ae')};
96
- --accent: ${v(c.accent, '#ce4806')};
97
- --accent-deep: ${v(c.accentDeep, '#9e3406')};
98
- --terracotta: ${v(c.terracotta, '#855b3d')};
99
- --font-display: ${v(fonts.display, '"Baloo 2", "Quicksand", system-ui, sans-serif')};
100
- --font-body: ${v(fonts.body, '"Quicksand", system-ui, -apple-system, "Segoe UI", sans-serif')};
94
+ --rule: ${v(c.rule, '#c6c6c8')};
95
+ --rule-soft: ${v(c.ruleSoft, '#dcdbdd')};
96
+ --accent: ${v(c.accent, '#fb9569')};
97
+ --accent-deep: ${v(c.accentDeep, '#a3481a')};
98
+ --terracotta: ${v(c.terracotta, '#c9512c')};
99
+ --font-display: ${v(fonts.display, '"Manrope", "Segoe UI", system-ui, sans-serif')};
100
+ --font-body: ${v(fonts.body, '"Manrope", "Segoe UI", system-ui, -apple-system, sans-serif')};
101
101
  }
102
102
  `;
103
103
  }
@@ -68,9 +68,9 @@ ICON = 100 # dmg.iconSize
68
68
  # it, so its "fallback" would paint a Cloud Bongos colour into a fully-specified
69
69
  # host build. That is exactly what `sea` did to the Applications-well rim.
70
70
  COLOR_ROLES = {
71
- "bg": "#f9d9a8", "bgDeep": "#f6d8ae", "bgCard": "#fffaf2",
72
- "ink": "#2b170e", "inkSoft": "#6d4a33", "ruleSoft": "#f6d8ae",
73
- "accent": "#ce4806", "accentDeep": "#9e3406", "terracotta": "#855b3d",
71
+ "bg": "#edecee", "bgDeep": "#e2e1e4", "bgCard": "#fbfafc",
72
+ "ink": "#17181a", "inkSoft": "#55565a", "ruleSoft": "#dcdbdd",
73
+ "accent": "#fb9569", "accentDeep": "#a3481a", "terracotta": "#c9512c",
74
74
  }
75
75
 
76
76
  FONT_DIRS = (
@@ -62,9 +62,9 @@ const OUT_BUILD = path.join(APP_DIR, 'build-resources');
62
62
  // it has nothing prompting a host to define it, so its "fallback" would paint a
63
63
  // Cloud Bongos colour into a fully-specified host build.
64
64
  const COLOR_ROLES = {
65
- bgCard: '#fffaf2', bgDeep: '#f6d8ae', ink: '#2b170e',
66
- rule: '#e8a463', ruleSoft: '#f6d8ae',
67
- accent: '#ce4806', accentDeep: '#9e3406', terracotta: '#855b3d',
65
+ bgCard: '#fbfafc', bgDeep: '#e2e1e4', ink: '#17181a',
66
+ rule: '#c6c6c8', ruleSoft: '#dcdbdd',
67
+ accent: '#fb9569', accentDeep: '#a3481a', terracotta: '#c9512c',
68
68
  };
69
69
 
70
70
  // The pack, resolved exactly as gen-brand.js resolves it (env → instance → neutral).
@@ -9,5 +9,5 @@
9
9
  "COPYRIGHT": "© Cloud Bongos",
10
10
  "CONFIG_DIR": "cloudbongos",
11
11
  "TAGLINE": "Building Digitally, as simple as Bongos.",
12
- "BG_COLOR": "#f9d9a8"
12
+ "BG_COLOR": "#edecee"
13
13
  }
@@ -101,3 +101,73 @@
101
101
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
102
102
  color: var(--accent-text); background: var(--surface-2); padding: 1px 5px; border-radius: 4px;
103
103
  }
104
+
105
+ /* ---- the Board Room: one sitting, one decision (task 1003270) -------------
106
+ The item card is NOT `.gov-section`. It used to be, which meant the title of
107
+ the idea being voted on rendered through `.gov-section__head` — 11.5px,
108
+ uppercase, letterspaced, i.e. styled as a table header. Here the title is a
109
+ title, the ballot sits directly under it, the idea's own words are body text,
110
+ and the constitutional metadata is the only thing behind a click. */
111
+ .gov-item {
112
+ margin: 0 0 18px; border: 1px solid var(--border); border-radius: var(--radius);
113
+ background: var(--surface); overflow: hidden;
114
+ }
115
+ .gov-item__head {
116
+ display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
117
+ gap: 6px 12px; padding: 12px 14px 10px;
118
+ }
119
+ .gov-item__title {
120
+ margin: 0; min-width: 0; font-size: 16px; font-weight: 600; line-height: 1.35;
121
+ color: var(--ink); overflow-wrap: anywhere;
122
+ }
123
+ .gov-item__title a { color: inherit; }
124
+ .gov-item__state { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; }
125
+ .gov-item__amend { margin: 0; padding: 0 14px 10px; color: var(--ink-soft); font-size: 12.5px; overflow-wrap: anywhere; }
126
+
127
+ /* The primary action, directly under the title — that adjacency is the fix. */
128
+ .gov-item__act { padding: 2px 14px 14px; }
129
+ .gov-item__cast { margin: 0; color: var(--ink-soft); font-size: 12.5px; }
130
+
131
+ /* The idea itself: open, and set as prose rather than as permission rows. */
132
+ .gov-idea { padding: 12px 14px 2px; border-top: 1px solid var(--border); }
133
+ .gov-idea__part { margin: 0 0 12px; }
134
+ .gov-idea__label { margin: 0 0 3px; font-size: 12px; font-weight: 600; color: var(--ink-soft); }
135
+ .gov-idea__body {
136
+ margin: 0; color: var(--ink); font-size: 13.5px; line-height: 1.5;
137
+ white-space: pre-wrap; overflow-wrap: anywhere;
138
+ }
139
+
140
+ /* Secondary: the tally rides the summary (visible without a click — ADR 0175
141
+ §9), the rules and the open ballot sit inside it. */
142
+ .gov-item__meta { border-top: 1px solid var(--border); }
143
+ .gov-item__summary {
144
+ padding: 8px 14px; cursor: pointer; color: var(--ink-soft); font-size: 12px;
145
+ list-style-position: inside;
146
+ }
147
+ .gov-item__summary:hover { color: var(--ink); }
148
+ .gov-item__metabody { padding: 10px 14px 2px; background: var(--surface-2); }
149
+ .gov-item__metabody .gov-section { margin: 0 0 12px; background: var(--surface); }
150
+ .gov-item__metabody .gov-empty { padding: 0 0 12px; }
151
+
152
+ /* ---- the ballot controls ------------------------------------------------- */
153
+ .gov-vote { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
154
+ .gov-vote > .gov-grid-note { margin: 0; max-width: 62ch; }
155
+ .gov-vote__no { align-self: stretch; }
156
+ /* The disclosure IS the secondary button, so its marker must go — otherwise the
157
+ triangle sits inside the pill. */
158
+ .gov-vote__no > summary { display: inline-block; cursor: pointer; }
159
+ .gov-vote__no > summary::marker { content: ''; }
160
+ .gov-vote__no > summary::-webkit-details-marker { display: none; }
161
+ .gov-vote__no > *:not(summary) { margin-top: 8px; }
162
+ .gov-vote__no > textarea.gov-input { display: block; width: 100%; max-width: 62ch; }
163
+ .gov-vote__sections { display: flex; flex-wrap: wrap; gap: 0 18px; }
164
+ .gov-vote__sections .gov-perm {
165
+ padding: 2px 0; border-bottom: 0; cursor: pointer; font-size: 12.5px; color: var(--ink);
166
+ }
167
+
168
+ /* The ballot rows reuse `.gov-assign-row`, whose four flex children (name, pill,
169
+ timestamp, objection) cannot fit one 390w line — the objection is a paragraph,
170
+ not a cell, so it takes its own row and the rest wraps. Scoped to the metabody
171
+ so the Permissions and Constitution tabs keep their single-line rows. */
172
+ .gov-item__metabody .gov-assign-row { flex-wrap: wrap; }
173
+ .gov-item__metabody .gov-assign-row .gov-perm__guards { flex: 1 1 100%; margin-top: 4px; }
@@ -37,14 +37,21 @@
37
37
  R18/R19; until then the section frames the model honestly. -->
38
38
  <section id="gov-panel-constitution" hidden>
39
39
  <h2 class="gov-h">Constitution</h2>
40
- <p class="gov-grid-note">Three dials decide how this project is governed: <strong>who sits on the board</strong> (a rank — everyone holding it is a member), <strong>what counts as passing</strong> (a first ratifier, or the consent of the sitting), and <strong>how long a vote stays open</strong> (a timed sitting, or no clock at all). They are configuration on the same machinery at every setting — a project starts with the founder ratifying alone and opens up by changing numbers, not code.</p>
40
+ <!-- Task 1003270: the ~60-word explainer that used to sit here said,
41
+ at length, what the three live sentences below say from the real
42
+ config. A reader met the paragraph before the values. -->
43
+ <p class="gov-grid-note">Three dials, all settings rather than code: <strong>who sits on the board</strong>, <strong>what counts as passing</strong>, and <strong>how long a vote stays open</strong>.</p>
41
44
  <div id="gov-constitution"></div>
42
45
  </section>
43
46
 
44
47
  <!-- §2 Board Room — open items, the clock, your vote (R16/R17). -->
45
48
  <section id="gov-panel-board" hidden>
46
49
  <h2 class="gov-h">Board Room</h2>
47
- <p class="gov-grid-note">Where a Full Idea that cleared the completeness grade comes to be ratified — and where the constitution itself is amended. Open items appear here with their clock and a live tally; an objection must carry a reason and name the section it faults, which is why votes are cast here and only mirrored to chat.</p>
50
+ <!-- Task 1003270: one line, then the ballot. Everything the old
51
+ ~50-word paragraph explained is now said by the cards themselves
52
+ — each one carries who votes and how it passes, in plain words,
53
+ under its own summary. -->
54
+ <p class="gov-grid-note">Read an idea, then decide. An objection needs a reason and the section it faults.</p>
48
55
  <div id="gov-board"></div>
49
56
  </section>
50
57
 
@@ -42,6 +42,22 @@
42
42
  // items and the vote. Until a frame is filled it states what belongs there
43
43
  // and shows an honest kit empty state — never a fake or a hardcoded value that
44
44
  // could drift from the real config.
45
+ //
46
+ // TASK 1003270 rebuilt the BOARD ROOM CARD around one claim: the idea is the
47
+ // subject, everything else is apparatus. So the idea's title renders at title
48
+ // size and its five sections render open as prose; the ballot sits directly
49
+ // beneath the title; and the government apparatus — who votes, how it passes,
50
+ // the clock, the open ballot — moved behind one summary that still shows the
51
+ // tally. Two rules came out of it and should survive the next edit here:
52
+ // • NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` /
53
+ // `why_matters` are grammar for the server; a person gets the sentence
54
+ // (decidedByHtml, rankNames, SECTION_LABELS). The single exception is a
55
+ // membership predicate that cannot be PARSED, where the broken string is
56
+ // the actionable fact.
57
+ // • COPY THAT EXPLAINS THE UI TO ITSELF IS DELETED, not shortened.
58
+ // What simplifying may NOT touch is ADR 0175 §8: an objection still carries a
59
+ // reason and names one section. That requirement is why voting lives in the
60
+ // hall and not on a chat reaction.
45
61
 
46
62
  (() => {
47
63
  'use strict';
@@ -497,7 +513,12 @@
497
513
  // and fixable, a board described as working is neither.
498
514
  return `Membership predicate <code>${escapeHtml(raw || 'unset')}</code> cannot be read, so <strong>nobody</strong> sits on the board until it is corrected.`;
499
515
  }
500
- const strong = keys.map((k) => `<strong>${escapeHtml(k)}</strong>`);
516
+ // Task 1003270: the DISPLAY label, not the Greek enum key. The enum is the
517
+ // grammar an owner types into the predicate (and the charter library below
518
+ // still shows it verbatim, which is that library's whole job); this sentence
519
+ // is the one that answers "who governs", so it uses the words the rest of
520
+ // the hall shows a rank in.
521
+ const strong = keys.map((k) => `<strong>${escapeHtml(window.OTB.rankLabel(k))}</strong>`);
501
522
  if (keys.length === 1) {
502
523
  return `Every active builder holding the ${strong[0]} rank — and only that rank — sits on the board. Membership follows the rank, live.`;
503
524
  }
@@ -536,7 +557,7 @@
536
557
  const early = board.close_early_on_full_turnout
537
558
  ? ', closing early the moment every member has voted'
538
559
  : '';
539
- return `A sitting runs <strong>${escapeHtml(String(board.window_minutes))} minutes</strong>${early}. An unvoted window nulls that member's vote — absence never blocks.`;
560
+ return `A sitting runs <strong>${fmtWindow(board.window_minutes)}</strong>${early}. An unvoted window nulls that member's vote — absence never blocks.`;
540
561
  }
541
562
 
542
563
  function historyRow(h) {
@@ -545,8 +566,10 @@
545
566
  const pill = h.outcome === 'passed'
546
567
  ? '<span class="gov-pill gov-pill--seeded">ratified</span>'
547
568
  : '<span class="gov-pill gov-pill--locked">returned</span>';
569
+ // Task 1003270: the rule by name, not by config key. An unknown value still
570
+ // shows verbatim — a rule this build does not know about is worth seeing.
548
571
  const under = h.decided_under && h.decided_under.pass_rule
549
- ? ` · decided under <code>${escapeHtml(h.decided_under.pass_rule)}</code>`
572
+ ? ` · decided ${escapeHtml(PASS_RULE_NAME[h.decided_under.pass_rule] || `under ${h.decided_under.pass_rule}`)}`
550
573
  : '';
551
574
  return `<div class="gov-assign-row">
552
575
  <span class="gov-assign-row__who">${who} proposed <code>${escapeHtml(JSON.stringify(h.proposed))}</code>${h.rationale_md ? ` — ${escapeHtml(h.rationale_md)}` : ''}</span>
@@ -717,11 +740,14 @@
717
740
  const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
718
741
  const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
719
742
  const when = escapeHtml(String(v.cast_at).slice(0, 16).replace('T', ' '));
743
+ // Task 1003270: the faulted section by its LABEL — the words the author
744
+ // wrote under and the objector picked from. `why_matters` is a jsonb key,
745
+ // not something a reader should have to decode.
720
746
  const objection = v.direction === 'no'
721
- ? `<div class="gov-perm__guards">objected to <code>${escapeHtml(v.section_key || '')}</code> — ${escapeHtml(v.reason_md || '')}</div>`
747
+ ? `<div class="gov-perm__guards">objected to <strong>${escapeHtml(SECTION_LABELS[v.section_key] || v.section_key || 'the idea')}</strong> — ${escapeHtml(v.reason_md || '')}</div>`
722
748
  : '';
723
749
  return `<div class="gov-assign-row"><span class="gov-assign-row__who">${who}${mine}</span>
724
- <span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction}</span>
750
+ <span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction === 'no' ? 'objected' : 'yes'}</span>
725
751
  <span class="muted">${when}</span>${objection}</div>`;
726
752
  }).join('');
727
753
  return `<div class="gov-section">${rows}</div>`;
@@ -742,20 +768,102 @@
742
768
 
743
769
  // The idea's five sections, IN FRONT of the voter — the entire reason voting
744
770
  // happens in the hall rather than on a reaction. Rendered in template order.
771
+ //
772
+ // TASK 1003270 — OPEN, AND SET AS PROSE. This used to be a <details> summarised
773
+ // "The idea, in full · completeness 100/100": the substance the vote is about
774
+ // sat behind a click while the constitutional metadata above it stayed expanded,
775
+ // which is exactly backwards. The labels were <code> pills over
776
+ // `.gov-perm__guards`, so an author's answers were styled as config rows. The
777
+ // score moved into the collapsed metadata (decidedByHtml) — it describes the
778
+ // grade the idea cleared, not the idea.
745
779
  function ideaSectionsHtml(item) {
746
780
  const fv = item.subject && item.subject.field_values;
747
781
  if (!fv) return '';
748
- const rows = Object.keys(SECTION_LABELS).map((key) => `
749
- <div class="gov-perm">
750
- <div class="gov-perm__main">
751
- <code class="gov-perm__key">${escapeHtml(SECTION_LABELS[key])}</code>
752
- <div class="gov-perm__guards">${escapeHtml(String(fv[key] ?? ''))}</div>
753
- </div>
754
- </div>`).join('');
755
- const score = item.subject.completeness_score;
756
- return `<details class="gov-section"><summary class="gov-section__head">The idea, in full${score != null ? ` \u00b7 completeness ${escapeHtml(String(score))}/100` : ''}</summary>${rows}</details>`;
782
+ const rows = Object.keys(SECTION_LABELS).map((key) => {
783
+ // An unanswered section is dropped rather than rendered as a bare heading:
784
+ // a graded Full Idea has all five, and a blank one is only noise.
785
+ const body = String(fv[key] ?? '').trim();
786
+ if (!body) return '';
787
+ return `<div class="gov-idea__part">
788
+ <h4 class="gov-idea__label">${escapeHtml(SECTION_LABELS[key])}</h4>
789
+ <p class="gov-idea__body">${escapeHtml(body)}</p>
790
+ </div>`;
791
+ }).join('');
792
+ return rows
793
+ ? `<div class="gov-idea">${rows}</div>`
794
+ : '<p class="gov-empty">This idea has no written sections.</p>';
795
+ }
796
+
797
+ // ---- the sitting's rules, in plain words (task 1003270) --------------------
798
+
799
+ // The card used to carry `tally: 0 yes · 0 no — sitting under <first_ratifier>,
800
+ // membership <rank:archon>` — raw config keys, in a reader's face, ABOVE the
801
+ // idea they were being asked to decide. These say the same thing in the words
802
+ // a person thinks in, filed under a summary where secondary information
803
+ // belongs.
804
+ //
805
+ // Read from the item's SNAPSHOT constitution (the rules in force when the
806
+ // window opened), never today's config — the same rules the server tallies
807
+ // under. Rank KEYS never surface: the display label comes from the branding
808
+ // pack via OTB.rankLabel, as on every other hall surface. The one place a raw
809
+ // predicate still shows is an UNREADABLE one, where the broken string is the
810
+ // actionable fact and hiding it would help nobody.
811
+ function rankNames(keys) {
812
+ const labels = keys.map((k) => escapeHtml(window.OTB.rankLabel(k)));
813
+ if (labels.length === 1) return labels[0];
814
+ return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`;
815
+ }
816
+
817
+ // A sitting length in the unit a person would say it in. The config stores
818
+ // minutes; "2880 minutes" is a number nobody converts in their head.
819
+ function fmtWindow(minutes) {
820
+ const m = Number(minutes);
821
+ if (!Number.isFinite(m) || m <= 0) return `${escapeHtml(String(minutes))} minutes`;
822
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
823
+ if (m % 1440 === 0) return plural(m / 1440, 'day');
824
+ if (m % 60 === 0) return plural(m / 60, 'hour');
825
+ return plural(m, 'minute');
757
826
  }
758
827
 
828
+ // The short name of a pass rule, for a place a whole sentence will not fit.
829
+ const PASS_RULE_NAME = {
830
+ first_ratifier: 'by first ratifier',
831
+ consent: 'by consent',
832
+ majority: 'by majority',
833
+ };
834
+
835
+ const PASS_RULE_PLAIN = {
836
+ first_ratifier: 'The first vote decides it: a yes ratifies, an objection returns it to its author.',
837
+ consent: 'It passes unless someone objects \u2014 silence is agreement, and one reasoned objection returns it.',
838
+ majority: 'It passes only when more than half the board votes yes. Silence is not agreement here.',
839
+ };
840
+
841
+ function decidedByHtml(item) {
842
+ const c = item.constitution || {};
843
+ const past = !!item.closed_at;
844
+ const keys = membershipRankKeys(c.membership);
845
+ const who = keys
846
+ ? `Every active <strong>${rankNames(keys)}</strong> ${past ? 'could vote on this' : 'may vote on this'}.`
847
+ : `Nobody ${past ? 'could vote' : 'may vote'} \u2014 the membership setting <code>${escapeHtml(String(c.membership || 'unset'))}</code> cannot be read.`;
848
+ const rule = PASS_RULE_PLAIN[c.pass_rule] || 'How this sitting passes could not be read.';
849
+ const clock = c.window_minutes === null || c.window_minutes === undefined
850
+ ? `No deadline \u2014 it ${past ? 'stayed' : 'stays'} open until a vote decides it.`
851
+ : `The sitting ${past ? 'ran' : 'runs'} ${fmtWindow(c.window_minutes)}${c.close_early_on_full_turnout ? ', closing early once every member has voted' : ''}.`;
852
+ const score = item.subject ? item.subject.completeness_score : null;
853
+ const grade = score == null
854
+ ? ''
855
+ : `<p class="gov-grid-note">It cleared the completeness grade at ${escapeHtml(String(score))}/100.</p>`;
856
+ return `<p class="gov-grid-note">${who}</p>
857
+ <p class="gov-grid-note">${rule}</p>
858
+ <p class="gov-grid-note">${clock}</p>${grade}`;
859
+ }
860
+
861
+ // TASK 1003270: the ballot is the point of the card, so its copy earns its
862
+ // place or goes. What stayed is the ADR 0175 §8 requirement itself — an
863
+ // objection carries a REASON and names ONE SECTION — because that requirement
864
+ // is the whole reason voting lives in the hall instead of on a chat reaction.
865
+ // Simplifying may not weaken it, so the reason box and the five-way picker are
866
+ // untouched; only the sentences around them shrank.
759
867
  function voteFormHtml(item, viewerId) {
760
868
  if (item.closed_at) return '';
761
869
  const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
@@ -770,16 +878,16 @@
770
878
  const isOwnIdea = item.subject_type === 'full_idea'
771
879
  && item.subject && String(item.subject.author_id) === String(viewerId);
772
880
  const sections = Object.keys(SECTION_LABELS).map((key) => `
773
- <label class="gov-perm" style="cursor:pointer"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
881
+ <label class="gov-perm"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
774
882
  const ownNote = isOwnIdea
775
- ? `<p class="gov-grid-note">This is your idea. You can vote on it \u2014 your position belongs on the record \u2014 but it can\u2019t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
883
+ ? `<p class="gov-grid-note">Your own idea. You may vote, but it can\u2019t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
776
884
  : '';
777
885
  return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
778
886
  ${ownNote}
779
887
  <button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
780
888
  <details class="gov-vote__no">
781
889
  <summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
782
- <p class="gov-grid-note">An objection returns the idea to its author with your reason and the section it faults \u2014 the feedback they revise against.</p>
890
+ <p class="gov-grid-note">This sends the idea back to its author. Say why, and which section.</p>
783
891
  <textarea class="gov-input" data-vote-reason placeholder="Why \u2014 the reason the author will act on" maxlength="5000" rows="3"></textarea>
784
892
  <div class="gov-vote__sections">${sections}</div>
785
893
  <button type="button" class="gov-btn" data-vote="no">Cast objection</button>
@@ -807,27 +915,60 @@
807
915
  loadBoard(); // re-render either way — the room shows what stands
808
916
  }
809
917
 
918
+ // ONE SITTING, ONE DECISION (task 1003270). The card reads top-down in the
919
+ // order a person actually needs it:
920
+ //
921
+ // 1. what is being decided — the idea's TITLE, at title size. It used to
922
+ // render through `.gov-section__head`, i.e. 11.5px uppercase letterspaced:
923
+ // the subject of the vote styled as a table header.
924
+ // 2. the ACTION, immediately under it. Everything that used to sit between
925
+ // the two — a tally of raw config keys, a sentence explaining that the UI
926
+ // had no vote form (it does), and a collapsed idea — is gone or moved, so
927
+ // "Vote yes" lands above the fold on an ordinary window instead of below
928
+ // a wall of prose.
929
+ // 3. the idea itself, open (ideaSectionsHtml).
930
+ // 4. the constitutional metadata, collapsed — who votes, how it passes, the
931
+ // clock, and the open ballot. Still one click away, never invisible: the
932
+ // tally rides the summary, which is the part of it a reader scans for
933
+ // (ADR 0175 §9 — a board whose votes are invisible constrains nobody).
934
+ //
935
+ // A CLOSED sitting has no action and no idea body: its head carries the
936
+ // outcome and the record lives under the same summary.
810
937
  function itemCard(item, viewerId) {
811
938
  const yes = item.votes.filter((v) => v.direction === 'yes').length;
812
939
  const no = item.votes.filter((v) => v.direction === 'no').length;
813
- const state = item.closed_at
814
- ? `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`
815
- : `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`;
940
+ const isOpen = !item.closed_at;
941
+ const state = isOpen
942
+ ? `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`
943
+ : `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`;
816
944
  const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
817
- ? `<div class="gov-perm__guards">proposes: <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</div>`
945
+ ? `<p class="gov-item__amend">Proposes <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` \u2014 ${escapeHtml(item.subject.rationale_md)}` : ''}</p>`
818
946
  : '';
819
947
  const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
820
- const myLine = item.closed_at ? '' : (mine
821
- ? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
822
- : `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
823
- return `<section class="gov-section" id="board-item-${escapeHtml(item.id)}">
824
- <div class="gov-section__head">${subjectLine(item)} ${state}</div>
948
+ // A voter who has cast sees WHAT they cast where the buttons were. The line
949
+ // that used to fill this slot for everyone else — announcing that the reader
950
+ // had not voted and that casting was still to come — is deleted outright: it
951
+ // explained the UI to itself, and the form it promised shipped in R17.
952
+ const action = !isOpen ? ''
953
+ : mine
954
+ ? `<p class="gov-item__cast">You ${mine.direction === 'no' ? 'objected' : 'voted yes'}. One vote per member.</p>`
955
+ : voteFormHtml(item, viewerId);
956
+ const tally = `${yes} yes \u00b7 ${no} objection${no === 1 ? '' : 's'}`;
957
+ return `<section class="gov-item" id="board-item-${escapeHtml(item.id)}">
958
+ <div class="gov-item__head">
959
+ <h3 class="gov-item__title">${subjectLine(item)}</h3>
960
+ <span class="gov-item__state">${state}</span>
961
+ </div>
825
962
  ${amendmentDetail}
826
- <p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
827
- ${myLine}
828
- ${item.closed_at ? '' : ideaSectionsHtml(item)}
829
- ${ballotHtml(item, viewerId)}
830
- ${voteFormHtml(item, viewerId)}
963
+ ${action ? `<div class="gov-item__act">${action}</div>` : ''}
964
+ ${isOpen ? ideaSectionsHtml(item) : ''}
965
+ <details class="gov-item__meta">
966
+ <summary class="gov-item__summary">${tally} \u00b7 who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
967
+ <div class="gov-item__metabody">
968
+ ${decidedByHtml(item)}
969
+ ${ballotHtml(item, viewerId)}
970
+ </div>
971
+ </details>
831
972
  </section>`;
832
973
  }
833
974
 
@@ -905,7 +1046,9 @@
905
1046
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
906
1047
  const isArchon = rank === 'archon';
907
1048
 
908
- document.getElementById('gov-sub').textContent = 'How this project is governed — the constitution it runs, the Board Room, and the ranks & permissions that administer it.';
1049
+ // Task 1003270: the subtitle used to restate the three tab labels sitting
1050
+ // directly beneath it, in ~20 words, above everything actionable.
1051
+ document.getElementById('gov-sub').textContent = 'How this project is governed.';
909
1052
  document.getElementById('gov-body').hidden = false;
910
1053
  wireTabs({ isArchon });
911
1054
  loadBoard(); // the room loads for everyone the page admits
@@ -99,8 +99,8 @@ function esc(s) {
99
99
  const ACCENT = (() => {
100
100
  try {
101
101
  const c = api.branding().theme?.ui?.colors || {};
102
- return { sun: c.accent || '#ce4806', sun600: c.accentDeep || '#9e3406' };
103
- } catch { return { sun: '#ce4806', sun600: '#9e3406' }; }
102
+ return { sun: c.accent || '#fb9569', sun600: c.accentDeep || '#a3481a' };
103
+ } catch { return { sun: '#fb9569', sun600: '#a3481a' }; }
104
104
  })();
105
105
 
106
106
  // Brand styling for the standalone onboarding pages — sunrise palette + Baloo 2 /
@@ -108,7 +108,7 @@ const ACCENT = (() => {
108
108
  // under the instance's own config/branding.json). Light + dark aware; no external
109
109
  // dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
110
110
  const PAGE_STYLE = `
111
- :root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
111
+ :root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--sunInk:#2a1305;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
112
112
  @media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
113
113
  *{box-sizing:border-box}html,body{height:100%}
114
114
  body{margin:0;font-family:"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,var(--bg1) 0%,var(--bg2) 45%,var(--bg3) 100%);background-attachment:fixed;display:flex;align-items:center;justify-content:center;padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
@@ -135,7 +135,7 @@ code{font-family:"Fira Code",ui-monospace,SFMono-Regular,monospace;background:rg
135
135
  @media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
136
136
  .status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
137
137
  .cta{margin-top:1.4rem}
138
- .btn{display:inline-block;background:var(--sun);color:#fff;text-decoration:none;font-weight:700;font-family:"Baloo 2",system-ui,sans-serif;padding:.72rem 1.5rem;border-radius:999px;font-size:1.02rem;box-shadow:0 10px 22px -8px rgba(249,115,22,.7);transition:transform .15s ease,box-shadow .15s ease}
138
+ .btn{display:inline-block;background:var(--sun);color:var(--sunInk);text-decoration:none;font-weight:700;font-family:"Baloo 2",system-ui,sans-serif;padding:.72rem 1.5rem;border-radius:999px;font-size:1.02rem;box-shadow:0 10px 22px -8px rgba(249,115,22,.7);transition:transform .15s ease,box-shadow .15s ease}
139
139
  .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
140
140
  .muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
141
141
  .hidden{display:none!important}