From e0620fb1096a967a6b65972dc2a2e5045406cdca Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 00:50:01 -0500 Subject: [PATCH 01/17] ui-review: add curve-drag mutation/frame measurement harness --- web/scripts/curve-drag-budget.mjs | 123 ++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 web/scripts/curve-drag-budget.mjs diff --git a/web/scripts/curve-drag-budget.mjs b/web/scripts/curve-drag-budget.mjs new file mode 100644 index 0000000..ee6446b --- /dev/null +++ b/web/scripts/curve-drag-budget.mjs @@ -0,0 +1,123 @@ +// web/scripts/curve-drag-budget.mjs +// Measures DOM mutation volume and frame times during a scripted curve drag +// in mock mode. Usage: node scripts/curve-drag-budget.mjs [--url http://...] +// Without --url it spawns `npm run dev:mock` on a free port and stops it after. +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import process from 'node:process'; +import { chromium } from 'playwright'; + +const urlArgIndex = process.argv.indexOf('--url'); +const externalUrl = urlArgIndex >= 0 ? process.argv[urlArgIndex + 1] : null; +const host = '127.0.0.1'; + +function findOpenPort(startPort) { + return new Promise((resolve, reject) => { + const tryPort = (candidate) => { + const server = net.createServer(); + server.unref(); + server.once('error', (error) => { + if (error.code === 'EADDRINUSE' || error.code === 'EACCES') tryPort(candidate + 1); + else reject(error); + }); + server.listen(candidate, host, () => { + server.close(() => resolve(candidate)); + }); + }; + tryPort(startPort); + }); +} + +async function waitForServer(url, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + /* not up yet */ + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`dev server did not come up at ${url}`); +} + +let server = null; +let baseUrl = externalUrl; +if (!baseUrl) { + const port = await findOpenPort(5180); + baseUrl = `http://${host}:${port}`; + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + server = spawn(npmCommand, ['run', 'dev:mock', '--', '--port', String(port), '--strictPort'], { + cwd: new URL('..', import.meta.url).pathname, + stdio: 'ignore', + env: { ...process.env, BROWSER: 'none' } + }); + await waitForServer(baseUrl); +} + +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.goto(baseUrl); + await page.waitForSelector('.app-toolbar', { timeout: 15000 }); + await page.evaluate(() => { + window.location.hash = '#/tuning'; + }); + const frame = page.locator('.dm-curve-frame').first(); + await frame.waitFor({ timeout: 15000 }); + + await page.evaluate(() => { + window.__dragMetrics = { mutations: 0, frames: [] }; + const observer = new MutationObserver((records) => { + window.__dragMetrics.mutations += records.length; + }); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); + let last = performance.now(); + const tick = (now) => { + window.__dragMetrics.frames.push(now - last); + last = now; + window.__dragMetrics.raf = requestAnimationFrame(tick); + }; + window.__dragMetrics.raf = requestAnimationFrame(tick); + }); + + const box = await frame.boundingBox(); + const startX = box.x + box.width * 0.3; + const endX = box.x + box.width * 0.7; + const y = box.y + box.height * 0.5; + const MOVES = 240; + await page.mouse.move(startX, y); + await page.mouse.down(); + for (let i = 1; i <= MOVES; i += 1) { + const x = startX + ((endX - startX) * i) / MOVES; + const wobble = Math.sin(i / 8) * box.height * 0.2; + await page.mouse.move(x, y + wobble); + await new Promise((resolve) => setTimeout(resolve, 16)); + } + await page.mouse.up(); + + const metrics = await page.evaluate(() => { + cancelAnimationFrame(window.__dragMetrics.raf); + return { mutations: window.__dragMetrics.mutations, frames: window.__dragMetrics.frames }; + }); + const frames = metrics.frames.filter((ms) => ms > 0).sort((a, b) => a - b); + const pick = (q) => frames[Math.min(frames.length - 1, Math.floor(frames.length * q))] ?? 0; + console.log( + JSON.stringify( + { + moves: MOVES, + mutations: metrics.mutations, + mutationsPerMove: Number((metrics.mutations / MOVES).toFixed(1)), + frameP50Ms: Number(pick(0.5).toFixed(1)), + frameP95Ms: Number(pick(0.95).toFixed(1)), + frameMaxMs: Number(frames[frames.length - 1]?.toFixed(1) ?? 0) + }, + null, + 2 + ) + ); +} finally { + await browser.close(); + if (server) server.kill(); +} From 7097792a2b7661bf099a6133b91e839e1dfc6355 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 00:53:29 -0500 Subject: [PATCH 02/17] ui-review: harden drag harness server cleanup and error reporting --- web/scripts/curve-drag-budget.mjs | 216 +++++++++++++++++++----------- 1 file changed, 139 insertions(+), 77 deletions(-) diff --git a/web/scripts/curve-drag-budget.mjs b/web/scripts/curve-drag-budget.mjs index ee6446b..fd81332 100644 --- a/web/scripts/curve-drag-budget.mjs +++ b/web/scripts/curve-drag-budget.mjs @@ -2,11 +2,14 @@ // Measures DOM mutation volume and frame times during a scripted curve drag // in mock mode. Usage: node scripts/curve-drag-budget.mjs [--url http://...] // Without --url it spawns `npm run dev:mock` on a free port and stops it after. -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import net from 'node:net'; +import path from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const urlArgIndex = process.argv.indexOf('--url'); const externalUrl = urlArgIndex >= 0 ? process.argv[urlArgIndex + 1] : null; const host = '127.0.0.1'; @@ -28,7 +31,60 @@ function findOpenPort(startPort) { }); } -async function waitForServer(url, timeoutMs = 30000) { +function startServer(port) { + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const command = process.platform === 'win32' ? 'cmd.exe' : npmCommand; + const args = process.platform === 'win32' + ? ['/d', '/s', '/c', `${npmCommand} run dev:mock -- --port ${port} --strictPort`] + : ['run', 'dev:mock', '--', '--port', String(port), '--strictPort']; + const child = spawn(command, args, { + cwd: webRoot, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + env: { ...process.env, BROWSER: 'none' } + }); + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + output += chunk.toString(); + }); + return { child, output: () => output }; +} + +async function stopServer(child) { + if (!child.pid || child.killed) return; + if (process.platform === 'win32') { + spawnSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); + return; + } + const exited = new Promise((resolve) => { + child.once('exit', resolve); + }); + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + const timeout = await Promise.race([ + exited.then(() => 'exited'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 2_000)) + ]); + if (timeout === 'timeout') { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + await Promise.race([ + exited, + new Promise((resolve) => setTimeout(resolve, 1_000)) + ]); + } +} + +async function waitForServer(url, output, timeoutMs = 30000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { @@ -39,85 +95,91 @@ async function waitForServer(url, timeoutMs = 30000) { } await new Promise((resolve) => setTimeout(resolve, 250)); } - throw new Error(`dev server did not come up at ${url}`); + throw new Error(`dev server did not come up at ${url}\n${output()}`); } -let server = null; -let baseUrl = externalUrl; -if (!baseUrl) { - const port = await findOpenPort(5180); - baseUrl = `http://${host}:${port}`; - const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - server = spawn(npmCommand, ['run', 'dev:mock', '--', '--port', String(port), '--strictPort'], { - cwd: new URL('..', import.meta.url).pathname, - stdio: 'ignore', - env: { ...process.env, BROWSER: 'none' } - }); - await waitForServer(baseUrl); -} +async function main() { + let server = null; + let baseUrl = externalUrl; + if (!baseUrl) { + const port = await findOpenPort(5180); + baseUrl = `http://${host}:${port}`; + server = startServer(port); + } -const browser = await chromium.launch(); -try { - const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); - await page.goto(baseUrl); - await page.waitForSelector('.app-toolbar', { timeout: 15000 }); - await page.evaluate(() => { - window.location.hash = '#/tuning'; - }); - const frame = page.locator('.dm-curve-frame').first(); - await frame.waitFor({ timeout: 15000 }); + try { + if (server) await waitForServer(baseUrl, server.output); - await page.evaluate(() => { - window.__dragMetrics = { mutations: 0, frames: [] }; - const observer = new MutationObserver((records) => { - window.__dragMetrics.mutations += records.length; - }); - observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); - let last = performance.now(); - const tick = (now) => { - window.__dragMetrics.frames.push(now - last); - last = now; - window.__dragMetrics.raf = requestAnimationFrame(tick); - }; - window.__dragMetrics.raf = requestAnimationFrame(tick); - }); + const browser = await chromium.launch(); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.goto(baseUrl); + await page.waitForSelector('.app-toolbar', { timeout: 15000 }); + await page.evaluate(() => { + window.location.hash = '#/tuning'; + }); + const frame = page.locator('.dm-curve-frame').first(); + await frame.waitFor({ timeout: 15000 }); - const box = await frame.boundingBox(); - const startX = box.x + box.width * 0.3; - const endX = box.x + box.width * 0.7; - const y = box.y + box.height * 0.5; - const MOVES = 240; - await page.mouse.move(startX, y); - await page.mouse.down(); - for (let i = 1; i <= MOVES; i += 1) { - const x = startX + ((endX - startX) * i) / MOVES; - const wobble = Math.sin(i / 8) * box.height * 0.2; - await page.mouse.move(x, y + wobble); - await new Promise((resolve) => setTimeout(resolve, 16)); - } - await page.mouse.up(); + await page.evaluate(() => { + window.__dragMetrics = { mutations: 0, frames: [] }; + const observer = new MutationObserver((records) => { + window.__dragMetrics.mutations += records.length; + }); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); + let last = performance.now(); + const tick = (now) => { + window.__dragMetrics.frames.push(now - last); + last = now; + window.__dragMetrics.raf = requestAnimationFrame(tick); + }; + window.__dragMetrics.raf = requestAnimationFrame(tick); + }); - const metrics = await page.evaluate(() => { - cancelAnimationFrame(window.__dragMetrics.raf); - return { mutations: window.__dragMetrics.mutations, frames: window.__dragMetrics.frames }; - }); - const frames = metrics.frames.filter((ms) => ms > 0).sort((a, b) => a - b); - const pick = (q) => frames[Math.min(frames.length - 1, Math.floor(frames.length * q))] ?? 0; - console.log( - JSON.stringify( - { - moves: MOVES, - mutations: metrics.mutations, - mutationsPerMove: Number((metrics.mutations / MOVES).toFixed(1)), - frameP50Ms: Number(pick(0.5).toFixed(1)), - frameP95Ms: Number(pick(0.95).toFixed(1)), - frameMaxMs: Number(frames[frames.length - 1]?.toFixed(1) ?? 0) - }, - null, - 2 - ) - ); -} finally { - await browser.close(); - if (server) server.kill(); + const box = await frame.boundingBox(); + const startX = box.x + box.width * 0.3; + const endX = box.x + box.width * 0.7; + const y = box.y + box.height * 0.5; + const MOVES = 240; + await page.mouse.move(startX, y); + await page.mouse.down(); + for (let i = 1; i <= MOVES; i += 1) { + const x = startX + ((endX - startX) * i) / MOVES; + const wobble = Math.sin(i / 8) * box.height * 0.2; + await page.mouse.move(x, y + wobble); + await new Promise((resolve) => setTimeout(resolve, 16)); + } + await page.mouse.up(); + + const metrics = await page.evaluate(() => { + cancelAnimationFrame(window.__dragMetrics.raf); + return { mutations: window.__dragMetrics.mutations, frames: window.__dragMetrics.frames }; + }); + const frames = metrics.frames.filter((ms) => ms > 0).sort((a, b) => a - b); + const pick = (q) => frames[Math.min(frames.length - 1, Math.floor(frames.length * q))] ?? 0; + console.log( + JSON.stringify( + { + moves: MOVES, + mutations: metrics.mutations, + mutationsPerMove: Number((metrics.mutations / MOVES).toFixed(1)), + frameP50Ms: Number(pick(0.5).toFixed(1)), + frameP95Ms: Number(pick(0.95).toFixed(1)), + frameMaxMs: Number(frames[frames.length - 1]?.toFixed(1) ?? 0) + }, + null, + 2 + ) + ); + } finally { + await browser.close(); + } + } finally { + if (server) await stopServer(server.child); + } } + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); From 9f6405567c18fd32ac158245c202ea337f2b56c4 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 00:55:46 -0500 Subject: [PATCH 03/17] ui-review: stop effect tests from reassigning the whole snapshot --- web/src/App.svelte | 44 ++++++++++---------------------------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 313f4d6..13de49a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1974,15 +1974,10 @@ if (!refreshOnly) baseFeelTestBusy = true; try { if (!refreshOnly) await pollTriggerInput(); - const result = await runEffectTest(baseFeelTestRequest(), controller?.id); - - snapshot = { - ...snapshot, - effectState: { - ...snapshot.effectState, - output: result.output - } - }; + // The test response's output frame has no UI consumers; reassigning the + // whole snapshot here invalidated every snapshot-derived statement per + // 35ms refresh tick. The 1Hz snapshot stream keeps effectState current. + await runEffectTest(baseFeelTestRequest(), controller?.id); baseFeelTestActive = true; startTriggerInputPolling(); armBaseFeelTestTimer(); @@ -2005,7 +2000,8 @@ baseFeelTestBusy = true; baseFeelTestRefreshTask.clear(); try { - const result = await runEffectTest( + // Output frame has no UI consumers; the 1Hz snapshot stream keeps state current. + await runEffectTest( { target: 'base_feel', mode: 'off', @@ -2014,13 +2010,6 @@ }, controller?.id ); - snapshot = { - ...snapshot, - effectState: { - ...snapshot.effectState, - output: result.output - } - }; setApplyMessage('Base feel test stopped'); } catch (caught) { setApplyMessage(caught instanceof Error ? caught.message : 'Unable to stop Base feel test'); @@ -2047,7 +2036,8 @@ } try { - const result = await runEffectTest( + // Output frame has no UI consumers; the 1Hz snapshot stream keeps state current. + await runEffectTest( { target: 'rumble', mode: vibrationModeRequest(vibrationMode), @@ -2056,13 +2046,6 @@ }, controller?.id ); - snapshot = { - ...snapshot, - effectState: { - ...snapshot.effectState, - output: result.output - } - }; setApplyMessage(`${vibrationMode} body haptics previewed`); } catch (caught) { setApplyMessage(caught instanceof Error ? caught.message : 'Body haptics preview failed'); @@ -2076,7 +2059,8 @@ const intensity = lightbarEnabled ? lightbarBrightness : 0; try { - const result = await runEffectTest( + // Output frame has no UI consumers; the 1Hz snapshot stream keeps state current. + await runEffectTest( { target: 'lightbar', mode: color, @@ -2085,14 +2069,6 @@ }, controller?.id ); - - snapshot = { - ...snapshot, - effectState: { - ...snapshot.effectState, - output: result.output - } - }; } catch (caught) { setApplyMessage(caught instanceof Error ? caught.message : `${label} preview failed`); return; From 554223929fb8254c990c0a87aa83180483061408 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:00:52 -0500 Subject: [PATCH 04/17] ui-review: rAF-coalesce curve drags, cache drag rect, skip double normalization Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 9 ++++++--- web/src/app/triggerCurveEditor.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 13de49a..473e6b2 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1255,8 +1255,8 @@ const showTriggerPress = (_side: 'l2' | 'r2', value: number) => baseFeelTestActive || clampUnit(value) > 0.01; - const setPointsForSide = (side: TriggerSide, points: TriggerCurvePoint[]) => { - const normalized = normalizeTriggerCurvePoints(points, side === 'l2' ? l2Curve : r2Curve); + const setPointsForSide = (side: TriggerSide, points: TriggerCurvePoint[], alreadyNormalized = false) => { + const normalized = alreadyNormalized ? points : normalizeTriggerCurvePoints(points, side === 'l2' ? l2Curve : r2Curve); if (side === 'l2') { l2CurvePoints = normalized; } else { @@ -1266,8 +1266,11 @@ scheduleLiveControllerConfigSync(); }; + // Point edits come from withCurvePointSet/withCurvePointAddedOrSelected, + // which already start from normalizeTriggerCurvePoints() output — skip the + // second normalization pass per pointermove. const applyCurvePointEdit = (side: TriggerSide, edit: CurvePointEdit) => { - if (edit.points) setPointsForSide(side, edit.points); + if (edit.points) setPointsForSide(side, edit.points, true); return edit.index; }; diff --git a/web/src/app/triggerCurveEditor.ts b/web/src/app/triggerCurveEditor.ts index bc445e8..9a1a52a 100644 --- a/web/src/app/triggerCurveEditor.ts +++ b/web/src/app/triggerCurveEditor.ts @@ -273,11 +273,34 @@ export type CurveDragOptions = { export const beginCurveDrag = (event: PointerEvent, target: HTMLElement, options: CurveDragOptions) => { target.setPointerCapture(event.pointerId); + // Pointer capture pins the target for the whole drag, so one rect read at + // drag start replaces a forced layout per pointermove event. + const rect = target.getBoundingClientRect(); + const pointFrom = (pointerEvent: PointerEvent) => ({ + x: clampUnit((pointerEvent.clientX - rect.left) / Math.max(1, rect.width)), + output: clampUnit(1 - (pointerEvent.clientY - rect.top) / Math.max(1, rect.height)) + }); + + // High-rate mice deliver up to 1000 pointermove events/s; coalesce to one + // application per animation frame. + let pending: PointerEvent | null = null; + let frame = 0; + const flush = () => { + frame = 0; + if (pending) { + const next = pending; + pending = null; + options.onPoint(pointFrom(next)); + } + }; const applyPoint = (pointerEvent: PointerEvent) => { - options.onPoint(curveGraphPointFromPointer(pointerEvent, target)); + pending = pointerEvent; + if (!frame) frame = requestAnimationFrame(flush); }; const stopDrag = () => { + if (frame) cancelAnimationFrame(frame); + flush(); options.onEnd(); if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId); target.removeEventListener('pointermove', applyPoint); @@ -285,7 +308,7 @@ export const beginCurveDrag = (event: PointerEvent, target: HTMLElement, options target.removeEventListener('pointercancel', stopDrag); }; - if (options.applyInitialEvent) applyPoint(event); + if (options.applyInitialEvent) options.onPoint(pointFrom(event)); target.addEventListener('pointermove', applyPoint); target.addEventListener('pointerup', stopDrag); target.addEventListener('pointercancel', stopDrag); From 1772ea8832ba8657d694c60def766bb80a15779b Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:08:13 -0500 Subject: [PATCH 05/17] ui-review: debounce saved-rail diff recomputation Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 473e6b2..ed69fd0 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1119,10 +1119,27 @@ leftStickDeadzone, rightStickDeadzone }; - $: savedRailRows = savedDiffRows(profileSaveBaselineConfig, profileDraftSnapshot, { - includeForza: selectedTuningScope === 'game', - intensityPercent: forzaIntensityPercent - }); + // The rail diff is display-only (the dirty flag has its own signature path), + // so it recomputes on a 100ms trailing debounce instead of per input event. + let savedRailRows: ReturnType = []; + let savedRailDiffTimer = 0; + const refreshSavedRailRows = () => { + savedRailRows = savedDiffRows(profileSaveBaselineConfig, profileDraftSnapshot, { + includeForza: selectedTuningScope === 'game', + intensityPercent: forzaIntensityPercent + }); + }; + $: { + void profileDraftSnapshot; + void profileSaveBaselineConfig; + void selectedTuningScope; + if (typeof window === 'undefined') { + refreshSavedRailRows(); + } else { + window.clearTimeout(savedRailDiffTimer); + savedRailDiffTimer = window.setTimeout(refreshSavedRailRows, 100); + } + } $: unsavedCount = unsavedChangeCount(savedRailRows); $: savedRailProfileName = profiles.find((profile) => profile.id === (selectedOverrideProfileId || activeProfileId))?.name ?? From 4b1bb5b37e86656a95b6d3a3882e57908c9e32dc Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:12:29 -0500 Subject: [PATCH 06/17] =?UTF-8?q?ui-review:=20rail-diff=20debounce=20polis?= =?UTF-8?q?h=20=E2=80=94=20timer=20teardown=20and=20dep-pin=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/App.svelte b/web/src/App.svelte index ed69fd0..383a2ec 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1130,6 +1130,7 @@ }); }; $: { + // Touched (not used) so the legacy compiler re-runs this block when they change. void profileDraftSnapshot; void profileSaveBaselineConfig; void selectedTuningScope; @@ -2127,6 +2128,7 @@ liveConfigSync.clear(); clearBaseFeelTestTimers(); stopTriggerInputPolling(); + window.clearTimeout(savedRailDiffTimer); } }); appRuntime.start(); From acd34004d321fd8267c2825715408c12f9f8fe73 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:15:48 -0500 Subject: [PATCH 07/17] ui-review: honor deep-link intent after first snapshot; toast permanent guard bounces --- web/src/App.svelte | 30 ++++++++++++++++++++++++++++-- web/src/app/navigation.ts | 12 +++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 383a2ec..d9e44ac 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -8,7 +8,7 @@ import OnboardingTutorial from './components/OnboardingTutorial.svelte'; import SupportPanel from './components/SupportPanel.svelte'; import ToastStack from './components/ToastStack.svelte'; - import { guardView, hashForView, isViewHash, viewFromHash } from './app/navigation'; + import { guardView, hashForView, isViewHash, viewFromHash, viewIntentFromHash } from './app/navigation'; import { createAppRuntime } from './app/runtime'; import { createButtonMappingSession, @@ -508,6 +508,19 @@ setViewHash(guardedView); } } + $: if (requestedView && snapshot && !loading) { + const readiness = { tuningReady, buttonMappingReady, edgeSlotsReady }; + const promoted = guardView(requestedView, readiness); + if (promoted === requestedView) { + activeView = requestedView; + setViewHash(requestedView); + } else { + const message = guardBounceMessages[requestedView]; + if (message) showToast(message, 'info'); + setViewHash(activeView); + } + requestedView = null; + } $: profileContextGame = profileWorkspace.profileContextGame; $: profileContextGameId = profileWorkspace.profileContextGameId; $: profileContextLabel = profileWorkspace.profileContextLabel; @@ -743,10 +756,23 @@ if (window.location.hash !== nextHash) window.location.hash = nextHash; }; + // A deep link / reload may ask for a view whose readiness is still unknown + // (no snapshot yet). Park the intent instead of rewriting the hash, promote + // it when readiness flips true, and explain the bounce when it's permanent. + let requestedView: AppView | null = null; + const guardBounceMessages: Partial> = { + tuning: 'Tuning opens once a controller is connected.', + advancedButtonMapping: 'Button mapping needs a game selected in Tuning first.' + }; + const syncViewFromHash = () => { + const intent = typeof window === 'undefined' ? null : viewIntentFromHash(window.location.hash); const view = appViewFromHash(); + requestedView = intent && intent !== view ? intent : null; activeView = view; - setViewHash(view); + // Only rewrite the hash once readiness is known (or the hash was junk); + // a pending intent keeps the user's original hash in the address bar. + if (snapshot || !requestedView) setViewHash(view); }; const navigateToView = (view: AppView) => { diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index 9d2a8e2..1690bd7 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -31,7 +31,7 @@ export const viewTooltips: Record = { }; /** Old routes keep working forever; they land on the new home for that content. */ -const legacyRedirects: Record = { +const oldRouteRedirects: Record = { '#/games': '#/tuning', '#/adaptive-triggers-haptics': '#/tuning', '#/controllers': '#/advanced/controller', @@ -41,7 +41,7 @@ const legacyRedirects: Record = { /** Every hash the router answers to: current view hashes plus old-route redirects. */ export const knownViewHashes: string[] = [ ...appViews.map((item) => item.hash), - ...Object.keys(legacyRedirects) + ...Object.keys(oldRouteRedirects) ]; export function isViewHash(hash: string): boolean { @@ -62,7 +62,13 @@ export function guardView(view: AppView, readiness: ViewReadiness): AppView { } export function viewFromHash(rawHash: string, readiness: ViewReadiness): AppView { - const hash = legacyRedirects[rawHash] ?? rawHash; + const hash = oldRouteRedirects[rawHash] ?? rawHash; const match = appViews.find((item) => item.hash === hash); return guardView(match?.id ?? 'status', readiness); } + +/** The view a hash is asking for, before readiness guards — null for unknown hashes. */ +export function viewIntentFromHash(rawHash: string): AppView | null { + const hash = oldRouteRedirects[rawHash] ?? rawHash; + return appViews.find((item) => item.hash === hash)?.id ?? null; +} From fd15d35f80bdb07cbe95838ead44b89f80302477 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:20:44 -0500 Subject: [PATCH 08/17] ui-review: explicit navigation clears parked deep-link intent Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/App.svelte b/web/src/App.svelte index d9e44ac..0a1a9e7 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -778,6 +778,8 @@ const navigateToView = (view: AppView) => { view = guardView(view, { tuningReady, buttonMappingReady, edgeSlotsReady }); activeView = view; + // An explicit navigation always wins over a parked deep-link intent. + requestedView = null; setViewHash(view); }; From 26bfa4bf1403d3431e7bf5b4a49135ad9d9344cf Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:28:55 -0500 Subject: [PATCH 09/17] ui-review: stop 1Hz snapshot ticks from rebuilding identical maps and sessions --- web/src/App.svelte | 60 ++++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 0a1a9e7..c139ddb 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -482,7 +482,19 @@ ? { ...effect, state: 'active' } : effect; }); - $: effectStatusById = new Map(displayedParityEffects.map((effect) => [normalizeEffectId(effect.id), effect])); + // Rebuild the Map (a prop of all TelemetryRoutingPanel instances) only when + // an effect's state actually changes, not on every snapshot tick. + let effectStatusById = new Map(); + let effectStatusSignature = '__unset__'; + $: { + const signature = displayedParityEffects + .map((effect) => `${normalizeEffectId(effect.id)}:${effect.state}`) + .join('|'); + if (signature !== effectStatusSignature) { + effectStatusSignature = signature; + effectStatusById = new Map(displayedParityEffects.map((effect) => [normalizeEffectId(effect.id), effect])); + } + } $: activeProfileName = profileWorkspace.activeProfileName; $: activeProfile = profileWorkspace.activeProfile; $: selectedOverrideProfile = profileWorkspace.selectedOverrideProfile; @@ -651,17 +663,23 @@ const trackEffectActivity = (effect: CurrentEffectState) => { const now = Date.now(); const nextActivity = { ...effectActivityUntil }; + let changed = false; for (const item of effect.parityEffects) { const id = normalizeEffectId(item.id); if (item.state === 'disabled') { - delete nextActivity[id]; + if (id in nextActivity) { + delete nextActivity[id]; + changed = true; + } } else if (item.state === 'active') { nextActivity[id] = now + 550; - } else if ((nextActivity[id] ?? 0) <= now) { + changed = true; + } else if ((nextActivity[id] ?? 0) <= now && id in nextActivity) { delete nextActivity[id]; + changed = true; } } - effectActivityUntil = nextActivity; + if (changed) effectActivityUntil = nextActivity; }; const applySnapshot = (next: AppSnapshot) => { @@ -800,22 +818,24 @@ const inputBridgeBindingProfileId = () => inputBridgeBindingProfileIdForWorkspace(profileWorkspace); - $: buttonMappingSession = createButtonMappingSession({ - state: buttonMappingSessionState, - store: buttonMappingSessionStore, - active: buttonMappingActive, - controller, - controllerHeaderName, - selectedTuningScope, - steamContextGame, - steamInputStatus, - inputBridgeStatus, - activeProfileName, - profileContextGameName: profileContextGame?.name ?? null, - bridgeProfileId: inputBridgeBindingProfileId(), - refresh, - notify: showToast - }); + $: buttonMappingSession = buttonMappingActive + ? createButtonMappingSession({ + state: buttonMappingSessionState, + store: buttonMappingSessionStore, + active: buttonMappingActive, + controller, + controllerHeaderName, + selectedTuningScope, + steamContextGame, + steamInputStatus, + inputBridgeStatus, + activeProfileName, + profileContextGameName: profileContextGame?.name ?? null, + bridgeProfileId: inputBridgeBindingProfileId(), + refresh, + notify: showToast + }) + : EMPTY_BUTTON_MAPPING_VIEW_SESSION; const setTriggerRangeValue = (side: TriggerSide, edge: TriggerRangeEdge, rawValue: number | string) => { if (side === 'l2') { From 5a6cf9197b0801c57bbcb50fd070fae7ec329eb2 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:34:30 -0500 Subject: [PATCH 10/17] =?UTF-8?q?ui-review:=20copy=20law=20=E2=80=94=20HID?= =?UTF-8?q?=20out=20of=20user=20copy,=20legacy=20identifiers=20renamed,=20?= =?UTF-8?q?audit=20catches=20camelCase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- crates/dscc-agent/src/agent_types.rs | 2 +- crates/dscc-agent/src/config_model.rs | 2 +- crates/dscc-agent/src/tests/effects/manual_tests.rs | 2 +- web/scripts/source-audit.mjs | 2 +- web/scripts/visual-smoke.mjs | 4 ++-- web/src/App.svelte | 2 +- web/src/components/OnboardingTutorial.svelte | 2 +- web/src/components/SupportPanel.svelte | 2 +- web/src/lib/features/tuning/SetupGuide.svelte | 6 +++--- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/dscc-agent/src/agent_types.rs b/crates/dscc-agent/src/agent_types.rs index 54cfe38..cc97a7f 100644 --- a/crates/dscc-agent/src/agent_types.rs +++ b/crates/dscc-agent/src/agent_types.rs @@ -345,7 +345,7 @@ pub(crate) fn default_l2_trigger_curve_points() -> Vec { ] } -pub(crate) fn legacy_soft_l2_trigger_curve_points() -> Vec { +pub(crate) fn soft_l2_trigger_curve_points() -> Vec { vec![ TriggerCurvePoint { input: 0, diff --git a/crates/dscc-agent/src/config_model.rs b/crates/dscc-agent/src/config_model.rs index 9f775b2..e06b1d2 100644 --- a/crates/dscc-agent/src/config_model.rs +++ b/crates/dscc-agent/src/config_model.rs @@ -192,7 +192,7 @@ impl TriggerConfig { self.l2_curve = self.l2_curve.normalized(); self.r2_curve = self.r2_curve.normalized(); self.l2_curve_points = normalize_trigger_curve_points(self.l2_curve_points, self.l2_curve); - if self.l2_curve_points == legacy_soft_l2_trigger_curve_points() { + if self.l2_curve_points == soft_l2_trigger_curve_points() { self.l2_curve = TriggerCurve::default_l2(); self.l2_curve_points = default_l2_trigger_curve_points(); } diff --git a/crates/dscc-agent/src/tests/effects/manual_tests.rs b/crates/dscc-agent/src/tests/effects/manual_tests.rs index 84947d7..18b7f70 100644 --- a/crates/dscc-agent/src/tests/effects/manual_tests.rs +++ b/crates/dscc-agent/src/tests/effects/manual_tests.rs @@ -159,7 +159,7 @@ fn trigger_config_derives_point_arrays_from_ratio_curves() { fn trigger_config_migrates_previous_soft_default_brake_curve() { let trigger = TriggerConfig { l2_curve: TriggerCurve::from_ratio(1.45), - l2_curve_points: legacy_soft_l2_trigger_curve_points(), + l2_curve_points: soft_l2_trigger_curve_points(), ..TriggerConfig::default() } .normalized(); diff --git a/web/scripts/source-audit.mjs b/web/scripts/source-audit.mjs index 4a47942..ecabd85 100644 --- a/web/scripts/source-audit.mjs +++ b/web/scripts/source-audit.mjs @@ -73,7 +73,7 @@ const rules = [ }, { name: 'legacy production surface', - pattern: /\blegacy\b/i, + pattern: /legacy/i, allow: [selfAuditScript] } ]; diff --git a/web/scripts/visual-smoke.mjs b/web/scripts/visual-smoke.mjs index b8d4533..732541e 100644 --- a/web/scripts/visual-smoke.mjs +++ b/web/scripts/visual-smoke.mjs @@ -20,7 +20,7 @@ const routeChecks = [ { hash: '#/advanced/button-mapping', pattern: /Button Mapping|Default mirror/i } ]; // Old routes keep working forever; each lands on the new home for its content. -const legacyRedirectChecks = [{ from: '#/games', to: '#/tuning' }]; +const retiredRouteRedirectChecks = [{ from: '#/games', to: '#/tuning' }]; const viewports = [ { width: 1366, height: 768 }, { width: 1440, height: 900 }, @@ -141,7 +141,7 @@ async function main() { } } - for (const redirect of legacyRedirectChecks) { + for (const redirect of retiredRouteRedirectChecks) { await page.goto(`${baseUrl}/${redirect.from}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); const finalHash = await page.evaluate(() => location.hash); diff --git a/web/src/App.svelte b/web/src/App.svelte index c139ddb..df2c4a5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1178,7 +1178,7 @@ }); }; $: { - // Touched (not used) so the legacy compiler re-runs this block when they change. + // Touched (not used) so the reactive compiler re-runs this block when they change. void profileDraftSnapshot; void profileSaveBaselineConfig; void selectedTuningScope; diff --git a/web/src/components/OnboardingTutorial.svelte b/web/src/components/OnboardingTutorial.svelte index 9ceceec..a3dac1f 100644 --- a/web/src/components/OnboardingTutorial.svelte +++ b/web/src/components/OnboardingTutorial.svelte @@ -53,7 +53,7 @@ { eyebrow: 'Get help', title: 'Support bundles stay sanitized', - body: 'The Support panel copies a diagnostic bundle that leaves out raw HID paths, serials, Bluetooth addresses, and private Steam account paths.', + body: 'The Support panel copies a diagnostic bundle that leaves out controller hardware identifiers, serial numbers, Bluetooth addresses, and private Steam account paths.', actionLabel: 'Stay here', icon: LifeBuoy } diff --git a/web/src/components/SupportPanel.svelte b/web/src/components/SupportPanel.svelte index 50213a3..0b2f66b 100644 --- a/web/src/components/SupportPanel.svelte +++ b/web/src/components/SupportPanel.svelte @@ -17,7 +17,7 @@
Support Diagnostics Sanitized support bundle -

