rcf-lite 0.30.0 → 0.32.0

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 (65) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12113.json +1 -1
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +15 -10
  5. package/package.json +1 -1
  6. package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
  7. package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
  8. package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
  9. package/rcf/code-nodes/cn-017.json +4 -2
  10. package/rcf/code-nodes/cn-771.json +15 -0
  11. package/rcf/code-nodes/cn-772.json +14 -0
  12. package/rcf/code-nodes/cn-773.json +16 -0
  13. package/rcf/code-nodes/cn-774.json +20 -0
  14. package/rcf/code-nodes/cn-775.json +15 -0
  15. package/rcf/code-nodes/cn-776.json +17 -0
  16. package/rcf/code-nodes/cn-777.json +21 -0
  17. package/rcf/code-nodes/cn-778.json +14 -0
  18. package/rcf/fbs/fbs-004.json +4 -1
  19. package/rcf/fbs/fbs-201.json +2 -1
  20. package/rcf/fbs/fbs-202.json +18 -2
  21. package/rcf/fbs/fbs-203.json +12 -4
  22. package/rcf/requirements/req-002.json +2 -2
  23. package/rcf/requirements/req-173.json +2 -2
  24. package/rcf/requirements/req-174.json +2 -2
  25. package/rcf/tacs/tac-4122-define-gates.json +7 -2
  26. package/rcf/tacs/tac-4127-readiness-view.json +3 -2
  27. package/rcf/test-suites/ts-004.json +82 -2
  28. package/rcf/test-suites/ts-007.json +1 -1
  29. package/rcf/test-suites/ts-215.json +64 -1
  30. package/rcf/test-suites/ts-216.json +18 -2
  31. package/rcf/test-suites/ts-219.json +10 -2
  32. package/rcf/test-suites/ts-220.json +18 -2
  33. package/rcf/test-suites/ts-223.json +24 -2
  34. package/rcf/test-suites/ts-225.json +10 -2
  35. package/rcf/test-suites/ts-229.json +49 -3
  36. package/rcf/test-suites/ts-230.json +26 -2
  37. package/rcf/test-suites/ts-231.json +16 -1
  38. package/rcf/test-suites/ts-236.json +44 -0
  39. package/rcf/user-stories/us-17302.json +36 -1
  40. package/rcf/user-stories/us-17401.json +11 -1
  41. package/rcf/user-stories/us-17402.json +6 -1
  42. package/rcf/user-stories/us-17403.json +11 -1
  43. package/rcf/user-stories/us-17404.json +16 -1
  44. package/rcf/user-stories/us-17405.json +7 -2
  45. package/rcf/user-stories/us-18001.json +6 -1
  46. package/rcf/user-stories/us-202.json +25 -1
  47. package/rcf/user-stories/us-205.json +6 -1
  48. package/rcf/user-stories/us-206.json +25 -4
  49. package/rcf/user-stories/us-207.json +36 -0
  50. package/releases/releases.yaml +20 -1
  51. package/src/cli/create.js +22 -2
  52. package/src/cli/freeze.js +8 -0
  53. package/src/cli/ledger.js +96 -4
  54. package/src/cli/readiness.js +17 -39
  55. package/src/core/errors/index.js +7 -1
  56. package/src/core/store/writer.js +181 -13
  57. package/src/define/interface-paths.js +47 -0
  58. package/src/define/ledgers.js +86 -7
  59. package/src/mcp/tools.js +1 -1
  60. package/src/query/gates.js +431 -163
  61. package/src/view/html-page.js +43 -13
  62. package/src/view/live-client.js +18 -10
  63. package/src/view/page-init.js +149 -8
  64. package/src/view/style.css +107 -20
  65. package/src/view/view-index.js +22 -0
@@ -59,7 +59,7 @@ import { renderEntitySelector } from './components/entity-selector.js';
59
59
  import { renderSubTabStrip } from './components/sub-tab-strip.js';
60
60
  import { buildDagLayout, buildDagInspectorPayload, renderBuildDag } from './build-dag.js';
61
61
  import { computeReqNeedsWorkIds, needsWorkReasonFor } from './needs-work.js';
62
- import { renderLookupModal, renderSearchButton } from './view-index.js';
62
+ import { renderLookupModal, renderSearchButton, renderThemeControl } from './view-index.js';
63
63
 
64
64
  // Umbrella version stamped at module load (same pattern as src/ruleset/index.js).
