rcf-lite 0.30.0 → 0.31.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 (52) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/fixtures/canary-manifest.json +9 -9
  3. package/guidance/define-draft-shapes.md +15 -10
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4131-gates-bite-ack-recorded-override.json +3 -3
  6. package/rcf/adrs/adr-4137-standalone-theme-persistence.json +30 -0
  7. package/rcf/adrs/adr-4138-tree-wide-checks-before-scope-shortcircuit.json +35 -0
  8. package/rcf/code-nodes/cn-771.json +14 -0
  9. package/rcf/code-nodes/cn-772.json +14 -0
  10. package/rcf/code-nodes/cn-773.json +16 -0
  11. package/rcf/code-nodes/cn-774.json +20 -0
  12. package/rcf/code-nodes/cn-775.json +15 -0
  13. package/rcf/code-nodes/cn-776.json +17 -0
  14. package/rcf/code-nodes/cn-777.json +21 -0
  15. package/rcf/fbs/fbs-202.json +18 -2
  16. package/rcf/fbs/fbs-203.json +12 -4
  17. package/rcf/requirements/req-002.json +2 -2
  18. package/rcf/requirements/req-173.json +2 -2
  19. package/rcf/requirements/req-174.json +2 -2
  20. package/rcf/tacs/tac-4122-define-gates.json +7 -2
  21. package/rcf/test-suites/ts-007.json +1 -1
  22. package/rcf/test-suites/ts-215.json +64 -1
  23. package/rcf/test-suites/ts-216.json +18 -2
  24. package/rcf/test-suites/ts-219.json +10 -2
  25. package/rcf/test-suites/ts-220.json +18 -2
  26. package/rcf/test-suites/ts-225.json +10 -2
  27. package/rcf/test-suites/ts-229.json +49 -3
  28. package/rcf/test-suites/ts-230.json +26 -2
  29. package/rcf/test-suites/ts-231.json +16 -1
  30. package/rcf/test-suites/ts-236.json +44 -0
  31. package/rcf/user-stories/us-17302.json +36 -1
  32. package/rcf/user-stories/us-17401.json +11 -1
  33. package/rcf/user-stories/us-17402.json +6 -1
  34. package/rcf/user-stories/us-17403.json +11 -1
  35. package/rcf/user-stories/us-17404.json +16 -1
  36. package/rcf/user-stories/us-17405.json +7 -2
  37. package/rcf/user-stories/us-205.json +6 -1
  38. package/rcf/user-stories/us-206.json +25 -4
  39. package/rcf/user-stories/us-207.json +36 -0
  40. package/releases/releases.yaml +11 -1
  41. package/src/cli/create.js +22 -2
  42. package/src/cli/ledger.js +96 -4
  43. package/src/cli/readiness.js +15 -1
  44. package/src/core/errors/index.js +7 -1
  45. package/src/core/store/writer.js +181 -13
  46. package/src/define/ledgers.js +86 -7
  47. package/src/mcp/tools.js +1 -1
  48. package/src/query/gates.js +383 -150
  49. package/src/view/html-page.js +14 -2
  50. package/src/view/page-init.js +149 -8
  51. package/src/view/style.css +97 -18
  52. 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>
@@ -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];
@@ -810,7 +810,10 @@ footer.app-footer a {
810
810
 
811
811
  footer.app-footer a:hover { text-decoration: underline; }
812
812
 
813
- footer.app-footer .sep { color: var(--sv-control); }
813
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: separator was `--sv-control`
814
+ which fell under 4.5:1 in both themes (3.51:1 light, 4.21:1 dark);
815
+ `--sv-muted` is the "muted text" token designed for this. */
816
+ footer.app-footer .sep { color: var(--sv-muted); }
814
817
 
815
818
  footer.app-footer .live {
816
819
  margin-left: auto;
@@ -955,7 +958,7 @@ html[data-embed="1"] .pm-controls { top: 3rem; }
955
958
  color: var(--sv-ink);
956
959
  font-weight: 600;
957
960
  box-shadow: var(--shadow-sm);
958
- border-color: var(--sv-border);
961
+ border-color: var(--sv-control);
959
962
  }
960
963
 
961
964
  .pm-status-filter {
@@ -1151,7 +1154,10 @@ details.pm-bucket[open] > summary.pm-bucket-heading {
1151
1154
  .pm-mini-review { color: var(--sv-link); background: var(--sv-active); border-color: var(--sv-link); }
1152
1155
  .pm-mini-draft { color: var(--sv-muted); background: var(--sv-raised); border-color: var(--sv-border); }
1153
1156
  .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); }
1157
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: `--sv-control` on `--sv-raised`
1158
+ was 3.51:1 light / 4.21:1 dark, below the 4.5:1 floor for small text.
1159
+ `--sv-muted` is the matching muted-text token and clears AA in both. */
1160
+ .pm-mini-superseded { color: var(--sv-muted); background: var(--sv-raised); border-color: var(--sv-border); }
1155
1161
 
1156
1162
  .pm-bucket-body { padding: 0.75rem 1rem 1rem; }
1157
1163
  .pm-bucket-body .pm-tad-root { margin-bottom: 0.65rem; }
@@ -1225,7 +1231,9 @@ details.pm-bucket[open] > summary.pm-bucket-heading {
1225
1231
  }
1226
1232
  .pm-blueprint-cap-count {
1227
1233
  font-weight: 500;
1228
- color: var(--sv-control);
1234
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: `--sv-control` fell under
1235
+ the 4.5:1 small-text floor; switched to `--sv-muted`. */
1236
+ color: var(--sv-muted);
1229
1237
  margin-left: 0.25rem;
1230
1238
  }
1231
1239
  .pm-blueprint-cap-body { display: block; }
@@ -2260,19 +2268,38 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2260
2268
  align-items: center;
2261
2269
  gap: 0.35rem;
2262
2270
  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);