No raw HID paths, raw hardware IDs, serial numbers, or Bluetooth addresses are included.

+

No controller hardware identifiers, serial numbers, or Bluetooth addresses are included.

diff --git a/web/src/lib/features/tuning/SetupGuide.svelte b/web/src/lib/features/tuning/SetupGuide.svelte index 2b2ae8d..913b287 100644 --- a/web/src/lib/features/tuning/SetupGuide.svelte +++ b/web/src/lib/features/tuning/SetupGuide.svelte @@ -48,7 +48,7 @@ let copyFailed = $state(false); let copyResetTimer = 0; - const legacyCopy = (value: string): boolean => { + const fallbackCopy = (value: string): boolean => { const area = document.createElement('textarea'); area.value = value; area.setAttribute('readonly', ''); @@ -73,10 +73,10 @@ await navigator.clipboard.writeText(value); ok = true; } else { - ok = legacyCopy(value); + ok = fallbackCopy(value); } } catch { - ok = legacyCopy(value); + ok = fallbackCopy(value); } copiedValue = ok ? value : ''; copyFailed = !ok; From e243f5dfabc8714d6f7770124bf962d5df1fe7d9 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 01:39:41 -0500 Subject: [PATCH 11/17] ui-review: restore migration semantics in curve fn name; align redirect-check naming Co-Authored-By: Claude Fable 5 --- crates/dscc-agent/src/agent_types.rs | 2 +- crates/dscc-agent/src/config_model.rs | 2 +- crates/dscc-agent/src/tests/effects/manual_tests.rs | 2 +- web/scripts/visual-smoke.mjs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/dscc-agent/src/agent_types.rs b/crates/dscc-agent/src/agent_types.rs index cc97a7f..59dc1df 100644 --- a/crates/dscc-agent/src/agent_types.rs +++ b/crates/dscc-agent/src/agent_types.rs @@ -345,7 +345,7 @@ pub(crate) fn default_l2_trigger_curve_points() -> Vec { ] } -pub(crate) fn soft_l2_trigger_curve_points() -> Vec { +pub(crate) fn previous_soft_l2_trigger_curve_points() -> Vec { vec![ TriggerCurvePoint { input: 0, diff --git a/crates/dscc-agent/src/config_model.rs b/crates/dscc-agent/src/config_model.rs index e06b1d2..b42b445 100644 --- a/crates/dscc-agent/src/config_model.rs +++ b/crates/dscc-agent/src/config_model.rs @@ -192,7 +192,7 @@ impl TriggerConfig { self.l2_curve = self.l2_curve.normalized(); self.r2_curve = self.r2_curve.normalized(); self.l2_curve_points = normalize_trigger_curve_points(self.l2_curve_points, self.l2_curve); - if self.l2_curve_points == soft_l2_trigger_curve_points() { + if self.l2_curve_points == previous_soft_l2_trigger_curve_points() { self.l2_curve = TriggerCurve::default_l2(); self.l2_curve_points = default_l2_trigger_curve_points(); } diff --git a/crates/dscc-agent/src/tests/effects/manual_tests.rs b/crates/dscc-agent/src/tests/effects/manual_tests.rs index 18b7f70..92eed5c 100644 --- a/crates/dscc-agent/src/tests/effects/manual_tests.rs +++ b/crates/dscc-agent/src/tests/effects/manual_tests.rs @@ -159,7 +159,7 @@ fn trigger_config_derives_point_arrays_from_ratio_curves() { fn trigger_config_migrates_previous_soft_default_brake_curve() { let trigger = TriggerConfig { l2_curve: TriggerCurve::from_ratio(1.45), - l2_curve_points: soft_l2_trigger_curve_points(), + l2_curve_points: previous_soft_l2_trigger_curve_points(), ..TriggerConfig::default() } .normalized(); diff --git a/web/scripts/visual-smoke.mjs b/web/scripts/visual-smoke.mjs index 732541e..dce587c 100644 --- a/web/scripts/visual-smoke.mjs +++ b/web/scripts/visual-smoke.mjs @@ -20,7 +20,7 @@ const routeChecks = [ { hash: '#/advanced/button-mapping', pattern: /Button Mapping|Default mirror/i } ]; // Old routes keep working forever; each lands on the new home for its content. -const retiredRouteRedirectChecks = [{ from: '#/games', to: '#/tuning' }]; +const oldRouteRedirectChecks = [{ from: '#/games', to: '#/tuning' }]; const viewports = [ { width: 1366, height: 768 }, { width: 1440, height: 900 }, @@ -141,7 +141,7 @@ async function main() { } } - for (const redirect of retiredRouteRedirectChecks) { + for (const redirect of oldRouteRedirectChecks) { await page.goto(`${baseUrl}/${redirect.from}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); const finalHash = await page.evaluate(() => location.hash); From 6622c9a9acc6c5020b5d1da8cd40c2d81a6d24ed Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 09:30:41 -0500 Subject: [PATCH 12/17] ui-review: self-host Inter/JetBrains Mono, drop Space Grotesk remnants and Google Fonts import Co-Authored-By: Claude Fable 5 --- web/package-lock.json | 22 ++++++++++++++++++++ web/package.json | 2 ++ web/src/components/InitialBadge.svelte | 4 ++-- web/src/components/Tooltip.svelte | 1 - web/src/lib/features/games/addGameDialog.css | 1 - web/src/main.ts | 2 ++ web/src/styles/app.css | 1 - web/src/styles/button-mapping/base.css | 1 - web/src/styles/haptics/routing.css | 1 - web/src/styles/tokens.css | 4 ++-- 10 files changed, 30 insertions(+), 9 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 7ac88e6..0d3ea05 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,8 @@ "version": "0.4.0", "license": "Apache-2.0", "devDependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@lucide/svelte": "^1.17.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.4", @@ -53,6 +55,26 @@ "tslib": "^2.4.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/web/package.json b/web/package.json index 4a6e2eb..6c69a22 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,8 @@ "test:visual-smoke": "node scripts/visual-smoke.mjs" }, "devDependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@lucide/svelte": "^1.17.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.4", diff --git a/web/src/components/InitialBadge.svelte b/web/src/components/InitialBadge.svelte index f216d03..e50552a 100644 --- a/web/src/components/InitialBadge.svelte +++ b/web/src/components/InitialBadge.svelte @@ -90,12 +90,12 @@ - + Date: Thu, 11 Jun 2026 09:37:12 -0500 Subject: [PATCH 13/17] ui-review: collapse utility toolbar at narrow widths, drop ambient bind-address readout Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 110 ++++++++++++++++++++---------------- web/src/styles/shell-v2.css | 47 ++++++++++++--- 2 files changed, 101 insertions(+), 56 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index df2c4a5..9683270 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -258,6 +258,7 @@ let applyMessage = ''; let appSettingsMessage = ''; let appSettingsBusy = false; + let toolbarOpen = false; let supportPanelOpen = false; let supportBundleBusy: SupportBundleBusy = ''; let supportBundleMessage = ''; @@ -2270,59 +2271,70 @@ -
- - +
-
-
- {systemReadoutTitle} -

{systemReadoutValue}{systemReadoutDetail}

+
+ + + +
+
+ {systemReadoutTitle} +

{systemReadoutValue}{systemReadoutDetail}

+
diff --git a/web/src/styles/shell-v2.css b/web/src/styles/shell-v2.css index 42526eb..f41a4f4 100644 --- a/web/src/styles/shell-v2.css +++ b/web/src/styles/shell-v2.css @@ -128,9 +128,8 @@ html { bind address, glyph override, system readout). */ .app-toolbar { display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 10px 16px; + flex-direction: column; + align-items: stretch; padding: 10px 12px; margin-bottom: 16px; background: var(--surface); @@ -138,10 +137,45 @@ html { border-radius: var(--radius-m); } +/* The toolbar is ambient context, not a destination: at narrow widths it + collapses to one quiet row so Status leads the screen. */ +.app-toolbar-disclosure { + display: none; + width: 100%; + padding: 4px 2px; + text-align: left; + font-size: 0.72rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--ink-muted); + background: none; + border: none; + cursor: pointer; +} + +.app-toolbar-disclosure:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 1px; +} + +.app-toolbar-items { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 16px; + min-width: 0; + flex: 1; +} + +@media (max-width: 759px) { + .app-toolbar { padding: 6px 12px; } + .app-toolbar-disclosure { display: block; } + .app-toolbar:not(.open) .app-toolbar-items { display: none; } + .app-toolbar.open .app-toolbar-items { padding-top: 6px; } +} + /* Each field is exactly two rows — label, then control — so every toolbar - item shares one label line and one control line. The optional caption - (e.g. the bind address) sits inline beside the control instead of below - it, keeping the row's baseline tidy. */ + item shares one label line and one control line. */ .app-toolbar-field { display: grid; grid-template-columns: auto minmax(0, auto); @@ -171,7 +205,6 @@ html { border-radius: var(--radius-s); } -.app-toolbar-field small, .app-toolbar-readout small { color: var(--ink-muted); font-size: 0.72rem; From 5dea2f60c3776aa8f332615c47bb3cc8e63872e0 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Thu, 11 Jun 2026 09:41:54 -0500 Subject: [PATCH 14/17] ui-review: wire aria-controls on the toolbar disclosure --- web/src/App.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 9683270..b1d8c2e 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2276,13 +2276,14 @@ class="app-toolbar-disclosure" type="button" aria-expanded={toolbarOpen} + aria-controls="app-toolbar-items" onclick={() => { toolbarOpen = !toolbarOpen; }} > Controller & display options -
+