From d7784407323e819f2bb82e8a1173ab48a3d22879 Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Fri, 24 Apr 2026 14:51:49 -0400 Subject: [PATCH 1/4] plan(ui-discoverability): surface feature toggles via Experiments panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up plan for the RuLake-inspired roadmap addressing the single largest UX weakness: three features (warm-restart/share, cross-tab, and the absent quantization UI) ship with zero discoverability because their UI is gated on URL flags. Proposes a single πŸ§ͺ Experiments disclosure panel consolidating all feature toggles, with URL flags demoted from UI-gate to initial-state-preset (the pattern consistency-modes and federation already use correctly). No default-on flips; plain URL still yields plain behaviour. Scope is deliberately small: ~250 lines, primarily in uiPanels.js, with a new agent-browser smoke harness. No feature module touched. See the plan for the 10-task checklist, default-state table, and design trade-offs. --- docs/plan/ui-discoverability-pass.md | 155 +++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/plan/ui-discoverability-pass.md diff --git a/docs/plan/ui-discoverability-pass.md b/docs/plan/ui-discoverability-pass.md new file mode 100644 index 0000000..6e1de0e --- /dev/null +++ b/docs/plan/ui-discoverability-pass.md @@ -0,0 +1,155 @@ +# UI discoverability pass for the RuLake-inspired features + +Plan date: 2026-04-24. Follows the RuLake-inspired feature roadmap +(see `docs/plan/rulake-inspired-features.md`) and addresses the +single largest UX weakness of that work: **three features ship with +zero UI and are discoverable only by knowing a URL flag.** + +This is a focused pass, not a multi-phase roadmap β€” one consolidated +PR, ~250 lines of change concentrated in `uiPanels.js` plus small +edits to `main.js` and `style.css`. + +## Status tracker + +**Legend:** ⬜ todo Β· 🟑 in progress Β· βœ… done Β· 🚫 blocked Β· ⏸ deferred + +**Current focus:** Not started β€” waiting on user approval of this plan. +**Last updated:** 2026-04-24 + +| Status | ID | Task | Owner | PR/SHA | Done date | +|:--:|:--:|------|-------|--------|-----------| +| ⬜ | A.1 | Add `πŸ§ͺ Experiments` disclosure panel scaffold | | | | +| ⬜ | A.2 | Migrate Federation checkbox into the panel | | | | +| ⬜ | A.3 | Migrate Consistency modes radio row into the panel | | | | +| ⬜ | A.4 | Migrate Observability panel toggle into the panel | | | | +| ⬜ | A.5 | Un-gate Snapshot / share row (remove `?snapshots=1` requirement for UI render) | | | | +| ⬜ | A.6 | Un-gate Cross-tab row (remove `?crosstab=1` requirement for UI render) | | | | +| ⬜ | A.7 | Add disabled Quantization row with tooltip | | | | +| ⬜ | A.8 | Add `confirm()` on destructive Import action | | | | +| ⬜ | A.9 | `[Learn]` links next to each row β†’ open matching ELI15 chapter | | | | +| ⬜ | A.10 | Smoke-test via agent-browser + commit | | | | + +**Exit gate:** all rows βœ… + agent-browser smoke: +- default URL: Experiments panel visible (collapsed), no behaviour change vs pre-pass; +- expanding the panel shows 6 rows (snapshots, crosstab, federation, consistency, observability, quantization-disabled); +- each `[Learn]` link opens the right chapter; +- URL flags still work as presets (checkbox/radio pre-selected). + +--- + +## What's wrong today + +Summary of current state (post RuLake roadmap, commit `ee2dd34`): + +| Feature | UI today | Problem | +|---------|----------|---------| +| Warm-restart Export / Import (F3) | Hidden β€” requires `?snapshots=1` to render at all | A user can train for an hour and not know they can save it | +| Shareable archive URL + gallery (F3/3C) | Hidden β€” requires `?snapshots=1` | Can't share what you can't see | +| Cross-tab live training (F6) | Hidden β€” requires `?crosstab=1` | The two-tab demo is delightful but completely undiscoverable | +| Federation (F2) | Visible checkbox in training panel | βœ… Already correct β€” flag is a preset, not a gate | +| Consistency modes (F4) | Visible radio row in training panel | βœ… Already correct | +| Observability panel (F7) | Visible collapsed panel below training UI | βœ… Already correct (telemetry-only) | +| 1-bit quantization (F1) | No UI β€” library-only | Invisible to users; chapter references nothing clickable | + +The pattern the three correct ones use is: **URL flag presets the initial state of a UI control that is always visible.** The three wrong ones gate the UI itself on the flag β€” no flag = no UI = no discoverability. + +## What this pass does + +### Design call: a single `πŸ§ͺ Experiments` disclosure panel + +Rather than scatter five more toggle rows across the existing training panel (which is already dense), consolidate all feature toggles into one collapsible disclosure panel near the bottom of the training UI. Collapsed by default to keep the first-impression UI clean; expanded by one click when the user wants to explore. + +Shape (inside the panel, when expanded): + +``` +πŸ§ͺ Experiments β–Ό + [Each row: toggle Β· emoji + label Β· one-line hint Β· (Learn β†’)] + + ☐ πŸ“¦ Save & share archives (Learn β†’) + Export your archive, import a shared one. + ☐ πŸ”— Cross-tab live training (Learn β†’) + Open two tabs β€” brains travel between them. + ☐ 🌐 Federated search (Learn β†’) + Union Euclidean + Hyperbolic nearest-neighbours. + πŸ”˜ Consistency: (β€’) Fresh ( ) Eventual ( ) Frozen (Learn β†’) + How retrieval sees the archive as it grows. + β˜‘ ⏱ Per-stage timings panel (Learn β†’) + Flame-graph-lite for each generation. + ☐ πŸ“ 1-bit quantized archive (library-only; not wired yet) (Learn β†’) + [disabled β€” tooltip: "Module ships but is not wired to archiveBrain yet"] +``` + +### Default state per feature + +| Feature | Default | Rationale | +|---------|---------|-----------| +| Snapshots / share | **OFF** | Adds file I/O + optional network fetches; opt-in semantically | +| Cross-tab | **OFF** | Adds BroadcastChannel traffic + per-tab peer discovery | +| Federation | **OFF** | Behaviour change (dual-index union) | +| Consistency | **Fresh** | Zero behaviour delta vs pre-pass | +| Observability | **ON** | Telemetry-only; already default-on today | +| Quantization | **DISABLED** | Library-only; no backing integration | + +Preserves the rule: plain URL β†’ plain behaviour. + +### URL flags keep working as presets + +Every existing flag (`?snapshots=1`, `?consistency=frozen`, `?federation=1`, `?crosstab=1`, `?archive=`) continues to work, but now it presets the initial state of the corresponding UI control instead of gating whether the control renders. Shareable demo links unchanged. + +### Destructive-action guardrail + +Enabling **πŸ“¦ Save & share archives** reveals the Export / Import / Share buttons. Clicking *Import* currently replaces the live archive without confirmation (previously OK because `?snapshots=1` was the friction; with the UI unhidden, that friction is gone). Add a `confirm()` on Import: *"This will replace your current N brains with the imported archive. Continue?"* The confirmation is the missing friction. + +### `[Learn]` links + +Each row's *Learn* link invokes `window.ELI15.openChapter(id)` with the matching chapter id. Turns the panel into a self-teaching surface β€” clicking *Learn* on federation opens the federation chapter, which has a live formula and a *"Try it yourself"* section that now points to the toggle one tap away in the panel. + +--- + +## Files touched (scope-bounded) + +Expected diff, ~250 lines: + +- `AI-Car-Racer/uiPanels.js` β€” bulk of the work. Create `mountExperimentsPanel()` that renders the disclosure section. Move/refactor existing Federation + Consistency + Observability rows into it. Un-gate the Snapshots row (currently behind `if (usp.get('snapshots') === '1')`) and the Cross-tab row (currently behind `if (usp.get('crosstab') === '1')`). Anchored clearly so future edits don't collide. +- `AI-Car-Racer/style.css` β€” styles for `.rv-experiments-panel` (disclosure + row + learn link + disabled row). +- `AI-Car-Racer/main.js` β€” minor change: the URL-flag appliers currently call `setConsistencyMode()` / `setFederationEnabled()` / `setCrosstabEnabled()` regardless of UI state. Ensure each one also updates the UI control so the checkbox/radio reflects the actual bridge state after the preset applies. Small bug-fix-sized edits. +- `tests/experiments-panel-smoke.html` (new) β€” standalone harness: + 1. Load the main page, wait for panel scaffold. + 2. Assert the disclosure starts collapsed. + 3. Click disclosure; assert 6 rows visible. + 4. Click *Learn* on a row; assert the ELI15 drawer opens with the right chapter. + 5. Click a toggle; assert the corresponding bridge getter reports the new state. + 6. Set `?snapshots=1` in URL; assert the Snapshots row is pre-toggled ON at boot. + Prints PASS/FAIL per claim. + +## Files NOT touched + +- None of the feature modules (`archive/`, `consistency/`, `quantization/`, `federation/`, `crosstab/`, `observability/`, `share/`, `lineage/`). This is pure UI plumbing over the existing APIs. +- `ruvectorBridge.js` β€” no new exports; consumes existing setters/getters only. +- Any existing ELI15 chapter body β€” the pass only touches the ordering + the way the panel links to chapters, not the content. + +## Exit criteria (the `/ship-task` gate) + +1. Default URL: Experiments panel appears collapsed; plain behaviour unchanged (every feature matches pre-pass default). +2. Expanding the panel shows 6 rows in the order above. +3. Every `[Learn]` link opens the correct ELI15 chapter via `window.ELI15.openChapter(id)`. +4. Toggling each row changes the bridge state (verified via `window.__rvBridge` getters β€” isFederationEnabled, isCrosstabEnabled, getConsistencyMode, etc.). +5. URL flags still work as presets β€” at boot, every flag that was set pre-selects its UI control. +6. Import button shows a `confirm()` that mentions the live archive count. +7. Quantization row renders disabled with a tooltip explaining the limitation. +8. `tests/experiments-panel-smoke.html` PASS on all 6 claims. +9. No new console errors at boot. + +## Trade-offs I'm making + +- **Visual simplicity over rich custom widgets.** Using the native `
` element + native checkboxes/radios rather than custom animated components. Ships faster, reads better for learners, one fewer thing to style. +- **One panel over five separate UI sections.** Alternative is to put each feature's toggle in-context (e.g. cross-tab pill always visible next to the fps counter). Rejected because the main training panel is already dense β€” a single disclosure absorbs the new surface area in one place a user can choose to ignore or explore. +- **No default-on flips.** I explicitly do NOT turn on Federation, Cross-tab, Snapshots, or Quantization by default, even though they're stable. Keeps the change a UX pass, not a behaviour change β€” if later we want "Federation default-on," that's a separate call backed by recall-vs-latency evidence per the cross-track-variance memory. + +## What this is NOT + +- Not a rewrite of any feature module. +- Not a redesign of the training panel. +- Not integrating quantization into the archive (that's a separate future slice). +- Not adding new URL flags. +- Not publishing any real archive URL to the community gallery (still gated by external-scope approval). From b074d8788703df8d87c3afdfaaff87a8620361ef Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Sun, 26 Apr 2026 23:34:13 -0400 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui-discoverability):=20consolidate=20f?= =?UTF-8?q?eature=20toggles=20in=20=F0=9F=A7=AA=20Experiments=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the plan from docs/plan/ui-discoverability-pass.md, in the same branch / PR #2 the plan landed in. Surfaces the previously URL-flag-gated Save/Share-archives row and Cross-tab row in a single collapsible disclosure panel, alongside the already-visible Federation and Consistency rows. What changed: - uiPanels.js β€” new buildExperimentsPanel() at the bottom of the IIFE. Creates a
+ summary + body, then re-parents the existing consistency/federation/crosstab DOM nodes into the disclosure body via appendChild. Existing event listeners and el.X references survive untouched (wrap-don't-rebuild). The snapshots row and the share row are now ALWAYS created (the if (_snapshotsFlagOn) gate is removed); the experiments-level "πŸ“¦ Save & share archives" checkbox controls subbody visibility. Same pattern for the crosstab "πŸ”— Cross-tab live training" toggle, which now drives bridge.setCrosstabEnabled() directly. URL flags become PRESETS that pre-toggle the corresponding control; if any flag is set the disclosure auto-opens so the user can see what their share-link enabled. Default state matches the plan's table: observability ON, consistency Fresh, federation OFF, snapshots OFF, crosstab OFF, quantization DISABLED. - uiPanels.js β€” confirm() on destructive Import paths. Both the file-Import in the snapshots row and the URL-Import in the share row now ask "REPLACE your current N brains?" before proceeding. Previously the ?snapshots=1 flag was the only friction; with the UI unhidden, an explicit confirm replaces that. - uiPanels.js β€” crosstab listeners now wired unconditionally (previously gated by ?crosstab=1). The bridge state, not the listener subscription, decides whether to broadcast. - style.css β€” adds .rv-experiments, .rv-experiments-summary, .rv-experiments-body, .rv-experiments-row, .rv-experiments-toggle, .rv-experiments-subbody, .rv-experiments-row-disabled, .rv-experiments-badge classes. Native
/; no custom widget, no JS animation. Triangle marker rotates 90Β° on open. - tests/experiments-panel-smoke.html (new) β€” 7-claim iframe-based harness. Loads the main page in a hidden iframe (no flags) and a second iframe with ?snapshots=1, then asserts: 1. Disclosure exists, defaults closed. 2. 6 rows visible after expand. 3. Quantization row is disabled. 4. [Learn] eli15 badges wired on new rows. 5. Snapshots toggle gates the inner controls + both snapshots row and share row are migrated inside the subbody. 6. Crosstab toggle drives bridge.setCrosstabEnabled() (falseβ†’true). 7. ?snapshots=1 presets the toggle ON + reveals controls + opens the disclosure. - docs/plan/ui-discoverability-pass.md β€” A.1–A.10 status flipped to βœ… with implementation notes (wrap-don't-rebuild strategy, auto-open on flag, listener-always-wired refactor). Validated via agent-browser: - Default boot clean, no new console errors. - Smoke harness 7/7 PASS, including bridge-state coupling (crosstab checkbox flips bridge.isCrosstabEnabled() live). - ?consistency=frozen&federation=1&crosstab=1 multi-flag URL still works β€” every UI control reflects its flag, every bridge getter reports the active state, disclosure auto-opens. Out of scope (explicit non-changes): - No default-on flips. Federation, Cross-tab, Snapshots, Quantization all remain OFF by default. - No new URL flags. - No feature module changes (archive/, consistency/, federation/, crosstab/, quantization/, observability/, share/, lineage/ all untouched). - No quantization integration into archiveBrain β€” disabled row says so explicitly with a tooltip pointing at the chapter. Net diff: ~310 lines added across 4 files (close to the plan's ~250-line estimate; came in higher because the smoke harness imitates a two-iframe test runner). --- AI-Car-Racer/style.css | 100 ++++++++++ AI-Car-Racer/uiPanels.js | 287 +++++++++++++++++++++++---- docs/plan/ui-discoverability-pass.md | 29 ++- tests/experiments-panel-smoke.html | 150 ++++++++++++++ 4 files changed, 517 insertions(+), 49 deletions(-) create mode 100644 tests/experiments-panel-smoke.html diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css index 4022b89..3c9ac1c 100644 --- a/AI-Car-Racer/style.css +++ b/AI-Car-Racer/style.css @@ -2117,3 +2117,103 @@ label { 0% { background: #62f0b8; box-shadow: 0 0 10px rgba(98, 240, 184, 0.65); } 100% { background: rgba(98, 240, 184, 0.14); box-shadow: 0 0 0 rgba(0, 0, 0, 0); } } + +/* === Phase A β€” Experiments disclosure panel === */ +/* Consolidates RuLake-inspired feature toggles. Native
+ + * for the disclosure β€” no JS animation, no custom widget. */ +.rv-experiments { + margin-top: 14px; + padding: 8px 10px; + background: rgba(20, 28, 48, 0.55); + border: 1px solid rgba(120, 140, 200, 0.18); + border-radius: 6px; +} +.rv-experiments[open] { + background: rgba(20, 28, 48, 0.75); +} +.rv-experiments-summary { + cursor: pointer; + font-weight: 600; + color: #cbd8ff; + user-select: none; + list-style: none; + padding: 2px 0; +} +.rv-experiments-summary::-webkit-details-marker { display: none; } +.rv-experiments-summary::before { + content: 'β–Έ'; + display: inline-block; + margin-right: 6px; + transition: transform 120ms ease-out; +} +.rv-experiments[open] .rv-experiments-summary::before { + transform: rotate(90deg); +} +.rv-experiments-hint { + font-weight: 400; + color: #8a98be; + font-size: 0.85em; + margin-left: 6px; +} +.rv-experiments-body { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.rv-experiments-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 4px 6px; + border-radius: 4px; +} +.rv-experiments-row + .rv-experiments-row { + border-top: 1px solid rgba(120, 140, 200, 0.08); + padding-top: 8px; +} +.rv-experiments-row > [data-rv]:not(input):not(label) { + /* migrated rows (consistency, federation, crosstab) keep their own + * layouts β€” strip the row's wrap so they read clean inside */ + flex: 1 1 100%; +} +.rv-experiments-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: #e6ebfa; +} +.rv-experiments-toggle input[type="checkbox"] { cursor: pointer; } +.rv-experiments-emoji { font-size: 1.05em; } +.rv-experiments-label { font-weight: 500; } +.rv-experiments-hint-inline { + color: #8a98be; + font-size: 0.85em; + font-style: italic; +} +.rv-experiments-subbody { + flex: 1 1 100%; + margin-top: 4px; + padding: 6px 8px; + background: rgba(10, 16, 32, 0.45); + border-radius: 4px; +} +.rv-experiments-subbody[hidden] { display: none; } + +/* Disabled (library-only) row styling */ +.rv-experiments-row-disabled { opacity: 0.55; } +.rv-experiments-toggle-disabled { cursor: not-allowed; } +.rv-experiments-toggle-disabled input { cursor: not-allowed; } +.rv-experiments-badge { + display: inline-block; + padding: 1px 6px; + margin-left: 4px; + border-radius: 3px; + background: rgba(180, 140, 90, 0.22); + color: #d8c39a; + font-size: 0.75em; + font-weight: 600; + letter-spacing: 0.02em; +} diff --git a/AI-Car-Racer/uiPanels.js b/AI-Car-Racer/uiPanels.js index 3620896..7e6db88 100644 --- a/AI-Car-Racer/uiPanels.js +++ b/AI-Car-Racer/uiPanels.js @@ -241,17 +241,24 @@ '', ].join(''); - // Phase 1A (F3). Warm-restart archive Export/Import row β€” rendered ONLY - // when the URL has ?snapshots=1 so the default experience is unchanged - // while the feature is baking. The row is a plain DOM append (not baked - // into the innerHTML above) so the gate lives in one readable place. - let _snapshotsFlagOn = false; - try { - if (typeof URLSearchParams === 'function') { - _snapshotsFlagOn = new URLSearchParams(window.location.search || '').get('snapshots') === '1'; - } - } catch (_) { _snapshotsFlagOn = false; } - if (_snapshotsFlagOn) { + // Phase 1A (F3). Warm-restart archive Export/Import row. + // + // Phase A (UI discoverability pass): the row is now ALWAYS created. The + // ?snapshots=1 URL flag is preserved, but it presets the "Save & share + // archives" toggle inside the πŸ§ͺ Experiments disclosure rather than gating + // whether this DOM ever exists. The Experiments wrapper at the bottom of + // this file moves this row into the disclosure body and hides it until the + // user (or the URL flag) opts in. + const _snapshotsFlagOn = (function () { + try { + if (typeof URLSearchParams === 'function') { + return new URLSearchParams(window.location.search || '').get('snapshots') === '1'; + } + } catch (_) {} + return false; + })(); + let __rvSnapshotsRow = null; + { const row = document.createElement('div'); row.className = 'rv-snapshots'; row.setAttribute('data-rv', 'snapshots'); @@ -309,7 +316,23 @@ } }); - btnImport.addEventListener('click', function () { fileInput.click(); }); + btnImport.addEventListener('click', function () { + // Phase A guardrail β€” Import REPLACES the live archive. With the + // ?snapshots=1 URL flag retired as a friction layer, the confirm() + // is what stops an accidental click from wiping a long training run. + const b = window.__rvBridge; + const liveCount = (function () { + try { return (b && b.info && b.info().brains) | 0; } catch (_) { return 0; } + })(); + const ok = window.confirm( + 'Import archive bundle?\n\n' + + 'This will REPLACE your current archive (' + liveCount + ' brain' + + (liveCount === 1 ? '' : 's') + ') with the contents of the imported file.\n\n' + + 'Proceed?' + ); + if (!ok) return; + fileInput.click(); + }); fileInput.addEventListener('change', async function () { const file = fileInput.files && fileInput.files[0]; if (!file) return; @@ -335,6 +358,7 @@ fileInput.value = ''; } }); + __rvSnapshotsRow = row; } const el = { @@ -503,19 +527,14 @@ if (on) await ensureFederationViewer(); }); } - // Phase 2B (F6) β€” cross-tab pill. Visible only when ?crosstab=1 URL flag is - // present (feature is baking behind a flag). Subscribes to the bridge's - // setCrosstabListeners so we get a callback on every received remote brain - // (for the green pulse) and on peer-count changes (for the "N peer(s)" - // readout). The subscription attempt is best-effort and retries a few - // times because the bridge sidecar can land slightly after the panel. - let _crosstabFlagOn = false; - try { - if (typeof URLSearchParams === 'function') { - _crosstabFlagOn = new URLSearchParams(window.location.search || '').get('crosstab') === '1'; - } - } catch (_) { _crosstabFlagOn = false; } - if (el.crosstab && _crosstabFlagOn) el.crosstab.hidden = false; + // Phase 2B (F6) β€” cross-tab pill. Phase A (UI discoverability pass): + // visibility now belongs to the Experiments disclosure's "Cross-tab live + // training" toggle, which also flips bridge.setCrosstabEnabled. The pill + // element itself (`el.crosstab`) is moved into the disclosure subbody + // later in this file; we always wire listeners here so the pulse + peer + // count work the moment the user flips the checkbox. + // The legacy `?crosstab=1` URL flag is preserved as a preset (it pre- + // checks the experiments toggle); see buildExperimentsPanel(). function renderCrosstabPeers(n) { if (!el.crosstabPeers) return; const count = Math.max(0, n | 0); @@ -555,7 +574,9 @@ } } } - if (_crosstabFlagOn) ensureCrosstabWiring(); + // Always wire listeners; the experiments toggle gates whether the bridge + // actually broadcasts/receives. The pill stays accurate either way. + ensureCrosstabWiring(); function renderFederation() { const b = window.__rvBridge; @@ -1432,8 +1453,10 @@ }); // === Phase 3C share panel === - // Rendered ONLY when `?snapshots=1` is present (same gate as the Phase - // 1A Export/Import row above). Three capabilities: + // Phase A (UI discoverability pass): always created; visibility is + // controlled by the "Save & share archives" toggle inside the Experiments + // disclosure. The ?snapshots=1 URL flag is preserved as a preset for that + // toggle. Three capabilities (unchanged): // 1. "πŸ“‹ Copy shareable link" β€” prompts for a URL the user already // hosts the bundle at, copies `?snapshots=1&archive=` to the // clipboard. We host nothing. @@ -1444,13 +1467,8 @@ // (see the external-scope note in gallery.js). // The anchor comment above is load-bearing: future phases should mount // above/below it, not replace it. - let _sharePanelGateOn = false; - try { - if (typeof URLSearchParams === 'function') { - _sharePanelGateOn = new URLSearchParams(window.location.search || '').get('snapshots') === '1'; - } - } catch (_) { _sharePanelGateOn = false; } - if (_sharePanelGateOn) { + let __rvShareRow = null; + { const shareRow = document.createElement('div'); shareRow.className = 'rv-share'; shareRow.setAttribute('data-rv', 'share'); @@ -1536,16 +1554,209 @@ }); btnImport.addEventListener('click', function () { - __shareImportFromUrl((urlInput.value || '').trim()); + const url = (urlInput.value || '').trim(); + if (!url) { setShareStatus('paste a URL above first', 'error'); return; } + // Phase A guardrail β€” same logic as the file-import confirm above. + const b = window.__rvBridge; + const liveCount = (function () { + try { return (b && b.info && b.info().brains) | 0; } catch (_) { return 0; } + })(); + const ok = window.confirm( + 'Import archive from URL?\n\n' + + url + '\n\n' + + 'This will REPLACE your current archive (' + liveCount + ' brain' + + (liveCount === 1 ? '' : 's') + ').\n\nProceed?' + ); + if (!ok) return; + __shareImportFromUrl(url); }); // Mount the community gallery. Each entry's button routes back // through the same fetch+import flow as the "πŸ“Ž Import from URL" - // button so the UX is consistent. + // button so the UX is consistent. The gallery handler also goes + // through confirm() because the placeholder will eventually become + // real third-party URLs. import('./share/gallery.js').then(({ mountGalleryPanel }) => { - mountGalleryPanel(galleryMount, (url) => __shareImportFromUrl(url)); + mountGalleryPanel(galleryMount, (url) => { + const b = window.__rvBridge; + const liveCount = (function () { + try { return (b && b.info && b.info().brains) | 0; } catch (_) { return 0; } + })(); + const ok = window.confirm( + 'Import this community archive?\n\n' + url + '\n\n' + + 'This will REPLACE your current archive (' + liveCount + ' brain' + + (liveCount === 1 ? '' : 's') + ').\n\nProceed?' + ); + if (!ok) return; + __shareImportFromUrl(url); + }); }).catch((e) => { console.warn('[rv-panel] share gallery mount failed', e); }); + __rvShareRow = shareRow; } + + // === Phase A β€” UI discoverability pass: πŸ§ͺ Experiments disclosure === + // + // Consolidates the RuLake-inspired feature toggles into one collapsible + // section. The previously-flag-gated rows (snapshots, share, crosstab) + // now live here and are gated by checkboxes inside the disclosure. URL + // flags are preserved as PRESETS that pre-toggle the corresponding + // checkbox at boot, but they no longer gate whether the UI exists. + // + // We MOVE existing DOM nodes (consistency, federation, crosstab, + // snapshots, share) into the disclosure body via appendChild, which + // preserves every existing event listener and querySelector reference. + // Refactoring all that wiring would be a much bigger change; the move- + // not-rebuild approach is the smallest possible diff that achieves the + // discoverability goal. + // + // Default state: see the "Default state per feature" table in + // docs/plan/ui-discoverability-pass.md. + (function buildExperimentsPanel() { + const usp = (function () { + try { return new URLSearchParams(window.location.search || ''); } catch (_) { return null; } + })(); + const flag = (k) => usp && usp.get(k) !== null; + const flagEq = (k, v) => usp && usp.get(k) === v; + + const details = document.createElement('details'); + details.className = 'rv-experiments'; + details.setAttribute('data-rv', 'experiments'); + details.innerHTML = [ + 'πŸ§ͺ Experiments (RuLake-inspired toggles)', + '
', + '
', + ' ', + ' flame-graph-lite for every generation', + ' ', + '
', + '
', + ' ', + ' export, import, share via URL', + ' ', + ' ', + '
', + '
', + ' ', + ' two tabs share an archive via BroadcastChannel', + ' ', + ' ', + '
', + '
', + '
', + '
', + ' ', + ' module ships, integration is a future slice', + ' ', + '
', + '
', + ].join(''); + root.appendChild(details); + + const expBody = details.querySelector('[data-rv="experiments-body"]'); + const expSnapshotsRow = details.querySelector('[data-rv="exp-snapshots-row"]'); + const expSnapshotsCb = details.querySelector('[data-rv="exp-snapshots"]'); + const expSnapshotsSub = details.querySelector('[data-rv="exp-snapshots-subbody"]'); + const expCrosstabRow = details.querySelector('[data-rv="exp-crosstab-row"]'); + const expCrosstabCb = details.querySelector('[data-rv="exp-crosstab"]'); + const expCrosstabSub = details.querySelector('[data-rv="exp-crosstab-subbody"]'); + const expFedRow = details.querySelector('[data-rv="exp-federation-row"]'); + const expConsRow = details.querySelector('[data-rv="exp-consistency-row"]'); + const expObsCb = details.querySelector('[data-rv="exp-observability"]'); + + // Move existing DOM nodes into the disclosure. Listeners attached + // earlier survive the appendChild move β€” that's the whole reason we + // refactored as "wrap, don't rebuild." + const consistencyEl = root.querySelector('[data-rv="consistency"]'); + const federationEl = root.querySelector('[data-rv="federation"]'); + const crosstabEl = root.querySelector('[data-rv="crosstab"]'); + if (federationEl && expFedRow) { + // Unset the federation toggle's prior placement; re-parent under exp. + expFedRow.appendChild(federationEl); + } + if (consistencyEl && expConsRow) { + expConsRow.appendChild(consistencyEl); + } + if (crosstabEl && expCrosstabSub) { + // The pill itself moves into the snapshots-style subbody, hidden + // until the experiments checkbox flips it on. + crosstabEl.hidden = false; // we control via subbody.hidden now + expCrosstabSub.appendChild(crosstabEl); + } + if (__rvSnapshotsRow && expSnapshotsSub) { + expSnapshotsSub.appendChild(__rvSnapshotsRow); + } + if (__rvShareRow && expSnapshotsSub) { + expSnapshotsSub.appendChild(__rvShareRow); + } + + // Snapshots toggle β€” show/hide the controls (which include both the + // file-based Export/Import row and the URL share row). + function applySnapshotsState(on) { + if (!expSnapshotsSub) return; + expSnapshotsSub.hidden = !on; + } + expSnapshotsCb.addEventListener('change', () => applySnapshotsState(expSnapshotsCb.checked)); + if (_snapshotsFlagOn) { + expSnapshotsCb.checked = true; + applySnapshotsState(true); + } + + // Crosstab toggle β€” flip both the bridge state AND the pill visibility. + function applyCrosstabState(on) { + if (expCrosstabSub) expCrosstabSub.hidden = !on; + try { + const b = window.__rvBridge; + if (b && typeof b.setCrosstabEnabled === 'function') b.setCrosstabEnabled(!!on); + } catch (e) { console.warn('[rv-experiments] setCrosstabEnabled failed', e); } + } + expCrosstabCb.addEventListener('change', () => applyCrosstabState(expCrosstabCb.checked)); + // ?crosstab=1 preset β€” but the bridge may not be ready yet. The + // existing __applyUrlCrosstabFlag in main.js polls for bridge + // readiness; here we just sync the checkbox state. The bridge's + // setCrosstabEnabled will then be called once it's ready. + if (flagEq('crosstab', '1')) { + expCrosstabCb.checked = true; + // Apply with a short delay to give the bridge time to load. If the + // bridge isn't ready, applyCrosstabState's try/catch swallows it + // and main.js's poll will pick up the slack. + setTimeout(() => applyCrosstabState(true), 100); + } + + // Observability toggle β€” show/hide the obs panel. The panel itself is + // mounted by the existing 3A code below; we just toggle its CSS. + function applyObsState(on) { + const obs = root.querySelector('.rv-obs-panel'); + if (obs) obs.hidden = !on; + } + expObsCb.addEventListener('change', () => applyObsState(expObsCb.checked)); + // Apply after a tick so the obs panel is mounted by then. + setTimeout(() => applyObsState(expObsCb.checked), 250); + + // Default-collapsed: leave details closed unless any feature is + // pre-toggled by a URL flag, in which case open it so the user can + // see what their share-link enabled. + if (flag('snapshots') || flag('crosstab') || flag('federation') || + flag('consistency') || flag('archive')) { + details.open = true; + } + })(); })(); diff --git a/docs/plan/ui-discoverability-pass.md b/docs/plan/ui-discoverability-pass.md index 6e1de0e..ac02f70 100644 --- a/docs/plan/ui-discoverability-pass.md +++ b/docs/plan/ui-discoverability-pass.md @@ -13,21 +13,28 @@ edits to `main.js` and `style.css`. **Legend:** ⬜ todo Β· 🟑 in progress Β· βœ… done Β· 🚫 blocked Β· ⏸ deferred -**Current focus:** Not started β€” waiting on user approval of this plan. +**Current focus:** Implementation complete; pending PR #2 review/merge. **Last updated:** 2026-04-24 | Status | ID | Task | Owner | PR/SHA | Done date | |:--:|:--:|------|-------|--------|-----------| -| ⬜ | A.1 | Add `πŸ§ͺ Experiments` disclosure panel scaffold | | | | -| ⬜ | A.2 | Migrate Federation checkbox into the panel | | | | -| ⬜ | A.3 | Migrate Consistency modes radio row into the panel | | | | -| ⬜ | A.4 | Migrate Observability panel toggle into the panel | | | | -| ⬜ | A.5 | Un-gate Snapshot / share row (remove `?snapshots=1` requirement for UI render) | | | | -| ⬜ | A.6 | Un-gate Cross-tab row (remove `?crosstab=1` requirement for UI render) | | | | -| ⬜ | A.7 | Add disabled Quantization row with tooltip | | | | -| ⬜ | A.8 | Add `confirm()` on destructive Import action | | | | -| ⬜ | A.9 | `[Learn]` links next to each row β†’ open matching ELI15 chapter | | | | -| ⬜ | A.10 | Smoke-test via agent-browser + commit | | | | +| βœ… | A.1 | Add `πŸ§ͺ Experiments` disclosure panel scaffold | Claude | PR #2 | 2026-04-24 | +| βœ… | A.2 | Migrate Federation checkbox into the panel | Claude | PR #2 | 2026-04-24 | +| βœ… | A.3 | Migrate Consistency modes radio row into the panel | Claude | PR #2 | 2026-04-24 | +| βœ… | A.4 | Migrate Observability panel toggle into the panel | Claude | PR #2 | 2026-04-24 | +| βœ… | A.5 | Un-gate Snapshot / share row (remove `?snapshots=1` requirement for UI render) | Claude | PR #2 | 2026-04-24 | +| βœ… | A.6 | Un-gate Cross-tab row (remove `?crosstab=1` requirement for UI render) | Claude | PR #2 | 2026-04-24 | +| βœ… | A.7 | Add disabled Quantization row with tooltip | Claude | PR #2 | 2026-04-24 | +| βœ… | A.8 | Add `confirm()` on destructive Import action | Claude | PR #2 | 2026-04-24 | +| βœ… | A.9 | `[Learn]` links next to each row β†’ open matching ELI15 chapter | Claude | PR #2 | 2026-04-24 | +| βœ… | A.10 | Smoke-test via agent-browser + commit | Claude | PR #2 | 2026-04-24 | + +**Implementation notes:** +- Refactor strategy was **wrap-don't-rebuild**: the existing consistency, federation, and crosstab DOM nodes are appended into the disclosure body via `appendChild`, which preserves every event listener and `el.X` reference established earlier in the file. No event re-binding required. +- Disclosure auto-opens whenever ANY URL flag is set (`?snapshots=1`, `?crosstab=1`, `?federation=1`, `?consistency=*`, `?archive=*`) so a user opening a share link immediately sees what's enabled. +- Crosstab listeners are now wired unconditionally (previously gated by `?crosstab=1`); the experiments toggle drives `setCrosstabEnabled` rather than render-time gating. +- Smoke harness uses two hidden iframes β€” one no-flag, one `?snapshots=1` β€” to test default state AND preset behaviour in a single page. +- 7/7 harness PASS including the cross-feature claim that toggling the crosstab checkbox flips the bridge's `isCrosstabEnabled()` from false to true (proves UI-to-bridge coupling, not just visual). **Exit gate:** all rows βœ… + agent-browser smoke: - default URL: Experiments panel visible (collapsed), no behaviour change vs pre-pass; diff --git a/tests/experiments-panel-smoke.html b/tests/experiments-panel-smoke.html new file mode 100644 index 0000000..8842563 --- /dev/null +++ b/tests/experiments-panel-smoke.html @@ -0,0 +1,150 @@ + + + + + Experiments panel smoke (Phase A) + + + +

Experiments panel smoke (Phase A)

+

+ Loads ../AI-Car-Racer/index.html in a hidden iframe and + asserts UI claims. The plain URL load tests default state; a second + iframe loads with ?snapshots=1 to verify the URL flag + presets the corresponding toggle. +

+ +
running…
+ +

Claims

+ + + +
#claimverdictdetail
+ + + + + + + From 209fbb0e00699ee513c55bb05fa12e681146b37b Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Mon, 27 Apr 2026 00:15:11 -0400 Subject: [PATCH 3/4] feat(brain-saves): named multi-slot saves + start-with-empty-brain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "🧠 Brain saves" disclosure section directly below the existing "More actions" block (which contains the legacy single-slot Save Best+Restart / Restore Old Brain). The new row gives users: β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β–Ό (no saves yet) β”‚ ← + + + +
+ + + +
+
+ +
+ +
`; bottomText.innerHTML = `

Train your model!

@@ -189,13 +207,23 @@ function phaseToLayout(phase){ try { const brainShareEl = document.getElementById('brainShareSection'); const moreActionsEl = document.getElementById('moreActions'); + const brainSavesEl = document.getElementById('brainSaves'); const rvPanelEl = document.getElementById('rv-panel'); if (rvPanelEl && rvPanelEl.parentNode) { if (brainShareEl) rvPanelEl.parentNode.insertBefore(brainShareEl, rvPanelEl.nextSibling); if (moreActionsEl && brainShareEl) brainShareEl.parentNode.insertBefore(moreActionsEl, brainShareEl.nextSibling); else if (moreActionsEl) rvPanelEl.parentNode.insertBefore(moreActionsEl, rvPanelEl.nextSibling); + // Brain saves sits directly under moreActions so the + // legacy single-slot Save Best+Restart button and the + // new named-slot row are visually adjacent. + if (brainSavesEl && moreActionsEl) moreActionsEl.parentNode.insertBefore(brainSavesEl, moreActionsEl.nextSibling); } } catch (_) {} + // Populate the brain-saves dropdown from localStorage now that + // the + + + + + + + From 1f790c212a23eeeba5070d49dcacdbc9ffe3f9c6 Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Mon, 27 Apr 2026 00:30:06 -0400 Subject: [PATCH 4/4] feat(fastlap): track-aware fast lap + last lap + all-time best subscript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global localStorage.fastLap key was track-confused: train on Rect, get an 11s record, switch to a triangle, the panel still shows "11.0" for a track where 11s is impossible. Phase A makes Fast Lap track-aware by content-addressing tracks the same way Phase 1D content-addresses brains. What changed user-visible: Before: "Fast Lap: 11.0" (one global, unaware of track) After: "Fast Lap: 11.00s (this track)" "Last: 13.42s" "all-time best: 9.10s" (subscript; suppressed when this track IS the all-time-record holder) Storage shape: localStorage.vv_fastlap_ = JSON of {timeS, recordedAt, generation}. trackHash = xxHash32 over the 512-d CNN track embedding (window.currentTrackVec) via the new hashVec alias in archive/hash.js β€” the same xxHash32 helper Phase 1D already uses to dedup brains. Symmetric naming: hashBrain at the F5 site, hashVec at the fast-lap site, one implementation. What this ships: - archive/hash.js β€” hashVec exported as an alias of hashBrain. Same function, two domain-honest names. Documented in a comment. - main.js β€” fastLap stays a global cache (so brainExport.js, grapher.js and other classic-script readers continue to work) but now reflects the CURRENT track's record. New globals: lastLap (in-memory, per-session) and allTimeBest (cached min over vv_fastlap_*). Helper bridge under window.__vvFastLap exposes syncFromStore / write / read / trackKey / allTimeBest / setLastLap / getLastLap so the classic-script files (buttonResponse.js) can call them without a module bridge. Hash util loaded async via dynamic import; bounded poll _bootSyncFastLap waits up to 15s for both hash util and currentTrackVec, then syncs once. New-record path writes per-track + recomputes allTimeBest. The render becomes 3-4 lines: "Fast Lap" + "Last" + (sometimes) "all-time best", with opacity hierarchy Fast Lap > Last > all-time-best. - main.js β€” legacy localStorage.fastLap key silently retired at boot. The old value was attributed to no track; migrating it would mislead. Clean cut: user gets a fresh per-track record from their next lap. - buttonResponse.js β€” resetFastLap() now scopes to the CURRENT track via __vvFastLap.trackKey + syncFromStore. clearAllFastLaps() is the bulk option, removes every vv_fastlap_* key (preserving vv_brainsave_* β€” confirmation dialog mentions this explicitly). destroyBrain() routes through resetFastLap() so the post-reset state is consistent. - utils.js β€” new "πŸ—‘ Clear all fast laps" row in the 🧠 Brain saves disclosure, directly below "🌱 Start with empty brain". Same destructive-but-cheap energy; same confirm()-friction shape. - tests/fastlap-track-aware-smoke.html β€” 7-claim harness covering hashVec aliasing, hash determinism, distinct-vec hash distinctness, per-track segregation, all-time-best math, legacy-key retirement not disturbing per-track records, clear-all-fastlaps not touching vv_brainsave_* keys. Validated via agent-browser: - Harness PASS 7/7. - Default boot clean: timer renders "Fast Lap: -- (this track) / Last: β€”" with the all-time-best subscript correctly suppressed (no records yet). - Live-state injection (current track 15.5s, different track 9.1s, last lap 17.3s): timer renders all 3 lines + subscript, opacity hierarchy as designed. Screenshot at /tmp/vv-review/10-*.png. - window.__vvFastLap API exposed and reachable from buttonResponse.js. Out of scope (intentional non-changes): - Track-change hot re-sync β€” the cache picks up new currentTrackVec references via _trackHash's identity check, but the global fastLap doesn't auto-resync on every track switch. The first lap on a new track triggers the sync via the new-record handler. Acceptable given how rarely tracks change mid-session. - Lap-history sparkline β€” deferred per the plan; the 3-line layout is already the meaningful upgrade. - Track-preview thumbnails β€” deferred; would require a proper "your tracks" feature, not an inline addition. --- AI-Car-Racer/archive/hash.js | 6 + AI-Car-Racer/buttonResponse.js | 55 ++++++++- AI-Car-Racer/main.js | 174 ++++++++++++++++++++++++++- AI-Car-Racer/utils.js | 3 + docs/plan/ui-discoverability-pass.md | 18 +++ tests/fastlap-track-aware-smoke.html | 165 +++++++++++++++++++++++++ 6 files changed, 415 insertions(+), 6 deletions(-) create mode 100644 tests/fastlap-track-aware-smoke.html diff --git a/AI-Car-Racer/archive/hash.js b/AI-Car-Racer/archive/hash.js index 045a21a..a1861bb 100644 --- a/AI-Car-Racer/archive/hash.js +++ b/AI-Car-Racer/archive/hash.js @@ -79,3 +79,9 @@ export function hashBrain(flat, seed = 0) { const h = xxHash32Bytes(bytes, seed); return h.toString(16).padStart(8, '0'); } + +// Symmetric alias: the same xxHash32-over-Float32Array function is used to +// content-address brains (Phase 1D) AND tracks (Phase A fastlap-track-aware). +// "hashBrain" reads naturally for the F5 dedup site; "hashVec" reads naturally +// at the track-fastlap site. Same code, two domain-honest names. +export const hashVec = hashBrain; diff --git a/AI-Car-Racer/buttonResponse.js b/AI-Car-Racer/buttonResponse.js index ed08bbe..dc1c5a3 100644 --- a/AI-Car-Racer/buttonResponse.js +++ b/AI-Car-Racer/buttonResponse.js @@ -240,12 +240,65 @@ async function brainStartFresh(){ } // ========================================================================= function resetFastLap(){ + // Phase A: scope the reset to the CURRENT track only. The legacy + // global `localStorage.fastLap` key was retired at boot; the new + // per-track keys are vv_fastlap_. Use the bridge helper + // exposed by main.js to look up the current track's key. + try { + if (window.__vvFastLap && typeof window.__vvFastLap.trackKey === 'function') { + const k = window.__vvFastLap.trackKey(); + if (k) localStorage.removeItem(k); + // Re-sync the global cache so the UI reflects the cleared state. + if (typeof window.__vvFastLap.syncFromStore === 'function') { + window.__vvFastLap.syncFromStore(); + return; + } + } + } catch (_) {} + // Fallback for the case where the bridge helper isn't loaded yet + // (e.g., during very early boot). Match the pre-Phase-A behaviour + // of clearing the in-memory cache. fastLap = '--'; + if (typeof lastLap !== 'undefined') lastLap = null; } function destroyBrain(){ localStorage.removeItem("bestBrain"); + // Phase A: legacy fastLap key is already retired at boot; this + // removal is a no-op now but kept so any pre-Phase-A revert leaves + // a clean slate. resetFastLap() handles the per-track keys. localStorage.removeItem("fastLap"); - fastLap="--"; + resetFastLap(); +} + +// Phase A: bulk clear of every per-track fastLap. Wired from the +// 🧠 Brain saves disclosure (utils.js) for the destructive bulk option, +// distinct from resetFastLap() which only clears the current track. +function clearAllFastLaps(){ + var keys = []; + try { + for (var i = 0; i < localStorage.length; i++){ + var k = localStorage.key(i); + if (k && window.__vvFastLap && k.indexOf(window.__vvFastLap.prefix) === 0){ + keys.push(k); + } + } + } catch (_) {} + if (keys.length === 0){ + window.alert('No fast-lap records to clear.'); + return; + } + if (!window.confirm('Clear ALL ' + keys.length + ' track fast-lap record' + + (keys.length === 1 ? '' : 's') + + '? This cannot be undone.\n\nNamed brain saves are NOT affected.')){ + return; + } + for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]); + // Resync display. + try { + if (window.__vvFastLap && typeof window.__vvFastLap.syncFromStore === 'function'){ + window.__vvFastLap.syncFromStore(); + } + } catch (_) {} } function submitTrack(){ road.getTrack(); diff --git a/AI-Car-Racer/main.js b/AI-Car-Racer/main.js index 527dfe9..a102774 100644 --- a/AI-Car-Racer/main.js +++ b/AI-Car-Racer/main.js @@ -93,7 +93,140 @@ var invincible=false; var traction=0.5; var frameCount = 0; // mirrors worker's frameCount via snapshots + +// === Phase A β€” track-aware fast lap ===================================== +// Three pieces of state, all globals because main.js is a classic script +// and other classic-script files (buttonResponse.js, brainExport.js) read +// `fastLap` directly. We KEEP `fastLap` as a global cache of the *current +// track's* best lap so existing callers continue to work; we just resync +// it whenever the active track changes (see _syncFastLapForCurrentTrack). +// +// Storage shape: localStorage.vv_fastlap_ = JSON of +// { timeS, recordedAt, generation }. trackHash is xxHash32 of the 512-d +// CNN track embedding (window.currentTrackVec) β€” same Phase 0 hash util +// that content-addresses brains; Phase A re-uses it under the alias +// hashVec for naming honesty. +// +// `lastLap` is in-memory only: it tracks the most recent *completed* lap +// in this session, regardless of whether it was a record. Persisting +// "last" would conflate it with "fastest"; the value of last-lap is +// real-time signal during a run, not historical. +// +// `allTimeBest` is a derived cache: min over every vv_fastlap_* entry. +// Recomputed on track change and on new-record. Cheap (small N). var fastLap = '--'; +var lastLap = null; +var allTimeBest = null; +const FASTLAP_PREFIX = 'vv_fastlap_'; + +// Async-load the hash util once. main.js is a classic script; archive/hash.js +// is an ES module β€” dynamic import() bridges them. Until it resolves, +// _trackHash() returns null and the fast-lap logic falls back to '--' (no +// regression vs. pre-Phase-A: the legacy global was '--' on first load too). +let __hashVec = null; +import('./archive/hash.js').then((m) => { __hashVec = m.hashVec || m.hashBrain; }) + .catch((e) => { console.warn('[fastlap] hash util load failed', e); }); + +// Track-hash cache so we don't re-xxHash the 512-dim vec on every render. +let __trackHashCache = { vec: null, hash: null }; +function _trackHash() { + const v = window.currentTrackVec; + if (!v || !v.length || !__hashVec) return null; + if (__trackHashCache.vec === v) return __trackHashCache.hash; + try { + const h = __hashVec(v); + __trackHashCache = { vec: v, hash: h }; + return h; + } catch (e) { + console.warn('[fastlap] hash failed', e); + return null; + } +} +function _trackKey() { + const h = _trackHash(); + return h ? (FASTLAP_PREFIX + h) : null; +} +function _readFastLapForCurrentTrack() { + const key = _trackKey(); + if (!key) return '--'; + try { + const raw = localStorage.getItem(key); + if (!raw) return '--'; + const obj = JSON.parse(raw); + return (typeof obj.timeS === 'number') ? obj.timeS : '--'; + } catch (_) { return '--'; } +} +function _writeFastLapForCurrentTrack(timeS, generation) { + const key = _trackKey(); + if (!key) return false; + try { + localStorage.setItem(key, JSON.stringify({ + timeS: timeS, + recordedAt: new Date().toISOString(), + generation: (generation | 0), + })); + return true; + } catch (e) { + console.warn('[fastlap] write failed', e); + return false; + } +} +function _computeAllTimeBest() { + let best = Infinity; + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || k.indexOf(FASTLAP_PREFIX) !== 0) continue; + try { + const obj = JSON.parse(localStorage.getItem(k)); + if (obj && typeof obj.timeS === 'number' && obj.timeS < best) best = obj.timeS; + } catch (_) {} + } + } catch (_) {} + return Number.isFinite(best) ? best : null; +} +// Re-sync the global cache from localStorage. Called on boot (after the +// hash util loads), on track change, on reset, and after a new-record write. +function _syncFastLapForCurrentTrack() { + fastLap = _readFastLapForCurrentTrack(); + allTimeBest = _computeAllTimeBest(); +} +// Exposed on window so buttonResponse.js (classic script, separate file) +// can call them without a module bridge. Kept namespaced under __vvFastLap +// so we don't pollute the global namespace beyond `fastLap` itself. +window.__vvFastLap = { + syncFromStore: _syncFastLapForCurrentTrack, + write: _writeFastLapForCurrentTrack, + read: _readFastLapForCurrentTrack, + trackKey: _trackKey, + allTimeBest: _computeAllTimeBest, + setLastLap: function (t) { lastLap = (typeof t === 'number') ? t : null; }, + getLastLap: function () { return lastLap; }, + prefix: FASTLAP_PREFIX, +}; + +// Legacy retirement: silently drop the pre-Phase-A track-confused +// localStorage.fastLap key. The old value was attributed to no specific +// track, so migrating it would be misleading; clean cut is honest. +try { localStorage.removeItem('fastLap'); } catch (_) {} + +// Polling boot-sync: when the hash util eventually loads AND +// currentTrackVec eventually becomes available, sync the cache so the +// timer renders the right number on the very first paint that has both. +// Bounded retries; gives up silently if neither materializes (display +// stays '--' which is the correct "no record on this track yet" state). +(function _bootSyncFastLap() { + let tries = 0; + const id = setInterval(() => { + if (__hashVec && window.currentTrackVec) { + _syncFastLapForCurrentTrack(); + clearInterval(id); + } else if (++tries > 60) { + clearInterval(id); + } + }, 250); +})(); +// ========================================================================= // Sim-speed multiplier. Worker owns the AI-car accumulator; main owns a // parallel accumulator for the 2 player cars only. They drift slightly under @@ -145,9 +278,10 @@ if (localStorage.getItem("traction")){ if (localStorage.getItem("maxSpeed")){ maxSpeed=JSON.parse(localStorage.getItem("maxSpeed")); } -if (localStorage.getItem("fastLap")){ - fastLap = JSON.parse(localStorage.getItem("fastLap")); -} +// Phase A: legacy `fastLap` localStorage key retired in the boot block +// above; per-track values now live under vv_fastlap_. The +// initial hydration from those keys happens in _bootSyncFastLap() once +// both the hash util and currentTrackVec are available. if (localStorage.getItem("conservativeInit")){ const v = parseFloat(localStorage.getItem("conservativeInit")); if (Number.isFinite(v)) conservativeInit = Math.max(0, Math.min(1, v)); @@ -1126,9 +1260,21 @@ function performNextBatch(genData){ _times.save = performance.now() - _tSave; if (genData.laps > 0 && genData.lapTimes && genData.lapTimes.length){ const minLap = Math.min.apply(null, genData.lapTimes); + // Phase A: lastLap is "the most recent completed lap", not the + // batch's fastest. Multi-lap batches still update lastLap to the + // last entry for an honest "what just happened?" signal. + const lastEntry = genData.lapTimes[genData.lapTimes.length - 1]; + if (typeof lastEntry === 'number' && Number.isFinite(lastEntry)) { + lastLap = lastEntry; + } if (fastLap === '--' || minLap < fastLap){ + // Per-track write. _writeFastLapForCurrentTrack is a no-op if + // currentTrackVec/hash aren't ready yet; the in-memory + // `fastLap` cache still updates so the UI reflects the new + // record even if the persist is deferred to the next sync. fastLap = minLap; - localStorage.setItem('fastLap', JSON.stringify(fastLap)); + _writeFastLapForCurrentTrack(minLap, generation); + allTimeBest = _computeAllTimeBest(); } } @@ -1233,7 +1379,25 @@ function animate(){ const wallSecs = ((performance.now() - wallStart)/1000).toFixed(2); timer.innerHTML = "

Sim Time: " + simSecs + "s " + "(wall " + wallSecs + "s · " + simSpeed + "×)

"; - timer.innerHTML += "

Fast Lap: " + (typeof fastLap === 'number' ? fastLap.toFixed(2) : fastLap) + "

"; + // Phase A: track-aware fast-lap render. Three lines: + // β€’ Fast Lap: 12.34s (this track) + // β€’ Last: 14.10s + // β€’ all-time best: 9.10s + // The "(this track)" tag is load-bearing β€” without it a returning + // user could assume the displayed value is global. allTimeBest is + // hidden when only one track has ever been raced (best === current). + const _fastStr = (typeof fastLap === 'number' ? fastLap.toFixed(2) + 's' : fastLap); + const _lastStr = (typeof lastLap === 'number' ? lastLap.toFixed(2) + 's' : 'β€”'); + timer.innerHTML += "

Fast Lap: " + _fastStr + + " (this track)

"; + timer.innerHTML += "

Last: " + _lastStr + "

"; + if (typeof allTimeBest === 'number' && + (typeof fastLap !== 'number' || allTimeBest < fastLap)) { + // Only show the subscript when a different track holds the + // record β€” saves a row of noise for single-track users. + timer.innerHTML += "

" + + "all-time best: " + allTimeBest.toFixed(2) + "s

"; + } ctx.save(); if(!pause){ diff --git a/AI-Car-Racer/utils.js b/AI-Car-Racer/utils.js index 72cc238..06282ed 100644 --- a/AI-Car-Racer/utils.js +++ b/AI-Car-Racer/utils.js @@ -188,6 +188,9 @@ function phaseToLayout(phase){
+
+ +
`; diff --git a/docs/plan/ui-discoverability-pass.md b/docs/plan/ui-discoverability-pass.md index c4750b1..a6bc47f 100644 --- a/docs/plan/ui-discoverability-pass.md +++ b/docs/plan/ui-discoverability-pass.md @@ -37,6 +37,24 @@ edits to `main.js` and `style.css`. | βœ… | B.2 | Brain-saves UI block in utils.js (`
` + dropdown + 4 buttons) | Claude | PR #2 | 2026-04-24 | | βœ… | B.3 | Smoke harness `tests/brain-saves-smoke.html` (6/6 PASS) + agent-browser validate | Claude | PR #2 | 2026-04-24 | +**Follow-up: track-aware fast lap (FL.* below) shipped on the same branch (PR #2):** + +| Status | ID | Task | Owner | PR/SHA | Done date | +|:--:|:--:|------|-------|--------|-----------| +| βœ… | FL.1 | `hashVec` alias in `archive/hash.js`; per-track plumbing in `main.js` (state, helpers, boot-sync, legacy retirement) | Claude | PR #2 | 2026-04-24 | +| βœ… | FL.2 | Track-scope `resetFastLap`; `clearAllFastLaps` in `buttonResponse.js`; `πŸ—‘ Clear all fast laps` button wired in 🧠 Brain saves disclosure; new timer render layout (`Fast Lap` + `Last` + `all-time best` subscript) | Claude | PR #2 | 2026-04-24 | +| βœ… | FL.3 | Smoke harness `tests/fastlap-track-aware-smoke.html` (7/7 PASS) + agent-browser validate | Claude | PR #2 | 2026-04-24 | + +The FL.* row makes Fast Lap track-aware β€” the global `localStorage.fastLap` +became `localStorage.vv_fastlap_` per track (hash via `hashVec`, +xxHash32 of the 512-d CNN track embedding). New panel layout: "Fast Lap: +12.34s (this track)" / "Last: 14.10s" / "all-time best: 9.10s" (subscript +suppressed when current track holds the all-time record). `Reset Fast Lap` +clears CURRENT track only; the bulk option lives in Brain saves as +`πŸ—‘ Clear all fast laps` with a confirm() dialog. Legacy `fastLap` +localStorage key silently retired at boot β€” the old value was +track-confused, clean cut is honest. + The B.* row delivers a multi-slot named-save system on top of the existing single-slot Save Best+Restart / Restore Old Brain pair. Storage: localStorage keys `vv_brainsave_`. Load reuses the existing seeding pathway diff --git a/tests/fastlap-track-aware-smoke.html b/tests/fastlap-track-aware-smoke.html new file mode 100644 index 0000000..adf1ee3 --- /dev/null +++ b/tests/fastlap-track-aware-smoke.html @@ -0,0 +1,165 @@ + + + + + Fast-lap track-aware smoke (Phase A) + + + +

Fast-lap track-aware smoke (Phase A)

+

+ Exercises the per-track fast-lap storage primitives. Imports + archive/hash.js directly to verify hash determinism, + then drives localStorage with the + vv_fastlap_<trackHash> shape used by + main.js. Also confirms the legacy fastLap + key retirement leaves the per-track keys alone. +

+ +
running…
+ +

Claims

+ + + +
#claimverdictdetail
+ + + +