2271
+ background: var(--sv-surface);
2272
+ color: var(--sv-ink);
2273
+ border: 1px solid var(--sv-border);
2266
2274
  border-radius: 999px;
2267
2275
  font: inherit;
2268
2276
  font-size: 0.85rem;
2269
2277
  line-height: 1;
2270
2278
  cursor: pointer;
2271
- transition: background-color 0.1s ease-out, border-color 0.1s ease-out;
2279
+ transition: background-color 0.1s ease-out, border-color 0.1s ease-out, color 0.1s ease-out;
2280
+ }
2281
+ /* Baz 2026-10-04 w-2026-10-04-dave-001: hover previously fell back to
2282
+ a hard-coded light grey (`#f0f3f8`) because `--sv-surface-hover` was
2283
+ never declared; in dark that painted dark ink on a light grey and
2284
+ made the FIND button unreadable. The hover now uses `--sv-active`
2285
+ (the Stravica brand "selected / hovered surface" token, defined in
2286
+ both the `:root` and `:root[data-theme="dark"]` blocks) with the
2287
+ control-strength border so the boundary stays >= 3:1 against the
2288
+ header surface in both themes. The focus-visible outline uses
2289
+ `--sv-focus` (brand focus token) at 3px with 2px offset. */
2290
+ .rcf-search-btn:hover,
2291
+ .rcf-search-btn:focus-visible {
2292
+ background: var(--sv-active);
2293
+ color: var(--sv-ink);
2294
+ border-color: var(--sv-control);
2295
+ }
2296
+ .rcf-search-btn:active {
2297
+ background: var(--sv-active);
2298
+ color: var(--sv-ink);
2299
+ border-color: var(--sv-focus);
2272
2300
  }