65
65
  // Used by the shell footer so the muted "RCF Lite X.Y.Z" line tracks the
@@ -81,10 +81,21 @@ const LIVE_WRAPPER_CLOSE = '</div>';
81
81
  * Render the complete index.html string. Phase 3.8: always includes the
82
82
  * live-content wrapper and the live-client script tag.
83
83
  *
84
+ * `opts.embed = true` strips the three-state theme control from the
85
+ * emitted HTML entirely (ADR-4137 server-side layer). The production
86
+ * server path at `renderModelToPage` serves one page to every client,
87
+ * so it never passes `embed`; the client-side layer in `page-init.js`
88
+ * removes `[data-rcf-theme-control]` from the DOM when `?embed=1` is
89
+ * on the URL. The option is here so `renderPage({ embed: true })` is
90
+ * a one-call proof that the control markup can be absent (used by
91
+ * `test/view/theme-control.test.js` for AC-206-9).
92
+ *
84
93
  * @param {import('./tree-model.js').BuiltTreeModel} model
94
+ * @param {{ embed?: boolean }} [opts]
85
95
  * @returns {string}
86
96
  */
87
- export function renderPage(model) {
97
+ export function renderPage(model, opts) {
98
+ const embed = Boolean(opts && opts.embed);
88
99
  const header = resolveProductHeader(model);
89
100
  const projectName = header.projectName ?? 'RCF project';
90
101
  const contentHtml = renderContent(model);
@@ -124,6 +135,7 @@ export function renderPage(model) {
124
135
  </nav>
125
136
  <div class="tools" aria-label="Viewer tools">
126
137
  ${renderSearchButton()}
138
+ ${embed ? '' : renderThemeControl()}
127
139
  </div>
128
140
  </div>
129
141
  </header>
@@ -831,21 +843,33 @@ function buildSpecFilterText(fbs) {
831
843
  }
832
844
 
833
845
  /**
834
- * Viewer UI refresh PR 2 (shell polish, Baz 2026-10-02; decision 17):
835
- * the one-bar header carries the project name only. The project name
836
- * is sourced from `manifest.projectName`, then `prd.productName`,
837
- * then `prd.productTitle` (the PRD title the brief names). Decision 17
838
- * is explicit: project name prominent after the icon mark, tool name
839
- * out of the bar, no technology chip. When the tree exposes no
840
- * project name, the whole product block collapses and the brand icon
841
- * stands on its own (silent empty state; CSS `.product:empty { display:none; }`).
846
+ * Viewer UI refresh PR 2 (shell polish, Baz 2026-10-02; decision 17)
847
+ * with the issue-295 flip (w-2026-10-06-dave-001): the one-bar header
848
+ * carries the project name only, sourced from `prd.productName` first
849
+ * (the human-readable name an owner edits via `rcf define update
850
+ * PRD-001 --set productName=...`), then `manifest.projectName` (the
851
+ * init-time value kept for ids / paths), then `prd.productTitle` (the
852
+ * PRD title the brief names). Decision 17 remains: project name
853
+ * prominent after the icon mark, tool name out of the bar, no
854
+ * technology chip. When the tree exposes no project name, the whole
855
+ * product block collapses and the brand icon stands on its own
856
+ * (silent empty state; CSS `.product:empty { display:none; }`).
857
+ *
858
+ * The precedence flip is a lite-native cut (US-202, Part 2 cut b):
859
+ * `rcf define update MANIFEST` is not addressable in the current CLI,
860
+ * so before this change manifest.projectName was a one-shot init value
861
+ * and the only way to rename the audit surface was a hand edit of
862
+ * manifest.json. prd.productName is already updatable via `rcf define
863
+ * update PRD-001 --set productName=...`; preferring it in the header
864
+ * means the display name is lite-native without touching the manifest
865
+ * (ids and paths are untouched).
842
866
  *
843
867
  * @param {import('./tree-model.js').BuiltTreeModel} model
844
868
  * @returns {{ projectName: string | null }}
845
869
  */
846
870
  export function resolveProductHeader(model) {
847
- const projectName = model.manifest?.projectName
848
- ?? model.prd?.productName
871
+ const projectName = model.prd?.productName
872
+ ?? model.manifest?.projectName
849
873
  ?? model.prd?.productTitle
850
874
  ?? null;
851
875
  return { projectName };
@@ -858,7 +882,13 @@ function renderProductBlock(header) {
858
882
  // empty state (brief) survives screen-reader output too.
859
883
  return '<div class="product"></div>';
860
884
  }
861
- return `<div class="product"><span class="name">${escapeHtml(header.projectName)}</span>
885
+ // w-2026-10-06-dave-001 (issue 295): .product .name clips its text
886
+ // with text-overflow:ellipsis so a long project name yields rather
887
+ // than pushing the tablist off the bar; the title attribute carries
888
+ // the full name so hover reveals it. Both the title and the body go
889
+ // through escapeHtml so the attribute is safe against injection.
890
+ const safe = escapeHtml(header.projectName);
891
+ return `<div class="product"><span class="name" title="${safe}">${safe}</span>
862
892
  <span class="sub">review surface</span></div>`;
863
893
  }
864
894
 
@@ -507,19 +507,27 @@
507
507
  // the panels back in line with the aria-selected button and re-run
508
508
  // Mermaid in the visible one.
509
509
  function resyncTabsAfterSwap(doc, win) {
510
- // Phase 3.8 tabs; Phase 3.9 added product-map. Product Map MUST be
511
- // in this list so an SSE swap while it is the active tab keeps the
512
- // panel visible; without it, resolveActiveTab falls back to
513
- // overview and the freshly-swapped pm listeners have already been
514
- // rewired by the rcfPage.init() call below.
515
- var TABS = ['overview', 'requirements', 'architecture', 'build', 'product-map'];
516
- // Scope to the top-level nav.tabs to avoid matching pm-group-btn
510
+ // Issue 297 (w-2026-10-06-dave-003, AC-18001-8): the authoritative
511
+ // tab list is the server-rendered nav.tabs itself, not a parallel
512
+ // array that can drift. Earlier the list was hand-copied here and
513
+ // omitted 'readiness' (added as tab 1 in US-18001); a Readiness
514
+ // SSE swap fell back to Overview and both panels went visible.
515
+ // Deriving from the DOM means a future tab cannot be forgotten
516
+ // the same way. Scope to nav.tabs to avoid matching pm-group-btn
517
517
  // nodes (they also carry role="tab").
518
+ var buttons = doc.querySelectorAll('nav.tabs [role="tab"][data-tab]');
519
+ var names = [];
520
+ for (var b = 0; b < buttons.length; b += 1) {
521
+ var n = buttons[b].getAttribute ? buttons[b].getAttribute('data-tab') : null;
522
+ if (n) names.push(n);
523
+ }
518
524
  var selectedBtn = doc.querySelector('nav.tabs [role="tab"][aria-selected="true"]');
519
525
  var selected = selectedBtn && selectedBtn.getAttribute ? selectedBtn.getAttribute('data-tab') : null;
520
- if (!selected || TABS.indexOf(selected) === -1) selected = 'overview';
521
- for (var i = 0; i < TABS.length; i += 1) {
522
- var name = TABS[i];
526
+ if (!selected || names.indexOf(selected) === -1) {
527
+ selected = names.indexOf('overview') !== -1 ? 'overview' : (names[0] || 'overview');
528
+ }
529
+ for (var i = 0; i < names.length; i += 1) {
530
+ var name = names[i];
523
531
  var panel = doc.getElementById('tab-' + name);
524
532
  if (!panel) continue;
525
533
  if (name === selected) panel.removeAttribute('hidden');
@@ -62,15 +62,151 @@
62
62
  else root.removeAttribute('data-embed');
63
63
  }
64
64
 
65
+ // Theme provenance (PR 291 landing review F1). live-client re-runs
66
+ // rcfPage.init() after every SSE swap, so the boot theme must be
67
+ // resolved and applied ONCE. The state lives on <html> (which
68
+ // survives every swap and any re-evaluation of this script), like
69
+ // the __rcf*Wired sentinels elsewhere in this file. `source` is the
70
+ // last thing that set the theme: 'query' (?theme=), 'stored'
71
+ // (rcf-view:v1:theme), 'default' (auto), 'host' (rcf-view-theme
72
+ // postMessage) or 'control' (a click on the theme control). A
73
+ // re-init never overrides 'host' or 'control'.
74
+ function themeState() {
75
+ var root = document.documentElement;
76
+ if (!root.__rcfThemeState) root.__rcfThemeState = { booted: false, source: null, choice: 'auto' };
77
+ return root.__rcfThemeState;
78
+ }
79
+
80
+ function setTheme(mode, source) {
81
+ applyTheme(mode === 'auto' ? null : mode);
82
+ var st = themeState();
83
+ st.source = source;
84
+ st.choice = mode;
85
+ }
86
+
87
+ // Three-state theme control (ADR-4137 standalone-theme-persistence,
88
+ // Baz 2026-10-04 w-2026-10-04-dave-001). The control writes
89
+ // `rcf-view:v1:theme` in non-embed mode only; under embed the control
90
+ // is removed from the DOM entirely and the key is never read or
91
+ // written. The key sits under the shared `rcf-view:v1:` namespace so
92
+ // the PR 9 localStorage namespace lint stays green.
93
+ var THEME_STORAGE_KEY = 'rcf-view:v1:theme';
94
+
95
+ function isEmbedActive() {
96
+ try { return document.documentElement.getAttribute('data-embed') === '1'; }
97
+ catch (e) { return false; }
98
+ }
99
+
100
+ function safeStorage() {
101
+ try {
102
+ if (typeof window === 'undefined' || !window.localStorage) return null;
103
+ return window.localStorage;
104
+ } catch (e) {
105
+ // localStorage can throw on strict privacy modes; treat as absent.
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function readStoredTheme() {
111
+ // The storage boundary (ADR-4137 consequences): under embed we
112
+ // never read the shared key, so a future re-render of the control
113
+ // under embed cannot accidentally pick up a value either.
114
+ if (isEmbedActive()) return null;
115
+ var s = safeStorage();
116
+ if (!s) return null;
117
+ try {
118
+ var v = s.getItem(THEME_STORAGE_KEY);
119
+ if (v === 'light' || v === 'dark' || v === 'auto') return v;
120
+ return null;
121
+ } catch (e) { return null; }
122
+ }
123
+
124
+ function writeStoredTheme(v) {
125
+ if (isEmbedActive()) return; // never write under embed (ADR-4137)
126
+ if (v !== 'light' && v !== 'dark' && v !== 'auto') return;
127
+ var s = safeStorage();
128
+ if (!s) return;
129
+ try { s.setItem(THEME_STORAGE_KEY, v); } catch (e) { /* quota or disabled */ }
130
+ }
131
+
132
+ // Reflect the current effective theme choice (light|dark|auto) on
133
+ // the control's three buttons. Idempotent across live swaps so the
134
+ // Router re-run after an SSE innerHTML swap of `#rcf-live-content`
135
+ // re-syncs (the control itself sits outside the swap wrapper so it
136
+ // survives, but a fresh page-init call still re-wires guards).
137
+ function reflectThemeChoice(mode) {
138
+ var buttons = document.querySelectorAll('[data-rcf-theme-control] [data-rcf-theme]');
139
+ for (var i = 0; i < buttons.length; i += 1) {
140
+ var b = buttons[i];
141
+ var isTarget = b.getAttribute('data-rcf-theme') === mode;
142
+ b.setAttribute('aria-pressed', isTarget ? 'true' : 'false');
143
+ }
144
+ }
145
+
146
+ function wireThemeControl() {
147
+ if (isEmbedActive()) {
148
+ // ADR-4137: control is absent from the DOM entirely under embed,
149
+ // not merely hidden by CSS. renderPage({ embed: true }) already
150
+ // omits the markup; this strip-on-boot is the client-side layer
151
+ // for the production path where the server renders one page for
152
+ // every client regardless of the client's query.
153
+ var ctl = document.querySelector('[data-rcf-theme-control]');
154
+ if (ctl && ctl.parentNode) ctl.parentNode.removeChild(ctl);
155
+ return;
156
+ }
157
+ var buttons = document.querySelectorAll('[data-rcf-theme-control] [data-rcf-theme]');
158
+ for (var i = 0; i < buttons.length; i += 1) {
159
+ var btn = buttons[i];
160
+ if (btn.__rcfThemeWired) continue;
161
+ btn.__rcfThemeWired = true;
162
+ btn.addEventListener('click', function (ev) {
163
+ var mode = ev.currentTarget.getAttribute('data-rcf-theme');
164
+ if (mode !== 'light' && mode !== 'dark' && mode !== 'auto') return;
165
+ setTheme(mode, 'control');
166
+ writeStoredTheme(mode);
167
+ reflectThemeChoice(mode);
168
+ });
169
+ }
170
+ }
171
+
172
+ // Resolve the effective theme choice for boot, in the precedence
173
+ // ADR-4137 pins: explicit ?theme= wins first; then (non-embed only)
174
+ // the stored value; then auto. The stored value is NEVER consulted
175
+ // under embed. Returns { mode: 'light'|'dark'|'auto', source:
176
+ // 'query'|'stored'|'default' }.
177
+ function resolveBootTheme(query) {
178
+ var q = query && (query.theme === 'light' || query.theme === 'dark' || query.theme === 'auto')
179
+ ? query.theme : null;
180
+ if (q) return { mode: q, source: 'query' };
181
+ var stored = readStoredTheme();
182
+ if (stored === 'light' || stored === 'dark' || stored === 'auto') return { mode: stored, source: 'stored' };
183
+ return { mode: 'auto', source: 'default' };
184
+ }
185
+
65
186
  function initShellFromQuery() {
66
187
  var q = parseQuery(window.location.search);
67
- // theme: explicit light|dark wins, auto falls through to the
68
- // :root:not([data-theme]) + @media prefers-color-scheme block in
69
- // style.css. Absent stays light (the base :root tokens).
70
- var theme = q.theme;
71
- if (theme === 'light' || theme === 'dark' || theme === 'auto') applyTheme(theme === 'auto' ? null : theme);
72
- // embed: any value other than exactly "1" is ignored.
188
+ // embed first so isEmbedActive() reads the right boundary when
189
+ // the theme precedence check consults storage.
73
190
  applyEmbed(q.embed === '1');
191
+ var st = themeState();
192
+ var qTheme = (q.theme === 'light' || q.theme === 'dark' || q.theme === 'auto') ? q.theme : null;
193
+ if (!st.booted) {
194
+ // First init: resolve the boot theme in ADR-4137 precedence and
195
+ // apply it once.
196
+ st.booted = true;
197
+ var boot = resolveBootTheme(q);
198
+ setTheme(boot.mode, boot.source);
199
+ } else if (qTheme && st.source === 'query') {
200
+ // Re-init (SSE swap): re-assert ?theme= only while it is still
201
+ // the last thing that set the theme. A host postMessage or a
202
+ // control click since boot wins and is never overridden; with no
203
+ // ?theme= a re-init applies nothing (embed host themes survive).
204
+ setTheme(qTheme, 'query');
205
+ }
206
+ // Strip or wire the control after embed has been stamped; the
207
+ // helper reads data-embed to pick the branch.
208
+ wireThemeControl();
209
+ reflectThemeChoice(st.choice);
74
210
  }
75
211
 
76
212
  var messageListenerWired = false;
@@ -84,7 +220,12 @@
84
220
  if (!ev || ev.origin !== window.location.origin) return;
85
221
  var data = ev.data || {};
86
222
  if (data.type !== 'rcf-view-theme') return;
87
- if (data.theme === 'light' || data.theme === 'dark') applyTheme(data.theme);
223
+ if (data.theme === 'light' || data.theme === 'dark') {
224
+ setTheme(data.theme, 'host');
225
+ // Live postMessage never writes the stored key (ADR-4137):
226
+ // the host-driven swap is a session-only override.
227
+ reflectThemeChoice(data.theme);
228
+ }
88
229
  });
89
230
  }
90
231
 
@@ -632,7 +773,7 @@
632
773
  btn.__rcfFixtureThemeWired = true;
633
774
  btn.addEventListener('click', function (ev) {
634
775
  var mode = ev.currentTarget.getAttribute('data-rcf-fixture-theme');
635
- applyTheme(mode === 'auto' ? null : mode);
776
+ setTheme(mode === 'light' || mode === 'dark' ? mode : 'auto', 'control');
636
777
  var buttons = document.querySelectorAll('[data-rcf-fixture-theme]');
637
778
  for (var j = 0; j < buttons.length; j += 1) {
638
779
  var b = buttons[j];
@@ -236,6 +236,10 @@ header.app-header .bar {
236
236
  color: var(--sv-ink);
237
237
  overflow: hidden;
238
238
  text-overflow: ellipsis;
239
+ /* w-2026-10-06-dave-001 (issue 295): .product is a flex container; the
240
+ ellipsis only triggers when its child is itself free to shrink below
241
+ intrinsic. min-width:0 unlocks that. */
242
+ min-width: 0;
239
243
  }
240
244
 
241
245
  .product .sub {
@@ -248,8 +252,12 @@ nav.tabs {
248
252
  display: flex;
249
253
  gap: 0.25rem;
250
254
  margin-left: 1rem;
251
- flex: 1 1 auto;
252
- min-width: 0;
255
+ /* w-2026-10-06-dave-001 (issue 295): the tablist grows to fill the
256
+ bar but never shrinks below its intrinsic width (flex-shrink:0),
257
+ so every main tab stays visible and the Find button in .tools
258
+ never sits on top of a clipped tab. .product carries the shrink
259
+ budget via its own min-width:0 and the ellipsis on .product .name. */
260
+ flex: 1 0 auto;
253
261
  overflow-x: auto;
254
262
  -webkit-overflow-scrolling: touch;
255
263
  }
@@ -810,7 +818,10 @@ footer.app-footer a {
810
818
 
811
819
  footer.app-footer a:hover { text-decoration: underline; }
812
820
 
813
- footer.app-footer .sep { color: var(--sv-control); }
821
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: separator was `--sv-control`
822
+ which fell under 4.5:1 in both themes (3.51:1 light, 4.21:1 dark);
823
+ `--sv-muted` is the "muted text" token designed for this. */
824
+ footer.app-footer .sep { color: var(--sv-muted); }
814
825
 
815
826
  footer.app-footer .live {
816
827
  margin-left: auto;
@@ -955,7 +966,7 @@ html[data-embed="1"] .pm-controls { top: 3rem; }
955
966
  color: var(--sv-ink);
956
967
  font-weight: 600;
957
968
  box-shadow: var(--shadow-sm);
958
- border-color: var(--sv-border);
969
+ border-color: var(--sv-control);
959
970
  }
960
971
 
961
972
  .pm-status-filter {
@@ -1151,7 +1162,10 @@ details.pm-bucket[open] > summary.pm-bucket-heading {
1151
1162
  .pm-mini-review { color: var(--sv-link); background: var(--sv-active); border-color: var(--sv-link); }
1152
1163
  .pm-mini-draft { color: var(--sv-muted); background: var(--sv-raised); border-color: var(--sv-border); }
1153
1164
  .pm-mini-needsRevision { color: var(--sv-danger); background: var(--sv-dangerbg); border-color: var(--sv-danger); }
1154
- .pm-mini-superseded { color: var(--sv-control); background: var(--sv-raised); border-color: var(--sv-border); }
1165
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: `--sv-control` on `--sv-raised`
1166
+ was 3.51:1 light / 4.21:1 dark, below the 4.5:1 floor for small text.
1167
+ `--sv-muted` is the matching muted-text token and clears AA in both. */
1168
+ .pm-mini-superseded { color: var(--sv-muted); background: var(--sv-raised); border-color: var(--sv-border); }
1155
1169
 
1156
1170
  .pm-bucket-body { padding: 0.75rem 1rem 1rem; }
1157
1171
  .pm-bucket-body .pm-tad-root { margin-bottom: 0.65rem; }
@@ -1225,7 +1239,9 @@ details.pm-bucket[open] > summary.pm-bucket-heading {
1225
1239
  }
1226
1240
  .pm-blueprint-cap-count {
1227
1241
  font-weight: 500;
1228
- color: var(--sv-control);
1242
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: `--sv-control` fell under
1243
+ the 4.5:1 small-text floor; switched to `--sv-muted`. */
1244
+ color: var(--sv-muted);
1229
1245
  margin-left: 0.25rem;
1230
1246
  }
1231
1247
  .pm-blueprint-cap-body { display: block; }
@@ -2260,19 +2276,38 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2260
2276
  align-items: center;
2261
2277
  gap: 0.35rem;
2262
2278
  padding: 0.3rem 0.6rem;
2263
- background: var(--sv-surface, #ffffff);
2264
- color: var(--sv-text, inherit);
2265
- border: 1px solid var(--sv-border, #d6dbe3);
2279
+ background: var(--sv-surface);
2280
+ color: var(--sv-ink);
2281
+ border: 1px solid var(--sv-border);
2266
2282
  border-radius: 999px;
2267
2283
  font: inherit;
2268
2284
  font-size: 0.85rem;
2269
2285
  line-height: 1;
2270
2286
  cursor: pointer;
2271
- transition: background-color 0.1s ease-out, border-color 0.1s ease-out;
2287
+ transition: background-color 0.1s ease-out, border-color 0.1s ease-out, color 0.1s ease-out;
2288
+ }
2289
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: hover previously fell back to
2290
+ a hard-coded light grey (`#f0f3f8`) because `--sv-surface-hover` was
2291
+ never declared; in dark that painted dark ink on a light grey and
2292
+ made the FIND button unreadable. The hover now uses `--sv-active`
2293
+ (the Stravica brand "selected / hovered surface" token, defined in
2294
+ both the `:root` and `:root[data-theme="dark"]` blocks) with the
2295
+ control-strength border so the boundary stays >= 3:1 against the
2296
+ header surface in both themes. The focus-visible outline uses
2297
+ `--sv-focus` (brand focus token) at 3px with 2px offset. */
2298
+ .rcf-search-btn:hover,
2299
+ .rcf-search-btn:focus-visible {
2300
+ background: var(--sv-active);
2301
+ color: var(--sv-ink);
2302
+ border-color: var(--sv-control);
2303
+ }
2304
+ .rcf-search-btn:active {
2305
+ background: var(--sv-active);
2306
+ color: var(--sv-ink);
2307
+ border-color: var(--sv-focus);
2272
2308
  }
2273
- .rcf-search-btn:hover { background: var(--sv-surface-hover, #f0f3f8); }
2274
2309
  .rcf-search-btn:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 2px; }
2275
- .rcf-search-btn .rcf-search-icon { flex-shrink: 0; opacity: 0.75; }
2310
+ .rcf-search-btn .rcf-search-icon { flex-shrink: 0; opacity: 0.85; color: var(--sv-ink); }
2276
2311
  .rcf-search-btn .rcf-search-kbd {
2277
2312
  display: inline-flex;
2278
2313
  gap: 0.1rem;
@@ -2282,9 +2317,10 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2282
2317
  }
2283
2318
  .rcf-search-btn .rcf-search-kbd kbd {
2284
2319
  padding: 0 0.25rem;
2285
- border: 1px solid var(--sv-border, #d6dbe3);
2320
+ border: 1px solid var(--sv-border);
2286
2321
  border-radius: 3px;
2287
- background: var(--sv-surface, #ffffff);
2322
+ background: var(--sv-surface);
2323
+ color: var(--sv-ink);
2288
2324
  font-family: inherit;
2289
2325
  }
2290
2326
 
@@ -2292,6 +2328,57 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2292
2328
  .rcf-search-btn .rcf-search-kbd { display: none; }
2293
2329
  }
2294
2330
 
2331
+ /* --------------------------------------------------------------------
2332
+ * Three-state theme control (ADR-4137, Baz 2026-10-04).
2333
+ *
2334
+ * Sits in `.tools` right of the SearchButton in non-embed mode; absent
2335
+ * from the DOM entirely under `data-embed="1"` (page-init.js removes
2336
+ * the element, renderPage({ embed: true }) also skips the markup).
2337
+ * The group buttons use the same ink/active/control tokens as the
2338
+ * SearchButton so hover and focus states stay above WCAG AA in both
2339
+ * themes.
2340
+ * -------------------------------------------------------------------- */
2341
+
2342
+ .rcf-theme {
2343
+ display: inline-flex;
2344
+ align-items: stretch;
2345
+ border: 1px solid var(--sv-border);
2346
+ border-radius: 999px;
2347
+ overflow: hidden;
2348
+ background: var(--sv-surface);
2349
+ }
2350
+ .rcf-theme-btn {
2351
+ appearance: none;
2352
+ background: transparent;
2353
+ color: var(--sv-ink);
2354
+ border: 0;
2355
+ padding: 0.3rem 0.55rem;
2356
+ font: inherit;
2357
+ font-size: 0.75rem;
2358
+ line-height: 1;
2359
+ cursor: pointer;
2360
+ transition: background-color 0.1s ease-out, color 0.1s ease-out;
2361
+ }
2362
+ .rcf-theme-btn + .rcf-theme-btn { border-left: 1px solid var(--sv-border); }
2363
+ .rcf-theme-btn:hover {
2364
+ background: var(--sv-active);
2365
+ color: var(--sv-ink);
2366
+ }
2367
+ .rcf-theme-btn:focus-visible {
2368
+ outline: 3px solid var(--sv-focus);
2369
+ outline-offset: -3px;
2370
+ background: var(--sv-active);
2371
+ }
2372
+ .rcf-theme-btn:active {
2373
+ background: var(--sv-active);
2374
+ color: var(--sv-ink);
2375
+ }
2376
+ .rcf-theme-btn[aria-pressed="true"] {
2377
+ background: var(--sv-button);
2378
+ color: var(--sv-onbutton);
2379
+ }
2380
+ html[data-embed="1"] .rcf-theme { display: none; }
2381
+
2295
2382
  .rcf-lookup[hidden] { display: none !important; }
2296
2383
  .rcf-lookup {
2297
2384
  position: fixed;
@@ -2346,7 +2433,7 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2346
2433
  border-radius: 4px;
2347
2434
  cursor: pointer;
2348
2435
  }
2349
- .rcf-lookup-close:hover { color: var(--sv-text); border-color: var(--sv-border); }
2436
+ .rcf-lookup-close:hover { color: var(--sv-text); border-color: var(--sv-control); }
2350
2437
  .rcf-lookup-close:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 1px; }
2351
2438
 
2352
2439
  .rcf-lookup-field { display: flex; flex-direction: column; gap: 0.25rem; }
@@ -2360,9 +2447,9 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2360
2447
  padding: 0.55rem 0.75rem;
2361
2448
  font: inherit;
2362
2449
  font-size: 0.95rem;
2363
- border: 1px solid var(--sv-border, #d6dbe3);
2450
+ border: 1px solid var(--sv-control);
2364
2451
  border-radius: 6px;
2365
- background: var(--sv-surface-sub, #fbfcfe);
2452
+ background: var(--sv-surface);
2366
2453
  color: var(--sv-text);
2367
2454
  }
2368
2455
  .rcf-lookup-input:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 1px; border-color: var(--sv-link, #3a5bb5); }
@@ -2389,9 +2476,9 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2389
2476
  cursor: pointer;
2390
2477
  border: 1px solid transparent;
2391
2478
  }
2392
- .rcf-lookup-row:hover { background: var(--sv-surface-hover, #f0f3f8); }
2479
+ .rcf-lookup-row:hover { background: var(--sv-active); }
2393
2480
  .rcf-lookup-row.is-active {
2394
- background: var(--sv-surface-hover, #f0f3f8);
2481
+ background: var(--sv-active);
2395
2482
  border-color: var(--sv-link, #3a5bb5);
2396
2483
  }
2397
2484
  .rcf-lookup-row mark {
@@ -2467,7 +2554,7 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2467
2554
  font: inherit;
2468
2555
  font-size: 0.85rem;
2469
2556
  }
2470
- .rcf-lookup-reload:hover { background: var(--sv-surface-hover, #f0f3f8); }
2557
+ .rcf-lookup-reload:hover { background: var(--sv-active); }
2471
2558
  .rcf-lookup-reload:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 1px; }
2472
2559
 
2473
2560
  html[data-theme="dark"] .rcf-lookup-backdrop { background: rgba(0, 0, 0, 0.55); }
@@ -321,3 +321,25 @@ export function renderSearchButton() {
321
321
  <span class="rcf-search-kbd" aria-hidden="true"><kbd>Ctrl</kbd>+<kbd>F</kbd></span>
322
322
  </button>`;
323
323
  }
324
+
325
+ /**
326
+ * Three-state Light|Dark|Auto theme control for the standalone viewer
327
+ * shell (viewer UI refresh design section 11 recommendation 6, Baz's
328
+ * 2026-10-04 review `w-2026-10-04-dave-001`). The control is rendered
329
+ * server-side without an initial `aria-pressed=true` on any button; the
330
+ * boot path in `page-init.js` reads `?theme=`, then (non-embed only)
331
+ * `localStorage['rcf-view:v1:theme']` per ADR-4137, then defaults to
332
+ * Auto, and stamps the right button with `aria-pressed="true"` from
333
+ * that resolved value. Under `embed=1` `html-page.js` omits this call
334
+ * entirely so no element with `[data-rcf-theme-control]` reaches the
335
+ * DOM and the storage key is never read or written (ADR-4137).
336
+ *
337
+ * @returns {string}
338
+ */
339
+ export function renderThemeControl() {
340
+ return `<div class="rcf-theme" data-rcf-theme-control role="group" aria-label="Theme">
341
+ <button type="button" class="rcf-theme-btn" data-rcf-theme="light" aria-pressed="false" title="Light theme" aria-label="Light theme">Light</button>
342
+ <button type="button" class="rcf-theme-btn" data-rcf-theme="dark" aria-pressed="false" title="Dark theme" aria-label="Dark theme">Dark</button>
343
+ <button type="button" class="rcf-theme-btn" data-rcf-theme="auto" aria-pressed="false" title="Follow system theme" aria-label="Follow system theme">Auto</button>
344
+ </div>`;
345
+ }