2273
- .rcf-search-btn:hover { background: var(--sv-surface-hover, #f0f3f8); }
2274
2301
  .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; }
2302
+ .rcf-search-btn .rcf-search-icon { flex-shrink: 0; opacity: 0.85; color: var(--sv-ink); }
2276
2303
  .rcf-search-btn .rcf-search-kbd {
2277
2304
  display: inline-flex;
2278
2305
  gap: 0.1rem;
@@ -2282,9 +2309,10 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2282
2309
  }
2283
2310
  .rcf-search-btn .rcf-search-kbd kbd {
2284
2311
  padding: 0 0.25rem;
2285
- border: 1px solid var(--sv-border, #d6dbe3);
2312
+ border: 1px solid var(--sv-border);
2286
2313
  border-radius: 3px;
2287
- background: var(--sv-surface, #ffffff);
2314
+ background: var(--sv-surface);
2315
+ color: var(--sv-ink);
2288
2316
  font-family: inherit;
2289
2317
  }
2290
2318
 
@@ -2292,6 +2320,57 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2292
2320
  .rcf-search-btn .rcf-search-kbd { display: none; }
2293
2321
  }
2294
2322
 
2323
+ /* --------------------------------------------------------------------
2324
+ * Three-state theme control (ADR-4137, Baz 2026-10-04).
2325
+ *
2326
+ * Sits in `.tools` right of the SearchButton in non-embed mode; absent
2327
+ * from the DOM entirely under `data-embed="1"` (page-init.js removes
2328
+ * the element, renderPage({ embed: true }) also skips the markup).
2329
+ * The group buttons use the same ink/active/control tokens as the
2330
+ * SearchButton so hover and focus states stay above WCAG AA in both
2331
+ * themes.
2332
+ * -------------------------------------------------------------------- */
2333
+
2334
+ .rcf-theme {
2335
+ display: inline-flex;
2336
+ align-items: stretch;
2337
+ border: 1px solid var(--sv-border);
2338
+ border-radius: 999px;
2339
+ overflow: hidden;
2340
+ background: var(--sv-surface);
2341
+ }
2342
+ .rcf-theme-btn {
2343
+ appearance: none;
2344
+ background: transparent;
2345
+ color: var(--sv-ink);
2346
+ border: 0;
2347
+ padding: 0.3rem 0.55rem;
2348
+ font: inherit;
2349
+ font-size: 0.75rem;
2350
+ line-height: 1;
2351
+ cursor: pointer;
2352
+ transition: background-color 0.1s ease-out, color 0.1s ease-out;
2353
+ }
2354
+ .rcf-theme-btn + .rcf-theme-btn { border-left: 1px solid var(--sv-border); }
2355
+ .rcf-theme-btn:hover {
2356
+ background: var(--sv-active);
2357
+ color: var(--sv-ink);
2358
+ }
2359
+ .rcf-theme-btn:focus-visible {
2360
+ outline: 3px solid var(--sv-focus);
2361
+ outline-offset: -3px;
2362
+ background: var(--sv-active);
2363
+ }
2364
+ .rcf-theme-btn:active {
2365
+ background: var(--sv-active);
2366
+ color: var(--sv-ink);
2367
+ }
2368
+ .rcf-theme-btn[aria-pressed="true"] {
2369
+ background: var(--sv-button);
2370
+ color: var(--sv-onbutton);
2371
+ }
2372
+ html[data-embed="1"] .rcf-theme { display: none; }
2373
+
2295
2374
  .rcf-lookup[hidden] { display: none !important; }
2296
2375
  .rcf-lookup {
2297
2376
  position: fixed;
@@ -2346,7 +2425,7 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2346
2425
  border-radius: 4px;
2347
2426
  cursor: pointer;
2348
2427
  }
2349
- .rcf-lookup-close:hover { color: var(--sv-text); border-color: var(--sv-border); }
2428
+ .rcf-lookup-close:hover { color: var(--sv-text); border-color: var(--sv-control); }
2350
2429
  .rcf-lookup-close:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 1px; }
2351
2430
 
2352
2431
  .rcf-lookup-field { display: flex; flex-direction: column; gap: 0.25rem; }
@@ -2360,9 +2439,9 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2360
2439
  padding: 0.55rem 0.75rem;
2361
2440
  font: inherit;
2362
2441
  font-size: 0.95rem;
2363
- border: 1px solid var(--sv-border, #d6dbe3);
2442
+ border: 1px solid var(--sv-control);
2364
2443
  border-radius: 6px;
2365
- background: var(--sv-surface-sub, #fbfcfe);
2444
+ background: var(--sv-surface);
2366
2445
  color: var(--sv-text);
2367
2446
  }
2368
2447
  .rcf-lookup-input:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 1px; border-color: var(--sv-link, #3a5bb5); }
@@ -2389,9 +2468,9 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2389
2468
  cursor: pointer;
2390
2469
  border: 1px solid transparent;
2391
2470
  }
2392
- .rcf-lookup-row:hover { background: var(--sv-surface-hover, #f0f3f8); }
2471
+ .rcf-lookup-row:hover { background: var(--sv-active); }
2393
2472
  .rcf-lookup-row.is-active {
2394
- background: var(--sv-surface-hover, #f0f3f8);
2473
+ background: var(--sv-active);
2395
2474
  border-color: var(--sv-link, #3a5bb5);
2396
2475
  }
2397
2476
  .rcf-lookup-row mark {
@@ -2467,7 +2546,7 @@ details.rcf-row.rcf-row--inner > summary { padding: 0.4rem 0.6rem; }
2467
2546
  font: inherit;
2468
2547
  font-size: 0.85rem;
2469
2548
  }
2470
- .rcf-lookup-reload:hover { background: var(--sv-surface-hover, #f0f3f8); }
2549
+ .rcf-lookup-reload:hover { background: var(--sv-active); }
2471
2550
  .rcf-lookup-reload:focus-visible { outline: 3px solid var(--sv-focus); outline-offset: 1px; }
2472
2551
 
2473
2552
  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
+ }