From 8bf5d08b47b073dddd0c28d2e4ade29730cf6bb6 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 18:35:16 -0500 Subject: [PATCH 01/23] docs: UI rework spec, implementation plan, and product brief Design exploration artifacts for the Status/Tuning/Advanced rework: spec, 10-task plan, PRODUCT.md register, and ignore the local .superpowers scratch directory. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + PRODUCT.md | 67 +++ .../superpowers/plans/2026-06-10-ui-rework.md | 409 ++++++++++++++++++ .../specs/2026-06-10-ui-rework-design.md | 206 +++++++++ 4 files changed, 683 insertions(+) create mode 100644 PRODUCT.md create mode 100644 docs/superpowers/plans/2026-06-10-ui-rework.md create mode 100644 docs/superpowers/specs/2026-06-10-ui-rework-design.md diff --git a/.gitignore b/.gitignore index 6539b17..be86e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,4 @@ pnpm-debug.log* # Upstream research clone kept out of this clean-room implementation repo. /Forza-Horizon-DualSense-Python/ +.superpowers/ diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..12b551b --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,67 @@ +# Product + +## Register + +product + +## Users + +Non-technical Windows gamers ("No Python, no scripts, no command line") who own a +PlayStation DualSense or DualSense Edge and play on PC — mostly racing games. +They open DSCC in two modes: a one-time **setup** moment (connect controller, +check it works) and a recurring **play** moment (pick a game, tune how it feels). +Between those moments the app should answer one question at a glance: +"is everything working?" + +They are not sim-rig tinkerers. Density, jargon, and diagnostics-first layouts +read as risk, not power. + +## Product Purpose + +DSCC changes how a DualSense controller feels on PC: adaptive trigger +resistance, rich haptics, lights, and live racing-game telemetry turned into +feel. Success looks like: a user connects a controller, starts a supported +game, and feels the difference — without ever wondering whether the app is +about to do something scary to their hardware. + +## Brand Personality + +Calm, trustworthy, capable. Quiet confidence: a well-made tool that writes to +real hardware and never makes that feel dangerous. Plain language over jargon +(domain terms come from CONTEXT.md and are law). Microcopy reassures rather +than performs. + +## Anchor References + +- **Linear** — soft dark surfaces, quiet hierarchy, restrained accent use, + speed as a feeling. +- **Raycast** — disciplined density, muted accents, exemplary settings panels. + +## Anti-references + +- The "gamer cockpit" HUD: grid textures, glows, glassmorphism, scanline + energy, all-caps telemetry labels everywhere. (This is what the current UI + does and what the rework moves away from.) +- RGB-gamer aesthetic (Razer/ROG software): loud, busy, salesy. +- Diagnostics-first layouts that lead with raw numbers instead of the task. + +## Design Principles + +1. **Status before controls.** Every screen answers "is everything OK?" before + it offers anything to change. +2. **The task is the navigation.** Lead with what the user is doing (set up my + controller, make my game feel great), not with tool categories. +3. **Advanced is opt-in.** Button mapping, calibration readouts, Edge slots, + and raw telemetry live behind a clear "Advanced" door — reachable, never + ambient. +4. **Safety is visible, not scary.** Always distinguish dry-run/test effects + from live Hardware Output, and Global Profile from an active Game Profile, + in plain words. +5. **Quiet surfaces, one voice.** One accent color doing real work (state, + selection, primary action), consistent component vocabulary on every screen. + +## Accessibility & Inclusion + +WCAG AA contrast (≥4.5:1 body text), full `prefers-reduced-motion` support, +keyboard navigable. The 390px mobile viewport is an enforced layout target via +the visual smoke test. diff --git a/docs/superpowers/plans/2026-06-10-ui-rework.md b/docs/superpowers/plans/2026-06-10-ui-rework.md new file mode 100644 index 0000000..93ebb23 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-ui-rework.md @@ -0,0 +1,409 @@ +# DSCC Web UI Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the DSCC web UI shell and routes into the Status / Tuning / Advanced architecture specified in `docs/superpowers/specs/2026-06-10-ui-rework-design.md`, with the Calm Console + PlayStation-blue visual system. + +**Architecture:** Svelte 5 + Vite SPA, hash routing, plain CSS design tokens. New slim sidebar shell replaces the HUD/ribbon; the existing feature components (haptics panels, button mapping, controllers detail) are re-housed, not rewritten. Behavior and API contracts unchanged except UX changes named in the spec. + +**Tech Stack:** Svelte 5 runes, plain CSS (no libs), `@lucide/svelte` icons, Playwright visual smoke harness. + +**Source-of-truth documents (read before starting any task):** +- Spec: `docs/superpowers/specs/2026-06-10-ui-rework-design.md` +- Domain language (law for all copy): `CONTEXT.md` +- Strategic context: `PRODUCT.md` +- Approved mockups (layout truth, HTML/CSS reference): `.superpowers/brainstorm/9273-1781129906/content/` — most relevant: `tuning-canvas-v9.html` (canvas + saved rail), `status-advanced-v2-psblue.html` (Status, Advanced, blue tokens), `game-setup.html` + `setup-reentry.html` (setup flow), `shell-hybrid.html` (game dropdown) +- App.svelte concern map: `docs/architecture-audit.md` + +**Ground rules for every task:** +- Branch: `ui-improvements` only. Never commit to `main`. +- Gates after each task: `cd web && npm run typecheck && npm run build`. Tasks that change routes/layout also run `npm run test:visual-smoke`. Final task runs the full `npm run check`. +- No `_Avoid_` terms from CONTEXT.md in any user-facing copy. "Everyday" label always pairs with "Global Profile". +- This is a UI rework: do not modify `web/src/lib/api/*`, `web/src/lib/mock/*`, or `web/src/lib/types.ts` (the artwork fields `artwork.bannerUrl` / `artwork.heroUrl` already exist at `web/src/lib/types.ts:145-162`). +- No Rust/backend changes; everything runs from `web/` with `npm run dev:mock`. + +--- + +### Task 1: New design tokens + +**Files:** +- Modify: `web/src/styles/tokens.css` (full replacement) + +- [ ] **Step 1: Replace tokens.css with the Calm Console + PS-blue system** + +```css +:root { + /* neutrals — calm console */ + --bg: #141417; + --bg-rail: #18181c; /* sidebar, header bars */ + --surface: #1d1d22; /* panels that need a bounded field */ + --surface-raised: #26262c; /* active nav item, chips, inputs */ + --hairline: #232329; + --hairline-strong: #2e2e36; + --ink: #d6d6dc; + --ink-muted: #8b8b96; /* secondary text only; not body-sized prose */ + + /* accent — PlayStation blue */ + --accent: #0070cc; /* primary buttons */ + --accent-bright: #1f8fff; /* slider fills, live lines, meters */ + --accent-text: #5db2ff; /* accent-colored text on dark (>=4.5:1) */ + --accent-tint: #12273d; /* edited/unsaved backgrounds */ + --accent-outline: #1d4f80; /* edited outlines */ + + /* semantic */ + --ok: #4ade80; + --ok-tint: #143620; + --warn: #facc15; + --danger: #f03e3e; + + /* shape & rhythm */ + --radius-s: 6px; + --radius-m: 8px; + --radius-l: 10px; + --speed: 180ms; + --ease: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-quint-ish */ + + /* layout contract (spec §6) */ + --curve-min: 280px; + --curve-max: 460px; + --slider-cap: 220px; + --saved-rail-w: 260px; + + /* z-scale: dropdown < sticky < backdrop < dialog < toast < tooltip */ + --z-dropdown: 10; + --z-sticky: 20; + --z-backdrop: 30; + --z-dialog: 40; + --z-toast: 50; + --z-tooltip: 60; + + color: var(--ink); + background: var(--bg); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +@media (prefers-reduced-motion: reduce) { + :root { --speed: 0ms; } +} +``` + +- [ ] **Step 2: Map legacy token names onto the new system so existing CSS keeps compiling** + +Append to `tokens.css` (removed again in Task 10 when legacy styles die): + +```css +/* TEMPORARY legacy aliases — delete in cleanup task */ +:root { + --obsidian: var(--bg); + --carbon: var(--bg-rail); + --actuation: var(--accent); + --haptic: var(--ink); + --tungsten: var(--ink-muted); + --overdrive: var(--danger); + --ready: var(--ok); + --matte: rgba(214, 214, 220, 0.045); + --line: rgba(139, 139, 150, 0.28); +} +``` + +- [ ] **Step 3: Gates** + +Run: `cd web && npm run typecheck && npm run build && npm run test:visual-smoke` +Expected: all pass (visuals shift color, structure unchanged). + +- [ ] **Step 4: Commit** — `git commit -m "ui-rework: calm-console + PS-blue design tokens with legacy aliases"` + +--- + +### Task 2: Navigation model — new views, old hashes redirect + +**Files:** +- Modify: `web/src/app/navigation.ts` (full replacement) +- Modify: `web/src/App.svelte` (only the places that consume `AppView` — see `docs/architecture-audit.md` for the routing region) +- Modify: `web/src/components/ViewNav.svelte` (consumes `appViews`; will be deleted in Task 3 — for now just keep it compiling) + +- [ ] **Step 1: Replace navigation.ts** + +```ts +export type AppView = 'status' | 'tuning' | 'advancedController' | 'advancedButtonMapping' | 'advancedEdgeSlots'; + +export type AppViewDefinition = { + id: AppView; + label: string; + hash: string; + group: 'main' | 'advanced'; +}; + +export type ViewReadiness = { + tuningReady: boolean; + buttonMappingReady: boolean; +}; + +export const appViews: AppViewDefinition[] = [ + { id: 'status', label: 'Status', hash: '#/status', group: 'main' }, + { id: 'tuning', label: 'Tuning', hash: '#/tuning', group: 'main' }, + { id: 'advancedController', label: 'Controller details', hash: '#/advanced/controller', group: 'advanced' }, + { id: 'advancedButtonMapping', label: 'Button mapping', hash: '#/advanced/button-mapping', group: 'advanced' }, + { id: 'advancedEdgeSlots', label: 'Edge onboard slots', hash: '#/advanced/edge-slots', group: 'advanced' } +]; + +export const viewTooltips: Record = { + status: 'Check that your controller, game detection, and telemetry are working.', + tuning: 'Tune trigger feel, rumble, and lights for a game or the Global Profile.', + advancedController: 'Live inputs, calibration readings, and connection details.', + advancedButtonMapping: 'Edit game/local-app mappings through Steam Input or DSCC Input Bridge.', + advancedEdgeSlots: 'Manage DualSense Edge onboard profile slots.' +}; + +/** Old routes keep working forever; they land on the new home for that content. */ +const legacyRedirects: Record = { + '#/games': '#/tuning', + '#/adaptive-triggers-haptics': '#/tuning', + '#/controllers': '#/advanced/controller', + '#/button-mapping': '#/advanced/button-mapping' +}; + +export function hashForView(view: AppView): string { + return appViews.find((item) => item.id === view)?.hash ?? '#/status'; +} + +export function guardView(view: AppView, readiness: ViewReadiness): AppView { + if (view === 'tuning' && !readiness.tuningReady) return 'status'; + if (view === 'advancedButtonMapping' && !readiness.buttonMappingReady) return 'status'; + return view; +} + +export function viewFromHash(rawHash: string, readiness: ViewReadiness): AppView { + const hash = legacyRedirects[rawHash] ?? rawHash; + const match = appViews.find((item) => item.hash === hash); + return guardView(match?.id ?? 'status', readiness); +} +``` + +- [ ] **Step 2: Fix all compile errors from the `AppView` union change** + +Run `npm run typecheck` and chase every error. In `App.svelte`, the old view ids map: `games`→`tuning` (scope strip moves in Task 5), `controllers`→`advancedController`, `haptics`→`tuning`, `buttonMapping`→`advancedButtonMapping`. Keep rendering the *existing* feature views for each new id (GamesView temporarily renders inside `tuning` alongside HapticsView gated by the same conditionals as today — Task 5 replaces this). + +- [ ] **Step 3: Gates** — typecheck + build pass. Visual smoke will FAIL on route hashes; that is expected until Task 3 Step 3. Do not run it yet. + +- [ ] **Step 4: Commit** — `git commit -m "ui-rework: status/tuning/advanced navigation model with legacy hash redirects"` + +--- + +### Task 3: Shell — sidebar replaces HUD + ribbon, smoke test retargeted + +**Files:** +- Create: `web/src/components/AppSidebar.svelte` +- Create: `web/src/styles/shell-v2.css` +- Modify: `web/src/App.svelte` (shell markup region), `web/src/styles/app.css` (import shell-v2 instead of shell/ribbon) +- Modify: `web/scripts/visual-smoke.mjs` (route list + text expectations) +- Delete (Task 10, not now): `ViewNav.svelte`, `ContextRibbon.svelte`, `shell.css`, `ribbon.css` + +- [ ] **Step 1: Build AppSidebar.svelte** + +Props: `view: AppView`, `readiness: ViewReadiness`, `onNavigate(view)`. Layout truth: sidebar in `status-advanced-v2-psblue.html`. Structure: + +```svelte + + + +``` + +- [ ] **Step 2: shell-v2.css** + +```css +.app-shell { display: flex; min-height: 100vh; background: var(--bg); } +.sidebar { + width: 168px; flex: 0 0 auto; background: var(--bg-rail); + display: flex; flex-direction: column; gap: 2px; padding: 14px 10px; + position: sticky; top: 0; height: 100vh; z-index: var(--z-sticky); +} +.sidebar-brand { font-weight: 600; padding: 6px 10px; } +.sidebar-item, .sidebar-group { + text-align: left; font: inherit; color: var(--ink-muted); background: none; border: 0; + padding: 7px 10px; border-radius: var(--radius-s); cursor: pointer; + transition: background var(--speed) var(--ease), color var(--speed) var(--ease); +} +.sidebar-item:hover { color: var(--ink); background: color-mix(in srgb, var(--surface-raised) 60%, transparent); } +.sidebar-item.active { color: var(--ink); background: var(--surface-raised); } +.sidebar-item:focus-visible, .sidebar-group:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 1px; } +.sidebar-group { font-size: 0.78rem; letter-spacing: 0.02em; text-transform: uppercase; margin-top: 12px; } +.sidebar-sub { padding-left: 20px; font-size: 0.92em; } +.sidebar-spacer { flex: 1; } +.app-main { flex: 1; min-width: 0; } + +@media (max-width: 760px) { + .app-shell { flex-direction: column; } + .sidebar { width: 100%; height: auto; position: sticky; flex-direction: row; flex-wrap: wrap; align-items: center; } + .sidebar-spacer { display: none; } +} +``` + +- [ ] **Step 3: Re-house App.svelte's shell** — replace `.ops-shell`/`.dm-hud`/ContextRibbon markup with `.app-shell > AppSidebar + .app-main`. Keep ToastStack, OnboardingTutorial, SupportPanel mounted (SupportPanel trigger moves into the sidebar footer slot). The controller/profile context the ribbon provided moves into the Tuning header (Task 5) and Status (Task 4); until those tasks, render the existing pickers in a plain `.app-main` toolbar so nothing is lost. + +- [ ] **Step 4: Retarget the smoke test** — in `web/scripts/visual-smoke.mjs`, update the route table: `#/status` (expect `/Status|Everything|controller/i`), `#/tuning` (expect `/Tuning|Profile/i`), `#/advanced/controller` (expect `/Controller details|Live input/i`), `#/advanced/button-mapping` (keep the existing `/Button Mapping|Default mirror/i` and read-only copy assertions). Also assert the legacy hashes redirect (navigate to `#/games`, expect final hash `#/tuning`). + +- [ ] **Step 5: Gates** — typecheck, build, visual-smoke all green at all 3 viewports. + +- [ ] **Step 6: Commit** — `git commit -m "ui-rework: sidebar shell, smoke test retargeted to new routes"` + +--- + +### Task 4: Status page + +**Files:** +- Create: `web/src/lib/features/status/StatusView.svelte` +- Create: `web/src/styles/status.css` (import from app.css) +- Modify: `web/src/App.svelte` (render StatusView for `view === 'status'`; make `#/status` the default route) + +Layout truth: Status block of `status-advanced-v2-psblue.html`. Compose entirely from state App.svelte already holds (controllers list, selected controller, runtime snapshot, profile resolution, telemetry freshness — see `docs/architecture-audit.md` for which module owns each; pass as props, do not re-fetch). + +- [ ] **Step 1: Build the four regions** + 1. Sentence of truth: dot (`--ok`/`--warn`/`--danger`) + "Everything is working." / "Something needs your attention." / "No controller connected yet." + one clause naming game + controller alias. + 2. CONTROLLER block: alias, family, transport, battery, charging; inline Rename (reuse existing rename action); empty state: "Plug in or pair a controller and it appears here." + 3. WHAT'S ACTIVE, AND WHY: rows for game detected / profile in use / telemetry / "when the game closes → back to Global Profile" — wording exactly as the mockup; uses Profile Resolution state. + 4. NEEDS ATTENTION: renders only real findings (telemetry quiet for a running game, controller disconnected mid-session, port conflicts surfaced by existing error state); when empty renders "Nothing else needs you." +- [ ] **Step 2: CSS** — groups are intrinsic-width flex children (`flex: 1 1 280px; max-width: 420px`), wrap per the layout contract; label style `.lbl` (10px caps muted) shared via status.css. +- [ ] **Step 3: Gates** — typecheck, build, visual-smoke (Status text assertions now hit real content). +- [ ] **Step 4: Commit** — `git commit -m "ui-rework: status page with sentence-of-truth and profile resolution"` + +--- + +### Task 5: Tuning canvas — header band, game dropdown, profile menu + +**Files:** +- Create: `web/src/lib/features/tuning/TuningHeader.svelte` (band + game dropdown + profile menu + telemetry chip) +- Create: `web/src/lib/features/tuning/gameSelect.ts` (pure: builds dropdown groups from games list + detection state — Running now / Everyday · Global Profile / Supported games / Setup guide / Add manually) +- Create: `web/src/styles/tuning.css` +- Modify: `web/src/App.svelte` (Tuning route renders TuningHeader + existing HapticsView content; GamesView's scope/selection logic migrates into gameSelect.ts; AddGameDialog opens from the dropdown's "+ Add a game manually…") + +Layout truth: header band of `tuning-canvas-v9.html`, dropdown of `shell-hybrid.html`, chip of `setup-reentry.html`. + +- [ ] **Step 1: Header band** — 80px, `background-image` from `artwork.heroUrl` with the double scrim (90° dark-left + 0° fade-to-bg); cover thumb from `artwork.bannerUrl` falls back to `InitialBadge`; Everyday/Global renders a flat `--bg-rail` band, no art. Title row: game name + dropdown caret + telemetry chip; second row: `Profile: ▾` + unsaved count (wire in Task 7). Right: controller alias + battery. +- [ ] **Step 2: Game dropdown** — native-feeling menu (positioned `fixed`, `--z-dropdown`), groups from `gameSelect.ts`, entries: detected game(s) with ● badge, "Everyday (no game) · Global Profile", supported games, divider, "Setup guide for …", "+ Add a game manually…" → existing AddGameDialog. Selecting an entry drives the same scope state GamesView drives today. +- [ ] **Step 3: Profile menu** — reuse `profileSelection.ts`/`profileManagement.ts` actions (switch, create, duplicate, import, export) in a compact menu under the profile label. GamesView and ProfileConsole stop being routed; leave files in place (deleted Task 10). +- [ ] **Step 4: Gates + eyes** — gates green; `npm run dev:mock`: dropdown groups correct, art band renders, Everyday band neutral. +- [ ] **Step 5: Commit** — `git commit -m "ui-rework: tuning header with game dropdown, profile menu, bespoke art band"` + +---### Task 6: Tuning canvas — semantic columns + +**Files:** +- Create: `web/src/lib/features/tuning/TuningCanvas.svelte` (the column grid; slots existing panels) +- Modify: `web/src/styles/tuning.css` +- Modify: `web/src/App.svelte` / `web/src/lib/features/haptics/*` wiring only (panels keep their internals: TriggerCurvesPanel splits visually into per-trigger columns via props if it doesn't already support single-trigger rendering — if it renders both triggers as one block, add a `trigger: 'L2' | 'R2'` prop rather than rewriting it) + +Layout truth + CSS contract: `tuning-canvas-v9.html` (`.canvas-grid`, `.col-trigger`, `.col-small`). + +- [ ] **Step 1: Grid CSS** + +```css +.canvas-grid { display: flex; flex-wrap: wrap; gap: 18px 22px; padding: 14px 16px 18px; flex: 1; min-width: 0; } +.col-trigger { flex: 1 1 300px; min-width: var(--curve-min); max-width: var(--curve-max); } +.col-small { flex: 1 1 220px; min-width: 210px; max-width: 320px; } +.canvas-grid .control-slider { max-width: var(--slider-cap); } +@media (max-width: 760px) { + .col-trigger, .col-small { max-width: none; flex-basis: 100%; } +} +``` + +- [ ] **Step 2: Column composition** — BRAKE · L2 (curve + ABS pulse + lockup effects), THROTTLE · R2 (curve + gear-shift kick + rev-limiter buzz), ROAD FEEL (road texture + surface detail + "More effects appear here for games that send them."), LIGHTS (LightbarControls + brightness + player LEDs). Map the existing Forza effect controls (`forzaEffectState.ts`, HapticsAside) into these groups; effect display names use the spec's plain-words labels. +- [ ] **Step 3: Gates + eyes** — gates green; verify wrap behavior at ~1280px (Road feel/Lights wrap under triggers) and 390px (stack), no horizontal overflow. +- [ ] **Step 4: Commit** — `git commit -m "ui-rework: semantic tuning columns (brake/throttle/road feel/lights)"` + +--- + +### Task 7: Saved rail + unsaved diff + +**Files:** +- Create: `web/src/lib/features/tuning/SavedRail.svelte` +- Create: `web/src/lib/features/tuning/savedDiff.ts` (pure: `(savedProfile, draft) => Array<{label, savedValue, currentValue, dirty}>`) +- Modify: `web/src/app/profileDraft.ts` consumers in App.svelte to feed savedDiff; `web/src/styles/tuning.css` + +Layout truth: saved rail of `tuning-canvas-v9.html` (sticky, outside `.canvas-grid`), mobile bar in the same file's `phone-note`. + +- [ ] **Step 1: savedDiff.ts** — derives rows from the active profile's saved values vs. the working draft (`profileDraft.ts` already models the draft; see `docs/architecture-audit.md`). Dirty rows carry both values. +- [ ] **Step 2: SavedRail.svelte** — `flex: 0 0 var(--saved-rail-w); position: sticky; top: 14px;` docked right of `.canvas-grid` inside a `.work-and-rail` flex wrapper; rows show `saved` muted, dirty rows `saved (strikethrough) → current (--accent-text)`; footer: Preview feel (existing dry-run/test action, caption "3s · nothing saved"), Save changes (`--accent`), Discard. Header band shows "· N unsaved changes" when N > 0. Curve editors render the saved curve as a dashed ghost path when dirty (TriggerCurvesPanel gets a `savedCurve` prop). +- [ ] **Step 3: <900px bar** — rail hides; a bottom-docked bar (position: fixed, `--z-sticky`) shows "N unsaved changes · Save · Discard", expanding to the rows on tap. +- [ ] **Step 4: Gates + eyes** — tweak a slider in dev:mock: count appears, rail diffs, Discard restores, Save persists (mock). Smoke green. +- [ ] **Step 5: Commit** — `git commit -m "ui-rework: sticky saved rail with tweak-vs-saved diff and mobile bar"` + +--- + +### Task 8: Per-game setup state + telemetry chip re-entry + +**Files:** +- Create: `web/src/lib/features/tuning/SetupGuide.svelte` +- Create: `web/src/lib/features/tuning/setupRequirements.ts` (pure: derives `{required, steps, verified}` per game from existing Game Module metadata + telemetry state; Forza-family games → Data Out steps with IP `127.0.0.1` / port from existing telemetry routing state; shared-memory games → `required: false`) +- Modify: `TuningHeader.svelte` (chip states + click), `web/src/App.svelte` (canvas renders SetupGuide when selected game unverified) + +Layout truth: `game-setup.html`, `setup-reentry.html`. The existing `TelemetryRoutingPanel.svelte` holds today's routing knowledge — mine it for state, then stop routing to it (its LAN settings move to the guide's "Stuck?" details). + +- [ ] **Step 1: setupRequirements.ts + persistence** — verification = telemetry packets seen for that game at least once (persist per game id in the same store the app already uses for UI prefs/onboarding state). Once verified, guide never auto-shows. +- [ ] **Step 2: SetupGuide.svelte** — numbered steps with done/now/todo states; copy buttons for IP/port (`navigator.clipboard.writeText`); LISTENING box bound to live telemetry state, flips to `--ok` automatically and swaps the canvas in (no click). Zero-setup variant: "No setup needed" + "Start tuning →" (pre-tune base feel). +- [ ] **Step 3: Chip re-entry** — chip variants: green "● TELEMETRY FRESH · setup ↗", neutral (no telemetry game), yellow "● TELEMETRY QUIET · fix ↗". Click toggles the guide over the canvas (canvas state, not modal); Status → Needs attention links to `#/tuning` with the guide open when quiet. +- [ ] **Step 4: Gates + eyes** — mock flow: select unverified Forza → guide; simulate packets (mock fixture) → canvas swaps in; chip reopens guide. Smoke green. +- [ ] **Step 5: Commit** — `git commit -m "ui-rework: per-game setup guide with passive verification and chip re-entry"` + +--- + +### Task 9: Advanced section + +**Files:** +- Modify: `web/src/App.svelte` (route the three advanced views), `web/src/lib/features/controllers/ControllersView.svelte` (restyle wrapper to canvas system; live input meters to `--accent-bright`; plain-words drift line; Support Bundle button joins this page), Edge slots UI extracted from ControllersView into the `advancedEdgeSlots` route if currently embedded +- Modify: `web/src/styles/controllers.css` → re-skin to tokens (structure may stay) +- Modify: button-mapping styles only where old tokens/HUD chrome leak through (`web/src/styles/button-mapping/*`); **all button-mapping copy and workflow unchanged** (smoke asserts it) + +- [ ] **Step 1: Controller details** — LIVE INPUT / CONNECTION / SUPPORT groups per `status-advanced-v2-psblue.html`; header line "Kyle's Edge · live readouts for checking, not for everyday tuning" pattern. +- [ ] **Step 2: Edge onboard slots route** — existing slots UI under `#/advanced/edge-slots`; hidden when the Target Controller isn't an Edge (sidebar item disabled with tooltip). +- [ ] **Step 3: Button mapping re-skin** — token sweep only. +- [ ] **Step 4: Gates** — typecheck, build, visual-smoke (button-mapping copy assertions prove behavior intact). +- [ ] **Step 5: Commit** — `git commit -m "ui-rework: advanced section (controller details, edge slots, button mapping reskin)"` + +--- + +### Task 10: Cleanup + full gate run + +**Files:** +- Delete: `web/src/components/ViewNav.svelte`, `web/src/components/ContextRibbon.svelte`, `web/src/lib/features/games/GamesView.svelte`, `web/src/lib/features/profiles/ProfileConsole.svelte` (confirm nothing imports them first), `web/src/styles/shell.css`, `web/src/styles/ribbon.css`, `web/src/styles/games.css`, `web/src/styles/games-catalog.css`, `web/src/styles/workspace.css` (audit each for still-used rules before deleting; move survivors into the new files) +- Modify: `web/src/styles/tokens.css` (delete the legacy alias block), `web/src/styles/app.css` (prune imports), `web/src/styles/responsive.css` (prune dead selectors) + +- [ ] **Step 1: Dead-code sweep** — `grep -rn "ContextRibbon\|ViewNav\|GamesView\|ProfileConsole\|--obsidian\|--carbon\|--actuation\|--haptic\b\|--tungsten\|--matte\|dm-hud\|ops-shell" web/src web/scripts` must return zero hits after deletions. +- [ ] **Step 2: Full gates** — `cd web && npm run check` (typecheck, source-audit, button-map, snapshot-map, haptics-graph, build, release-size, visual-smoke) — ALL green. +- [ ] **Step 3: Eyes-on pass** — `npm run dev:mock`: walk Status → Tuning (Everyday + Forza + zero-setup game) → setup guide → Advanced ×3, at desktop width and 390px. Check every spec §11 state. +- [ ] **Step 4: Commit** — `git commit -m "ui-rework: remove HUD-era shell, styles, and routed views"` + +--- + +## Self-review notes (already applied) + +- Spec §5 route map ↔ Task 2 redirects: consistent. +- Spec §12 safety copy lives in Tasks 5 (chip), 7 (rail/preview), 8 (guide). +- Smoke-test retarget happens in the same task that breaks old routes (Task 3) so the suite is never red across a commit boundary. +- TriggerCurvesPanel/`savedCurve` and `trigger` props are the only feature-component API changes; everything else is composition. +- Release-size budget (`test:release-size`) may need headroom if Steam hero images were bundled — they are NOT: art comes from existing `artwork.*Url` fields at runtime; no new assets ship. diff --git a/docs/superpowers/specs/2026-06-10-ui-rework-design.md b/docs/superpowers/specs/2026-06-10-ui-rework-design.md new file mode 100644 index 0000000..a828f83 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-ui-rework-design.md @@ -0,0 +1,206 @@ +# DSCC Web UI Rework — Design Brief + +**Date:** 2026-06-10 · **Branch:** `ui-improvements` · **Status:** awaiting approval, no implementation yet +**Inputs:** brainstorm + 12 browser-validated mockups (`.superpowers/brainstorm/9273-1781129906/content/`), PRODUCT.md, CONTEXT.md, impeccable skill (product register) + +## 1. Feature Summary + +Reimagine the DSCC web UI as a calm, task-oriented app for non-technical Windows +gamers. The four parallel tool routes become a three-destination shell — Status, +Tuning, Advanced — where the primary surface is a game-led tuning canvas and +power tools are demoted behind an Advanced door. Visual system moves from the +"gamer cockpit" HUD to a Calm Console aesthetic (Linear/Raycast lineage) with a +PlayStation-blue accent. + +## 2. Primary User Action + +Pick a game (or Everyday), tune how the controller feels, and always know two +things at a glance: *is everything working* and *what's saved vs. what I've +tweaked*. + +## 3. Design Direction + +- **Color strategy:** Restrained. Neutral dark surfaces; one accent doing real + work (actions, selection, live/edited state). +- **Scene sentence:** An evening PC gamer at a desk or couch in a dim room, + controller in hand, game about to launch — glancing at DSCC to confirm + everything works, then nudging feel. → Dark theme, low-glare, quiet. +- **Anchors:** Linear (soft dark surfaces, quiet hierarchy), Raycast + (disciplined density, settings craft). +- **Anti-references:** current HUD (grid textures, glows, glass), RGB-gamer + software, diagnostics-first layouts. + +## 4. Scope + +- **Fidelity:** production-ready (the mockups are direction; implementation is + real Svelte 5 + CSS, no component library). +- **Breadth:** whole web UI — shell, all routes, tokens. +- **Interactivity:** shipped-quality; behavior preserved except where this + brief intentionally changes UX. +- **Time intent:** iterate route-by-route on `ui-improvements`; nothing merges + until the gates pass and the user signs off in the running app. + +## 5. Information Architecture + +``` +Sidebar (slim, persistent; icon rail <760px) +├─ Status ← default route on launch +├─ Tuning ← game-led canvas (the heart of the app) +├─ ADVANCED ▸ ← collapsed group, expands in place +│ ├─ Controller details (live input, calibration, connection) +│ ├─ Button mapping (existing workflow, restyled only) +│ └─ Edge onboard slots +└─ ⚙ Settings +``` + +- **No Games page.** Game selection is a dropdown in the Tuning header: + Running now (detected) → Everyday (Global Profile) → Supported games → + "Setup guide for …" → "+ Add a game manually…". +- **Profiles live in the canvas**: profile selector under the game title; + create/duplicate/import/export inside that menu. +- Old routes `#/games`, `#/controllers`, `#/adaptive-triggers-haptics`, + `#/button-mapping` map to `#/status`, `#/tuning`, + `#/advanced/controller`, `#/advanced/button-mapping` (old hashes redirect). + +## 6. The Tuning Canvas (locked pattern, mockup v9) + +**Header band (per game, bespoke):** Steam `library_hero` behind a slim ~80px +band, double-scrimmed into the page bg; `library_600x900` cover thumbnail +anchors the game dropdown; profile selector + unsaved-changes count beneath the +title; controller name/battery right-aligned; clickable telemetry status chip. +Everyday (Global Profile) uses a neutral band, no game art. + +**Working surface — semantic columns, not control-type rows:** +- **Brake · L2** — trigger curve editor + brake effects (ABS pulse, lockup rumble) +- **Throttle · R2** — trigger curve editor + throttle effects (gear-shift kick, + rev-limiter buzz) +- **Road feel** — road texture rumble, surface detail; game-provided effects + appear here +- **Lights** — lightbar mode/RPM colors, brightness, player LEDs + +**Saved rail (furniture):** fixed ~260px panel docked right, sticky on scroll, +excluded from wrapping. Shows the active profile's saved values; edited rows +render `saved → current` with strikethrough; curve editors echo with a dashed +saved-curve ghost. Contains Preview feel ("3s · nothing saved") above +Save changes / Discard. Below ~900px it becomes a docked bottom bar +("2 unsaved changes · Save · Discard", expands to the list on tap). + +**Layout contract (the no-wasted-space law):** +- Controls have intrinsic sizes; extra width adds columns or grows + *instruments only* — never stretches sliders/rows. +- Curve editors: flex 280→460px (instruments earn size). Sliders: ~220px cap. +- Columns flex-wrap: 5-across on wide, Road feel/Lights wrap under + Brake/Throttle near 720p widths, single column <760px. +- Saved rail is outside the wrap container at all times (until the <900px bar). +- Smoke test enforces no horizontal overflow at 390px. + +## 7. Status (default route) + +1. **One sentence of truth:** green/yellow/red dot + "Everything is working." + + one plain-language clause naming game and controller. +2. **Controller** block: alias, family, connection/transport, battery; Rename + inline; hint line for adding another controller. +3. **What's active, and why:** Profile Resolution as plain-words rows — game + detected, profile in use, telemetry freshness, "when the game closes → + back to Global Profile". +4. **Needs attention:** the only home for warnings; states "Nothing else needs + you" when empty. Links into game setup if telemetry goes quiet. + +## 8. Per-Game Setup (canvas state, not a pop-up) + +- Each Game Module declares its requirements. Selecting an unverified game + renders the walkthrough *in the canvas*; once verified, the canvas shows + tuning controls permanently. +- Forza-style flow: numbered steps (a real sequence): ① game found + (auto-verified) ② enable Data Out with exact menu path + copy buttons for + IP/port ③ drive. A "Listening on port 5300…" box flips green passively when + packets arrive — completion requires zero clicks. +- Zero-setup games (Assetto Corsa Rally): "No setup needed" reassurance + + offer to pre-tune with base feel. +- **Re-entry:** the header telemetry chip ("● TELEMETRY FRESH · setup ↗") opens + the guide anytime; turns yellow and deep-links to the fix when telemetry is + quiet. Fallback entry in the game dropdown. Telemetry loss never yanks the + canvas — it flags Status → Needs attention. + +## 9. Advanced + +Sidebar group expands in place. Controller details = live input meters, +connection facts (transport, input path, polling, firmware), stick-drift +plain-words readout, Support Bundle download. Framed as "for checking, not for +everyday tuning." Button mapping keeps its current workflow and copy +(read-only mirror messaging preserved for the smoke test), restyled to the new +tokens. Edge onboard slots move here. + +## 10. Visual System (tokens.css rewrite) + +Neutrals (Calm Console): bg `#141417` · sidebar/header `#18181c` · surface +`#1d1d22` · raised `#26262c` · hairline `#232329` / `#2e2e36` · ink `#d6d6dc` +· muted `#8b8b96` (large/secondary text only; verify 4.5:1 where body-sized). + +Accent (PlayStation blue): `--accent #0070CC` (primary buttons) · +`--accent-bright #1f8fff` (slider fills, live lines, meters) · +`--accent-text #5db2ff` (accent text on dark, ≥4.5:1) · `--accent-tint #12273d` +(edited/unsaved bg) · `--accent-outline #1d4f80` (edited outlines). +Semantic: green = working, yellow = attention, red = errors only. +Final values to be expressed in OKLCH with contrast verified at implementation. + +Type: Inter only (drop Space Grotesk display pairing); fixed rem scale, ratio +~1.2; JetBrains Mono retained solely for literal values (ports, IPs). +Surfaces: flat fills + hairlines; no glassmorphism, grid textures, glows, or +side-stripe borders. Radii ~6–10px. Motion: 150–250ms ease-out state +transitions only; full `prefers-reduced-motion` support. + +## 11. Key States + +- Tuning: setup-needed (walkthrough) · waiting-for-detection · active-clean · + active-with-unsaved (count in header, diff in rail, ghost curves) · + Everyday/Global (neutral band) · no-controller (point to Status). +- Status: all-good · attention (yellow) · no-controller-yet (first-run: + connect instructions) · telemetry-quiet. +- Saved rail: clean (values only) · dirty (diffs + actions) · mobile bar. +- Empty states teach ("Plug in or pair a controller and it appears here"). + +## 12. Safety Framing (constraint #4) + +Preview feel is always labeled "Nothing is saved"; unsaved drift is always +visible (header count + rail diff); plain-words distinction between what's +saved, what the controller is using right now, and what happens on +Save/Discard. Global vs Game Profile state is always named in the header and +on Status. + +## 13. Constraints & Gates + +- Domain language from CONTEXT.md is law; no _Avoid_ terms in copy. + "Everyday" is a presentation label only and always appears with its domain + term ("Everyday · Global Profile"); the term of record in copy, code, and + docs remains Global Profile. +- Mock/real API contracts unchanged (`web/src/lib/mock/`, `web/src/lib/api/`). + Steam art assets come via existing game artwork fields; if hero/cover grades + are missing for a title, fall back to a neutral band + InitialBadge. +- Behavior preserved except UX changes specified here. +- Gates before any "done": `npm run typecheck`, `npm run build`, + `npm run test:visual-smoke` (route-text assertions will need updating to the + new IA in the same change), `npm run test:source-audit`, plus eyes-on + `dev:mock` at desktop and 390px. +- Coordinate with the architecture-decomposition effort: this rework + effectively decomposes App.svelte's route shells; note overlaps in PRs. + +## 14. Implementation Shape (route-by-route, each step green) + +1. Tokens + shell: new tokens.css, sidebar, hash redirects (old routes still + render existing views inside the new shell). +2. Status page (new). +3. Tuning canvas: header band + game dropdown + profile menu; semantic + columns wrapping existing trigger/haptics controls; saved rail. +4. Per-game setup state + telemetry chip re-entry. +5. Advanced: move controllers detail + button mapping + Edge slots; restyle. +6. Delete dead styles (HUD, ribbon, games grid); update smoke-test + expectations; full gate run. + +## 15. Open Questions (deliberately few) + +- Sidebar at 390px: icon rail vs. bottom tab bar — decide in implementation + against the smoke test; mockups assume collapse, either satisfies the + contract. +- Onboarding tutorial and LAN settings panel: keep current behavior, restyle + only; revisit copy in a later pass. From b35abf8f761df95c42223d629bc4654d9b362ac6 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 18:37:21 -0500 Subject: [PATCH 02/23] ui-rework: calm-console + PS-blue design tokens with legacy aliases Co-Authored-By: Claude Fable 5 --- web/src/styles/tokens.css | 79 +++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index e69f7de..e5d41c5 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -1,16 +1,73 @@ :root { - --obsidian: #0A0A0C; - --carbon: #121214; - --actuation: #0070CC; - --haptic: #E2E8F0; - --tungsten: #71717A; - --overdrive: #F03E3E; - --ready: #22C55E; - --matte: rgba(226, 232, 240, 0.045); - --line: rgba(113, 113, 122, 0.28); - color: var(--haptic); - background: var(--obsidian); + /* neutrals — calm console */ + --bg: #141417; + --bg-rail: #18181c; /* sidebar, header bars */ + --surface: #1d1d22; /* panels that need a bounded field */ + --surface-raised: #26262c; /* active nav item, chips, inputs */ + --hairline: #232329; + --hairline-strong: #2e2e36; + --ink: #d6d6dc; + --ink-muted: #8b8b96; /* secondary text only; not body-sized prose */ + + /* accent — PlayStation blue */ + --accent: #0070cc; /* primary buttons */ + --accent-bright: #1f8fff; /* slider fills, live lines, meters */ + --accent-text: #5db2ff; /* accent-colored text on dark (>=4.5:1) */ + --accent-tint: #12273d; /* edited/unsaved backgrounds */ + --accent-outline: #1d4f80; /* edited outlines */ + + /* semantic */ + --ok: #4ade80; + --ok-tint: #143620; + --warn: #facc15; + --danger: #f03e3e; + + /* shape & rhythm */ + --radius-s: 6px; + --radius-m: 8px; + --radius-l: 10px; + --speed: 180ms; + --ease: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-quint-ish */ + + /* layout contract (spec §6) */ + --curve-min: 280px; + --curve-max: 460px; + --slider-cap: 220px; + --saved-rail-w: 260px; + + /* z-scale: dropdown < sticky < backdrop < dialog < toast < tooltip */ + --z-dropdown: 10; + --z-sticky: 20; + --z-backdrop: 30; + --z-dialog: 40; + --z-toast: 50; + --z-tooltip: 60; + + color: var(--ink); + background: var(--bg); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; } + +@media (prefers-reduced-motion: reduce) { + :root { --speed: 0ms; } +} + +/* TEMPORARY legacy aliases — delete in cleanup task */ +:root { + --obsidian: var(--bg); + --carbon: var(--bg-rail); + --actuation: var(--accent); + --haptic: var(--ink); + --tungsten: var(--ink-muted); + --overdrive: var(--danger); + --ready: var(--ok); + --matte: rgba(214, 214, 220, 0.045); + --line: rgba(139, 139, 150, 0.28); + --muted: var(--ink-muted); + --text: var(--ink); + --ash: var(--ink-muted); + --mono: "Courier New", monospace; + --border-strong: var(--hairline-strong); +} From ae302fe243a1daf668d01ab03131bbc83e6ddd68 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 18:44:46 -0500 Subject: [PATCH 03/23] ui-rework: annotate legacy token block for safe cleanup Co-Authored-By: Claude Fable 5 --- web/src/styles/tokens.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index e5d41c5..158a467 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -65,6 +65,10 @@ --ready: var(--ok); --matte: rgba(214, 214, 220, 0.045); --line: rgba(139, 139, 150, 0.28); + + /* previously-undefined vars referenced by legacy CSS — verify zero usages + before deleting; --mono is a real value (not an alias) and must move to + the main block if monospace text survives the rework */ --muted: var(--ink-muted); --text: var(--ink); --ash: var(--ink-muted); From d74ba83f0de931faf20e371db640bb26e58b8dd0 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 18:50:14 -0500 Subject: [PATCH 04/23] ui-rework: status/tuning/advanced navigation model with legacy hash redirects Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 46 +++++++++++++------- web/src/app/navigation.ts | 43 +++++++++++------- web/src/components/OnboardingTutorial.svelte | 12 ++--- web/src/components/ViewNav.svelte | 9 ++-- 4 files changed, 69 insertions(+), 41 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index e5366bc..0bcb369 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -317,7 +317,7 @@ let curveDragSide: TriggerSide | null = null; let curveDragPoint: CurveDragPoint | null = null; let triggerCurveDisplayMode: TriggerCurveDisplayMode = 'base'; - let activeView: AppView = 'games'; + let activeView: AppView = 'status'; let triggerEffect = 'Adaptive resistance'; let triggerIntensity = 'Strong (Standard)'; let vibrationIntensity = 'Medium'; @@ -498,7 +498,15 @@ $: activeProfileHeader = profileWorkspace.activeProfileHeader; $: activeProfileHeaderName = profileWorkspace.activeProfileHeaderName; $: activeProfileHeaderMeta = profileWorkspace.activeProfileHeaderMeta; - $: buttonMappingActive = activeView === 'buttonMapping'; + $: buttonMappingActive = activeView === 'advancedButtonMapping'; + // Temporary view composition (Tasks 3-5 replace this): 'status' renders the old + // default (GamesView) so the app is never blank; 'tuning' renders GamesView plus + // the haptics workspace; 'advancedEdgeSlots' renders ControllersView because the + // Edge onboard slots UI currently lives inside it. + $: showAdvancedControllerView = activeView === 'advancedController' || activeView === 'advancedEdgeSlots'; + $: showGamesView = activeView === 'status' || activeView === 'tuning' || (!tuningReady && !showAdvancedControllerView); + $: showWorkspaceViews = + showAdvancedControllerView || (tuningReady && (activeView === 'tuning' || activeView === 'advancedButtonMapping')); $: steamInputStatus = snapshot?.steamInput; $: inputBridgeStatus = snapshot?.inputBridge; $: telemetryPacketRate = adapter?.packetRateHz ?? 0; @@ -632,7 +640,7 @@ }; const appViewFromHash = (): AppView => { - if (typeof window === 'undefined') return 'games'; + if (typeof window === 'undefined') return 'status'; return viewFromHash(window.location.hash, { tuningReady, buttonMappingReady }); }; @@ -916,8 +924,8 @@ selectedTuningGameId = ''; const profileId = globalTuningProfileSelection(profiles, activeProfileId); selectedOverrideProfileId = profileId; - activeView = 'haptics'; - setViewHash('haptics'); + activeView = 'tuning'; + setViewHash('tuning'); if (profileId) await selectProfileForScope(profileId, null, 'Global Profile'); }; @@ -931,8 +939,8 @@ currentControllerConfig }); if (preferredProfileId) selectedOverrideProfileId = preferredProfileId; - activeView = 'haptics'; - setViewHash('haptics'); + activeView = 'tuning'; + setViewHash('tuning'); if (preferredProfileId) await selectProfileForScope(preferredProfileId, game.gameId, game.name); }; @@ -1727,7 +1735,7 @@ function shouldPollTriggerInput() { return Boolean( controller?.id && - activeView === 'haptics' && + activeView === 'tuning' && typeof window !== 'undefined' && typeof document !== 'undefined' && !document.hidden @@ -1740,9 +1748,14 @@ $: if ( typeof window !== 'undefined' && - (window.location.hash === '#/controllers' || + (window.location.hash === '#/tuning' || + window.location.hash === '#/advanced/controller' || + window.location.hash === '#/advanced/button-mapping' || + window.location.hash === '#/advanced/edge-slots' || + window.location.hash === '#/controllers' || window.location.hash === '#/adaptive-triggers-haptics' || - window.location.hash === '#/button-mapping') + window.location.hash === '#/button-mapping' || + window.location.hash === '#/games') ) { const routeView = appViewFromHash(); if (routeView !== activeView) { @@ -1957,7 +1970,7 @@ // Live trigger polling feeds the haptics curve cursor and the base-feel test. // It is intentionally limited to the visible Haptics view so inactive routes // do not spend the 25Hz input budget or trigger extra DOM work. - $: if (controller?.id && activeView === 'haptics') { + $: if (controller?.id && activeView === 'tuning') { startTriggerInputPolling(); } else { stopTriggerInputPolling(); @@ -2081,7 +2094,7 @@ onNavigate={navigateToView} /> - {#if activeView === 'games' || (!tuningReady && activeView !== 'controllers')} + {#if showGamesView} - {:else} + {/if} + {#if showWorkspaceViews} - {#if activeView === 'controllers'} + {#if showAdvancedControllerView} {/if} - {#if activeView === 'haptics'} + {#if activeView === 'tuning' && tuningReady} = { - games: 'Choose Global Profile or a supported game scope for tuning.', - controllers: 'View controller details, live inputs, calibration readings, and DualSense Edge onboard slots.', - haptics: 'Tune L2/R2 curves, manual trigger tests, body haptics, lightbar colors, and telemetry routes.', - buttonMapping: 'Edit game/local-app mappings through Steam Input or DSCC Input Bridge.' + status: 'Check that your controller, game detection, and telemetry are working.', + tuning: 'Tune trigger feel, rumble, and lights for a game or the Global Profile.', + advancedController: 'Live inputs, calibration readings, and connection details.', + advancedButtonMapping: 'Edit game/local-app mappings through Steam Input or DSCC Input Bridge.', + advancedEdgeSlots: 'Manage DualSense Edge onboard profile slots.' +}; + +/** Old routes keep working forever; they land on the new home for that content. */ +const legacyRedirects: Record = { + '#/games': '#/tuning', + '#/adaptive-triggers-haptics': '#/tuning', + '#/controllers': '#/advanced/controller', + '#/button-mapping': '#/advanced/button-mapping' }; export function hashForView(view: AppView): string { - return appViews.find((item) => item.id === view)?.hash ?? appViews[0].hash; + return appViews.find((item) => item.id === view)?.hash ?? '#/status'; } export function guardView(view: AppView, readiness: ViewReadiness): AppView { - if (view === 'haptics' && !readiness.tuningReady) return 'games'; + if (view === 'tuning' && !readiness.tuningReady) return 'status'; + if (view === 'advancedButtonMapping' && !readiness.buttonMappingReady) return 'status'; return view; } -export function viewFromHash(hash: string, readiness: ViewReadiness): AppView { - if (hash === '#/controllers') return 'controllers'; - if (hash === '#/button-mapping') return guardView('buttonMapping', readiness); - if (hash === '#/adaptive-triggers-haptics') return guardView('haptics', readiness); - return 'games'; +export function viewFromHash(rawHash: string, readiness: ViewReadiness): AppView { + const hash = legacyRedirects[rawHash] ?? rawHash; + const match = appViews.find((item) => item.hash === hash); + return guardView(match?.id ?? 'status', readiness); } diff --git a/web/src/components/OnboardingTutorial.svelte b/web/src/components/OnboardingTutorial.svelte index 9c60478..b29708c 100644 --- a/web/src/components/OnboardingTutorial.svelte +++ b/web/src/components/OnboardingTutorial.svelte @@ -10,16 +10,18 @@ X } from '@lucide/svelte'; + import type { AppView } from '../app/navigation'; + export let open = false; export let onClose: () => void = () => {}; - export let onNavigate: (view: 'games' | 'controllers' | 'haptics' | 'buttonMapping') => void = () => {}; + export let onNavigate: (view: AppView) => void = () => {}; type TutorialStep = { title: string; eyebrow: string; body: string; actionLabel: string; - targetView?: 'games' | 'controllers' | 'haptics' | 'buttonMapping'; + targetView?: AppView; icon: typeof Gamepad2; }; @@ -29,7 +31,7 @@ title: 'Pick the controller and scope', body: 'Use Controllers for hardware state and live inputs, then use Profiles to choose Global or a supported game scope.', actionLabel: 'Open Controllers', - targetView: 'controllers', + targetView: 'advancedController', icon: Gamepad2 }, { @@ -37,7 +39,7 @@ title: 'Game effects wait for telemetry', body: 'DSCC keeps triggers neutral until a supported game is detected and fresh telemetry is flowing. Manual test buttons attach only for the short test window.', actionLabel: 'Open haptics', - targetView: 'haptics', + targetView: 'tuning', icon: RadioTower }, { @@ -45,7 +47,7 @@ title: 'Shape L2 and R2 with curve points', body: 'Drag the trigger dots for custom brake and throttle response, then use Test Actuation to feel the current profile without starting a game.', actionLabel: 'Open haptics', - targetView: 'haptics', + targetView: 'tuning', icon: SlidersHorizontal }, { diff --git a/web/src/components/ViewNav.svelte b/web/src/components/ViewNav.svelte index cc17cef..7420919 100644 --- a/web/src/components/ViewNav.svelte +++ b/web/src/components/ViewNav.svelte @@ -3,18 +3,19 @@ import type { AppView, AppViewDefinition } from '../app/navigation'; export let views: AppViewDefinition[] = []; - export let activeView: AppView = 'games'; + export let activeView: AppView = 'status'; export let tooltips: Record; export let tuningReady = false; export let buttonMappingReady = false; export let onNavigate: (view: AppView) => void = () => {}; $: viewModels = views.map((view) => { - const disabled = (view.id === 'haptics' && !tuningReady) || (view.id === 'buttonMapping' && !buttonMappingReady); + const disabled = + (view.id === 'tuning' && !tuningReady) || (view.id === 'advancedButtonMapping' && !buttonMappingReady); const tooltip = - view.id === 'buttonMapping' && !buttonMappingReady + view.id === 'advancedButtonMapping' && !buttonMappingReady ? 'Select a game or local app scope before editing mappings.' - : view.id === 'haptics' && !tuningReady + : view.id === 'tuning' && !tuningReady ? 'Select a controller before tuning haptics.' : tooltips[view.id]; return { ...view, disabled, tooltip }; From 64cad795781119a0f7ee439750a8fbd9d5a57b62 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 19:07:55 -0500 Subject: [PATCH 05/23] ui-rework: sidebar shell, smoke test retargeted to new routes Co-Authored-By: Claude Fable 5 --- web/scripts/visual-smoke.mjs | 29 ++- web/src/App.svelte | 234 ++++++++++++++----------- web/src/app/navigation.ts | 10 ++ web/src/components/AppSidebar.svelte | 74 ++++++++ web/src/styles/app.css | 3 +- web/src/styles/shell-v2.css | 252 +++++++++++++++++++++++++++ 6 files changed, 494 insertions(+), 108 deletions(-) create mode 100644 web/src/components/AppSidebar.svelte create mode 100644 web/src/styles/shell-v2.css diff --git a/web/scripts/visual-smoke.mjs b/web/scripts/visual-smoke.mjs index 7afd905..a526267 100644 --- a/web/scripts/visual-smoke.mjs +++ b/web/scripts/visual-smoke.mjs @@ -12,11 +12,15 @@ const port = requestedPort > 0 ? requestedPort : await findOpenPort(5174); const baseUrl = `http://${host}:${port}`; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const routeChecks = [ - { hash: '#/games', pattern: /Profiles|Games|Selected Game/i }, - { hash: '#/controllers', pattern: /Controllers|Live Input|Input Path/i }, - { hash: '#/adaptive-triggers-haptics', pattern: /Trigger Curves|Base Haptics|Adaptive/i }, - { hash: '#/button-mapping', pattern: /Customize Button Assignments|Button Mapping|Default mirror/i } + { hash: '#/status', pattern: /Status|Everything|controller/i }, + { hash: '#/tuning', pattern: /Tuning|Profile/i }, + { hash: '#/advanced/controller', pattern: /Controller details|Live input/i }, + // Button mapping needs a game scope first; main() selects one via the + // toolbar before this route runs. + { 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 viewports = [ { width: 1366, height: 768 }, { width: 1440, height: 900 }, @@ -114,6 +118,12 @@ async function main() { }); for (const check of routeChecks) { + if (check.hash === '#/advanced/button-mapping') { + // Button mapping is guarded behind a game scope; pick the mock game + // (option 0 is the Global Profile) so the route does not bounce. + await page.selectOption('select[aria-label="Tuning scope"]', { index: 1 }); + await page.waitForTimeout(300); + } await page.goto(`${baseUrl}/${check.hash}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); const snapshot = await routeSnapshot(page); @@ -122,10 +132,19 @@ async function main() { if (!check.pattern.test(snapshot.text)) failures.push(`${label}: expected route text was missing`); if (!snapshot.canReachBottom) failures.push(`${label}: page content could not scroll to the bottom`); if (snapshot.scrollWidth > snapshot.clientWidth + 2) failures.push(`${label}: horizontal overflow ${snapshot.scrollWidth - snapshot.clientWidth}px`); - if (check.hash === '#/button-mapping' && !/Default mirror only|No writable|read-only|Global Profile/i.test(snapshot.text)) { + if (check.hash === '#/advanced/button-mapping' && !/Default mirror only|No writable|read-only|Global Profile/i.test(snapshot.text)) { failures.push(`${label}: read-only/default-mirror mapping copy was missing`); } } + + for (const redirect of legacyRedirectChecks) { + await page.goto(`${baseUrl}/${redirect.from}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(300); + const finalHash = await page.evaluate(() => location.hash); + if (finalHash !== redirect.to) { + failures.push(`${viewport.width}x${viewport.height} ${redirect.from}: redirected to ${finalHash}, expected ${redirect.to}`); + } + } if (consoleErrors.length) { failures.push(`${viewport.width}x${viewport.height}: console errors: ${consoleErrors.slice(0, 5).join(' | ')}`); } diff --git a/web/src/App.svelte b/web/src/App.svelte index 0bcb369..2625498 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,15 +1,13 @@ -
+
+ + {#snippet footer()} + + {/snippet} + + +
{#if loading}
@@ -2008,56 +2030,100 @@
{:else if snapshot} -
-
- -
-

DualSense Command Center

-

Adaptive triggers, haptics, and live telemetry — tuned locally.

-
-
- - - -
-
- {systemReadoutTitle} - {systemReadoutValue} - {systemReadoutDetail} -
- - - - - - + +
+ + + + + +
+
+ {systemReadoutTitle} + {systemReadoutValue} + {systemReadoutDetail}
-
+ {#if showPartialErrorBanner}
+
+ diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index 9c921cc..54e6bb7 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -36,6 +36,16 @@ const legacyRedirects: Record = { '#/button-mapping': '#/advanced/button-mapping' }; +/** Every hash the router answers to: current view hashes plus legacy redirects. */ +export const knownViewHashes: string[] = [ + ...appViews.map((item) => item.hash), + ...Object.keys(legacyRedirects) +]; + +export function isViewHash(hash: string): boolean { + return knownViewHashes.includes(hash); +} + export function hashForView(view: AppView): string { return appViews.find((item) => item.id === view)?.hash ?? '#/status'; } diff --git a/web/src/components/AppSidebar.svelte b/web/src/components/AppSidebar.svelte new file mode 100644 index 0000000..c51251b --- /dev/null +++ b/web/src/components/AppSidebar.svelte @@ -0,0 +1,74 @@ + + + diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 13a84d0..5504005 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1,8 +1,7 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@600;700;800&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&display=swap"); @import "./tokens.css"; @import "./base.css"; -@import "./shell.css"; -@import "./ribbon.css"; +@import "./shell-v2.css"; @import "./games.css"; @import "./controllers.css"; @import "./games-catalog.css"; diff --git a/web/src/styles/shell-v2.css b/web/src/styles/shell-v2.css new file mode 100644 index 0000000..e41453b --- /dev/null +++ b/web/src/styles/shell-v2.css @@ -0,0 +1,252 @@ +/* Shell v2 — slim sidebar + document-flow main column (calm console, flat fills). */ + +/* The document scrolls again under the v2 shell; base.css pinned it for the old + HUD shell, which scrolled inside .ops-shell. Delete the base.css rules in the + cleanup task and fold these in. */ +html, +body, +#app { + height: auto; + min-height: 100%; +} + +body { + overflow-x: hidden; + overflow-y: auto; +} + +html { + /* Reserve the vertical scrollbar's track so its appearance never reflows + content (keeps the visual smoke overflow check stable on classic + scrollbar platforms). */ + scrollbar-gutter: stable; +} + +.app-shell { + display: flex; + min-height: 100vh; + background: var(--bg); +} + +.sidebar { + width: 168px; + flex: 0 0 auto; + background: var(--bg-rail); + display: flex; + flex-direction: column; + gap: 2px; + padding: 14px 10px; + position: sticky; + top: 0; + height: 100vh; + z-index: var(--z-sticky); +} + +.sidebar-brand { + font-weight: 600; + padding: 6px 10px; +} + +.sidebar-item, +.sidebar-group { + text-align: left; + font: inherit; + color: var(--ink-muted); + background: none; + border: 0; + padding: 7px 10px; + border-radius: var(--radius-s); + cursor: pointer; + transition: background var(--speed) var(--ease), color var(--speed) var(--ease); +} + +.sidebar-item:hover { + color: var(--ink); + background: color-mix(in srgb, var(--surface-raised) 60%, transparent); +} + +.sidebar-item.active { + color: var(--ink); + background: var(--surface-raised); +} + +.sidebar-item:focus-visible, +.sidebar-group:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 1px; +} + +.sidebar-group { + font-size: 0.78rem; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-top: 12px; +} + +.sidebar-sub { + padding-left: 20px; + font-size: 0.92em; +} + +.sidebar-spacer { + flex: 1; +} + +.sidebar-footer { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-footer .sidebar-item { + display: flex; + align-items: center; + gap: 6px; +} + +.app-main { + flex: 1; + min-width: 0; + /* Clip latent horizontal overflow from legacy feature views (the old + .ops-shell did the same); remove once the views are rebuilt. */ + overflow-x: hidden; + padding: 18px clamp(18px, 2.2vw, 34px) 22px; +} + +/* TEMPORARY toolbar — controller/scope/profile context the old ribbon carried. + Tasks 4-5 re-house this in Status and the Tuning header; delete it then. */ +.app-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 16px; + padding: 10px 12px; + margin-bottom: 16px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-m); +} + +.app-toolbar-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.app-toolbar-field > span { + font-size: 0.72rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.app-toolbar-field select { + max-width: 220px; + padding: 5px 8px; + color: var(--ink); + background: var(--surface-raised); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-s); +} + +.app-toolbar-field small, +.app-toolbar-readout small { + color: var(--ink-muted); + font-size: 0.72rem; +} + +.app-toolbar-readout { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.app-toolbar-readout > span { + font-size: 0.72rem; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.app-toolbar-toggle { + padding: 6px 10px; + color: var(--ink-muted); + background: var(--surface-raised); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-s); + transition: color var(--speed) var(--ease), border-color var(--speed) var(--ease); +} + +.app-toolbar-toggle.active { + color: var(--accent-text); + border-color: var(--accent-outline); + background: var(--accent-tint); +} + +.app-toolbar-spacer { + flex: 1; +} + +/* TEMPORARY carry-over from shell.css (still used by legacy feature views); + delete with the views in the cleanup task. */ +.dm-controller-glyph { + width: 42px; + height: 42px; + flex: 0 0 auto; + color: var(--ink); + opacity: 0.82; + background: currentColor; + mask: url("/dualsense-controller.svg") center / contain no-repeat; + -webkit-mask: url("/dualsense-controller.svg") center / contain no-repeat; +} + +.dm-battery-pill { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--ink-muted); +} + +.dm-battery { + width: 28px; + height: 14px; + flex: 0 0 auto; + color: var(--ink-muted); +} + +.dm-battery rect:first-child, +.dm-battery path { + fill: none; + stroke: currentColor; + stroke-width: 1.5; +} + +.dm-battery-fill { + fill: var(--ink); +} + +@media (max-width: 760px) { + .app-shell { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: auto; + position: sticky; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } + + .sidebar-spacer { + display: none; + } + + .sidebar-footer { + flex-direction: row; + } +} From 56b0c490532076eb4aed66755841d3a48306e79a Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 19:17:10 -0500 Subject: [PATCH 06/23] ui-rework: contain overlay scroll, prune dead glyph rule, cleanup breadcrumbs Co-Authored-By: Claude Fable 5 --- web/src/styles/shell-v2.css | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/web/src/styles/shell-v2.css b/web/src/styles/shell-v2.css index e41453b..6df30d4 100644 --- a/web/src/styles/shell-v2.css +++ b/web/src/styles/shell-v2.css @@ -2,7 +2,9 @@ /* The document scrolls again under the v2 shell; base.css pinned it for the old HUD shell, which scrolled inside .ops-shell. Delete the base.css rules in the - cleanup task and fold these in. */ + cleanup task and fold these in. ALSO: responsive.css (@max-width: 1180px) sets + body { overflow: auto } and .ops-shell rules that override document scroll — + fold those into the responsive section here. */ html, body, #app { @@ -189,19 +191,6 @@ html { flex: 1; } -/* TEMPORARY carry-over from shell.css (still used by legacy feature views); - delete with the views in the cleanup task. */ -.dm-controller-glyph { - width: 42px; - height: 42px; - flex: 0 0 auto; - color: var(--ink); - opacity: 0.82; - background: currentColor; - mask: url("/dualsense-controller.svg") center / contain no-repeat; - -webkit-mask: url("/dualsense-controller.svg") center / contain no-repeat; -} - .dm-battery-pill { display: inline-flex; align-items: center; @@ -228,6 +217,12 @@ html { fill: var(--ink); } +/* Prevent scroll bleed behind fixed overlays; the main document scrolls under + v2 shell, so fixed panels must contain their scroll momentum. */ +.dm-onboarding { + overscroll-behavior: contain; +} + @media (max-width: 760px) { .app-shell { flex-direction: column; From 89bf4dde4c54ac0e816cc40e2efefceefcf24380 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 19:24:41 -0500 Subject: [PATCH 07/23] ui-rework: status page with sentence-of-truth and profile resolution Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 32 ++- web/src/lib/features/status/StatusView.svelte | 257 ++++++++++++++++++ web/src/styles/app.css | 1 + web/src/styles/status.css | 239 ++++++++++++++++ 4 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/features/status/StatusView.svelte create mode 100644 web/src/styles/status.css diff --git a/web/src/App.svelte b/web/src/App.svelte index 2625498..bfa9c06 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -4,6 +4,7 @@ import AppSidebar from './components/AppSidebar.svelte'; import AddGameDialog from './lib/features/games/AddGameDialog.svelte'; import GamesView from './lib/features/games/GamesView.svelte'; + import StatusView from './lib/features/status/StatusView.svelte'; import OnboardingTutorial from './components/OnboardingTutorial.svelte'; import SupportPanel from './components/SupportPanel.svelte'; import ToastStack from './components/ToastStack.svelte'; @@ -497,12 +498,13 @@ $: activeProfileHeaderName = profileWorkspace.activeProfileHeaderName; $: activeProfileHeaderMeta = profileWorkspace.activeProfileHeaderMeta; $: buttonMappingActive = activeView === 'advancedButtonMapping'; - // Temporary view composition (Tasks 3-5 replace this): 'status' renders the old - // default (GamesView) so the app is never blank; 'tuning' renders GamesView plus - // the haptics workspace; 'advancedEdgeSlots' renders ControllersView because the - // Edge onboard slots UI currently lives inside it. + // Temporary view composition (Tasks 5-9 replace the rest): 'status' renders the + // new StatusView; 'tuning' renders GamesView plus the haptics workspace; + // 'advancedEdgeSlots' renders ControllersView because the Edge onboard slots UI + // currently lives inside it. Views the guard rejects land on 'status'. $: showAdvancedControllerView = activeView === 'advancedController' || activeView === 'advancedEdgeSlots'; - $: showGamesView = activeView === 'status' || activeView === 'tuning' || (!tuningReady && !showAdvancedControllerView); + $: showStatusView = activeView === 'status'; + $: showGamesView = activeView === 'tuning'; $: showWorkspaceViews = showAdvancedControllerView || (tuningReady && (activeView === 'tuning' || activeView === 'advancedButtonMapping')); $: steamInputStatus = snapshot?.steamInput; @@ -2160,6 +2162,26 @@ onNavigate={navigateToView} /> + {#if showStatusView} + + {/if} {#if showGamesView} + import type { + AdapterStatus, + ControllerStatus, + ProfileSummary, + SupportedGame + } from '../../types'; + + let { + controllers = [], + controller = undefined, + detectedGame = null, + detectedGameName = null, + activeProfile = undefined, + activeProfileName = 'None', + overrideActive = false, + adapter = undefined, + adapters = [], + renameActiveId = '', + renameName = $bindable(''), + renameBusy = false, + onBeginRename = () => {}, + onSubmitRename = () => {}, + onCancelRename = () => {}, + onRenameKeydown = () => {} + }: { + controllers?: ControllerStatus[]; + controller?: ControllerStatus | undefined; + detectedGame?: SupportedGame | null; + detectedGameName?: string | null; + activeProfile?: ProfileSummary | undefined; + activeProfileName?: string; + overrideActive?: boolean; + adapter?: AdapterStatus | undefined; + adapters?: AdapterStatus[]; + renameActiveId?: string; + renameName?: string; + renameBusy?: boolean; + onBeginRename?: (item: ControllerStatus) => void; + onSubmitRename?: () => void | Promise; + onCancelRename?: () => void; + onRenameKeydown?: (event: KeyboardEvent) => void; + } = $props(); + + type Finding = { text: string; detail?: string }; + + const connectedControllers = $derived(controllers.filter((item) => item.connected)); + const hasController = $derived(connectedControllers.length > 0); + const alias = $derived(controller?.name || controller?.family || 'your controller'); + const gameName = $derived(detectedGameName ?? detectedGame?.name ?? null); + const gameRunning = $derived(Boolean(detectedGame?.running && gameName)); + const telemetryExpected = $derived(gameRunning && detectedGame?.supportLevel === 'telemetry'); + const telemetryFresh = $derived( + Boolean(adapter && adapter.state === 'running' && adapter.packetRateHz > 0) + ); + + const findings = $derived.by(() => { + const list: Finding[] = []; + for (const item of controllers) { + if (!item.connected) { + list.push({ + text: `${item.name || item.family} is disconnected.`, + detail: 'Reconnect it and its tuned feel comes right back.' + }); + } + } + if (telemetryExpected && !telemetryFresh) { + list.push({ + text: `${gameName} is running, but its telemetry has gone quiet.`, + detail: adapter?.setupHint || 'Check the game’s Data Out setting if the feel stops.' + }); + } + for (const item of adapters) { + if (item.state === 'faulted') { + list.push({ + text: `${item.name} is blocked — another app may be using its port.`, + detail: item.setupHint || undefined + }); + } + } + return list; + }); + + const dotState = $derived(!hasController ? 'danger' : findings.length ? 'warn' : 'ok'); + const headline = $derived( + !hasController + ? 'No controller connected yet.' + : findings.length + ? 'Something needs your attention.' + : 'Everything is working.' + ); + const clause = $derived.by(() => { + if (!hasController) return 'Plug in or pair a controller and DSCC takes it from there.'; + if (gameRunning && telemetryExpected && !telemetryFresh) { + return `${gameName} detected, but its telemetry is quiet on ${alias}.`; + } + if (gameRunning) return `${gameName} detected — tuned feel is live on ${alias}.`; + return `${alias} is connected and ready. Tuned feel starts when a supported game does.`; + }); + + const profileScopeNote = $derived.by(() => { + if (!activeProfile) return ''; + if (activeProfile.scope === 'Game') return 'its Game Profile'; + if (activeProfile.scope === 'Global') return 'Everyday · Global Profile'; + return 'built-in'; + }); + + const connectionLine = (item: ControllerStatus): string => { + const parts: string[] = []; + if (item.connected && item.transport !== 'Unknown') parts.push(item.transport); + if (typeof item.battery === 'number' && item.batteryState !== 'unknown') { + if (item.batteryState === 'charging') parts.push(`battery ${item.battery}%, charging`); + else if (item.batteryState === 'full') parts.push(`battery ${item.battery}%, full`); + else parts.push(`battery ${item.battery}%`); + } + return parts.join(' · '); + }; + + +
+

Status

+ +
+ + {headline} + {clause} +
+ +
+
+
Controller
+ {#if controllers.length} +
+ {#each controllers as item (item.id)} +
+ +
+ {#if renameActiveId === item.id} +
+ + + + +
+ {:else} +
+ {item.name || item.family} + · {item.family} +
+ {/if} +
+ {#if item.connected} + ● Connected + {:else} + ● Disconnected + {/if} + {#if connectionLine(item)} + · {connectionLine(item)} + {/if} +
+
+ {#if renameActiveId !== item.id} + + {/if} +
+ {/each} +
+
Another controller? Plug it in or pair it and it appears here.
+ {:else} +
Plug in or pair a controller and it appears here.
+ {/if} +
+ +
+
What's active, and why
+
+
+ Game detected + {#if gameRunning} + {gameName} + {:else if gameName} + {gameName} (installed, not running) + {:else} + None yet + {/if} +
+
+ Profile in use + + {activeProfileName} + {#if profileScopeNote} + ({profileScopeNote}) + {/if} + {#if overrideActive} + · chosen by you + {/if} + +
+
+ Telemetry + {#if telemetryExpected && telemetryFresh} + Fresh · driving feel is live + {:else if telemetryExpected} + Quiet · waiting for game data + {:else} + Idle until a supported game runs + {/if} +
+
+ When the game closes + Back to Global Profile +
+
+
+ +
+
Needs attention
+
+ {#if findings.length} + {#each findings as finding (finding.text)} +
+ {finding.text} + {#if finding.detail} +
{finding.detail}
+ {/if} +
+ {/each} + {:else} +
Nothing else needs you.
+
This box stays empty when all is well.
+ {/if} +
+
+
+
diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 5504005..a51de4f 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -2,6 +2,7 @@ @import "./tokens.css"; @import "./base.css"; @import "./shell-v2.css"; +@import "./status.css"; @import "./games.css"; @import "./controllers.css"; @import "./games-catalog.css"; diff --git a/web/src/styles/status.css b/web/src/styles/status.css new file mode 100644 index 0000000..8088646 --- /dev/null +++ b/web/src/styles/status.css @@ -0,0 +1,239 @@ +/* Status — the glanceable page (layout truth: status-advanced-v2-psblue mockup). + Groups are intrinsic-width flex children that wrap; boxes only where content + needs a bounded field. */ + +/* Shared 10px caps label treatment used by Status (and later canvases). */ +.lbl { + font-size: 10px; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.status-view { + font-size: 12px; + line-height: 1.45; + color: var(--ink); +} + +/* Sentence of truth */ +.status-sentence { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 10px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-dot.ok { background: var(--ok); } +.status-dot.warn { background: var(--warn); } +.status-dot.danger { background: var(--danger); } + +.status-headline { + font-size: 15px; + font-weight: 600; +} + +.status-clause { + font-size: 12px; + color: var(--ink-muted); +} + +/* Groups row — intrinsic widths, wraps naturally, no stretched hero cards */ +.status-groups { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 16px 22px; + margin-top: 16px; +} + +.status-group { + flex: 1 1 280px; + min-width: 260px; + max-width: 420px; +} + +.status-group.narrow { + flex: 1 1 240px; + min-width: 230px; + max-width: 360px; +} + +.status-surf { + margin-top: 6px; + padding: 12px 14px; + background: var(--surface); + border-radius: var(--radius-m); +} + +.status-empty { + font-size: 11px; + color: var(--ink-muted); +} + +/* Controller block */ +.status-controller-card { + display: flex; + align-items: center; + gap: 12px; +} + +.status-controller-card + .status-controller-card { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline); +} + +.status-controller-icon { + width: 46px; + height: 34px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + border-radius: var(--radius-m); + background: var(--surface-raised); +} + +.status-controller-main { + flex: 1; + min-width: 0; +} + +.status-controller-name { + font-weight: 600; +} + +.status-controller-name .status-mut { + font-weight: 400; +} + +.status-controller-line { + margin-top: 2px; + font-size: 11px; + color: var(--ink-muted); +} + +.status-hint { + margin-top: 8px; + font-size: 11px; + color: var(--ink-muted); +} + +.status-rename { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.status-rename input { + flex: 1; + min-width: 0; + font: inherit; + color: var(--ink); + background: var(--surface-raised); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-s); + padding: 3px 8px; +} + +.status-rename input:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 1px; +} + +.status-link { + font-size: 11px; + font-weight: 500; + color: var(--accent-text); + background: none; + border: 0; + padding: 0; + cursor: pointer; +} + +.status-link.mut { + color: var(--ink-muted); +} + +.status-link:disabled { + opacity: 0.55; + cursor: default; +} + +.status-link:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 2px; + border-radius: 2px; +} + +/* What's active rows */ +.status-rows { + margin-top: 6px; +} + +.status-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 6px 0; + font-size: 11px; + border-bottom: 1px solid var(--hairline); +} + +.status-row:last-child { + border-bottom: 0; +} + +.status-row > span:last-child { + text-align: right; +} + +.status-strong { + font-weight: 600; +} + +.status-strong .status-mut { + font-weight: 400; +} + +/* Needs attention */ +.status-finding { + font-size: 11px; +} + +.status-finding + .status-finding { + margin-top: 8px; +} + +.status-finding-detail { + margin-top: 4px; + font-size: 10px; + color: var(--ink-muted); +} + +.status-ok { color: var(--ok); } +.status-warn { color: var(--warn); } +.status-mut { color: var(--ink-muted); } From 20c52e1e551923c902f5b7f10286c686bfdbb0e0 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 19:39:19 -0500 Subject: [PATCH 08/23] ui-rework: stable finding keys and aria-hidden decorative dots on Status Co-Authored-By: Claude Fable 5 --- web/src/lib/features/status/StatusView.svelte | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/web/src/lib/features/status/StatusView.svelte b/web/src/lib/features/status/StatusView.svelte index bc61dab..b9f23c3 100644 --- a/web/src/lib/features/status/StatusView.svelte +++ b/web/src/lib/features/status/StatusView.svelte @@ -42,7 +42,7 @@ onRenameKeydown?: (event: KeyboardEvent) => void; } = $props(); - type Finding = { text: string; detail?: string }; + type Finding = { id: string; text: string; detail?: string }; const connectedControllers = $derived(controllers.filter((item) => item.connected)); const hasController = $derived(connectedControllers.length > 0); @@ -59,6 +59,7 @@ for (const item of controllers) { if (!item.connected) { list.push({ + id: `controller-disconnected-${item.id}`, text: `${item.name || item.family} is disconnected.`, detail: 'Reconnect it and its tuned feel comes right back.' }); @@ -66,13 +67,15 @@ } if (telemetryExpected && !telemetryFresh) { list.push({ + id: 'telemetry-quiet', text: `${gameName} is running, but its telemetry has gone quiet.`, - detail: adapter?.setupHint || 'Check the game’s Data Out setting if the feel stops.' + detail: adapter?.setupHint || 'Check the game\'s Data Out setting if the feel stops.' }); } for (const item of adapters) { if (item.state === 'faulted') { list.push({ + id: `adapter-faulted-${item.id}`, text: `${item.name} is blocked — another app may be using its port.`, detail: item.setupHint || undefined }); @@ -167,9 +170,9 @@ {/if}
{#if item.connected} - ● Connected + Connected {:else} - ● Disconnected + Disconnected {/if} {#if connectionLine(item)} · {connectionLine(item)} @@ -199,7 +202,7 @@
Game detected {#if gameRunning} - {gameName} + {gameName} {:else if gameName} {gameName} (installed, not running) {:else} @@ -239,9 +242,9 @@
Needs attention
{#if findings.length} - {#each findings as finding (finding.text)} + {#each findings as finding (finding.id)}
- {finding.text} + {finding.text} {#if finding.detail}
{finding.detail}
{/if} From 18383b95c8a3974516cf7060f6bd8d40723f63c4 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 19:55:36 -0500 Subject: [PATCH 09/23] ui-rework: tuning header with game dropdown, profile menu, bespoke art band Co-Authored-By: Claude Fable 5 --- web/scripts/visual-smoke.mjs | 14 +- web/src/App.svelte | 141 ++--- .../lib/features/haptics/HapticsAside.svelte | 68 +-- .../lib/features/tuning/TuningHeader.svelte | 492 ++++++++++++++++++ web/src/lib/features/tuning/gameSelect.ts | 135 +++++ web/src/styles/app.css | 1 + web/src/styles/tuning.css | 322 ++++++++++++ 7 files changed, 1002 insertions(+), 171 deletions(-) create mode 100644 web/src/lib/features/tuning/TuningHeader.svelte create mode 100644 web/src/lib/features/tuning/gameSelect.ts create mode 100644 web/src/styles/tuning.css diff --git a/web/scripts/visual-smoke.mjs b/web/scripts/visual-smoke.mjs index a526267..76e189c 100644 --- a/web/scripts/visual-smoke.mjs +++ b/web/scripts/visual-smoke.mjs @@ -119,10 +119,14 @@ async function main() { for (const check of routeChecks) { if (check.hash === '#/advanced/button-mapping') { - // Button mapping is guarded behind a game scope; pick the mock game - // (option 0 is the Global Profile) so the route does not bounce. - await page.selectOption('select[aria-label="Tuning scope"]', { index: 1 }); + // Button mapping is guarded behind a game scope; pick the mock + // running game from the Tuning header's game dropdown so the route + // does not bounce. + await page.goto(`${baseUrl}/#/tuning`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); + await page.click('.tuning-header-game'); + await page.click('.tuning-menu .tuning-menu-item:has(.tuning-running-dot)'); + await page.waitForTimeout(500); } await page.goto(`${baseUrl}/${check.hash}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(300); @@ -132,8 +136,8 @@ async function main() { if (!check.pattern.test(snapshot.text)) failures.push(`${label}: expected route text was missing`); if (!snapshot.canReachBottom) failures.push(`${label}: page content could not scroll to the bottom`); if (snapshot.scrollWidth > snapshot.clientWidth + 2) failures.push(`${label}: horizontal overflow ${snapshot.scrollWidth - snapshot.clientWidth}px`); - if (check.hash === '#/advanced/button-mapping' && !/Default mirror only|No writable|read-only|Global Profile/i.test(snapshot.text)) { - failures.push(`${label}: read-only/default-mirror mapping copy was missing`); + if (check.hash === '#/advanced/button-mapping' && !/Default mirror only|No writable|read-only|inputs mapped/i.test(snapshot.text)) { + failures.push(`${label}: mapping session copy (read-only or live layout) was missing`); } } diff --git a/web/src/App.svelte b/web/src/App.svelte index bfa9c06..d5aa119 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -3,8 +3,8 @@ import { onMount } from 'svelte'; import AppSidebar from './components/AppSidebar.svelte'; import AddGameDialog from './lib/features/games/AddGameDialog.svelte'; - import GamesView from './lib/features/games/GamesView.svelte'; import StatusView from './lib/features/status/StatusView.svelte'; + import TuningHeader from './lib/features/tuning/TuningHeader.svelte'; import OnboardingTutorial from './components/OnboardingTutorial.svelte'; import SupportPanel from './components/SupportPanel.svelte'; import ToastStack from './components/ToastStack.svelte'; @@ -162,13 +162,6 @@ forzaTuningFromConfig, type ForzaTuningValues } from './app/forzaEffectState'; - import { - gameAccentColor, - gameArtwork, - gameMediaDetails, - gameTileStatus, - profileScopeCount as countProfilesForGame - } from './lib/features/games/gamePresentation'; import { defaultProfileIdForGame, usesForzaRuntimeProfile @@ -498,13 +491,13 @@ $: activeProfileHeaderName = profileWorkspace.activeProfileHeaderName; $: activeProfileHeaderMeta = profileWorkspace.activeProfileHeaderMeta; $: buttonMappingActive = activeView === 'advancedButtonMapping'; - // Temporary view composition (Tasks 5-9 replace the rest): 'status' renders the - // new StatusView; 'tuning' renders GamesView plus the haptics workspace; + // Temporary view composition (Tasks 6-9 replace the rest): 'status' renders the + // new StatusView; 'tuning' renders the TuningHeader plus the haptics workspace; // 'advancedEdgeSlots' renders ControllersView because the Edge onboard slots UI // currently lives inside it. Views the guard rejects land on 'status'. $: showAdvancedControllerView = activeView === 'advancedController' || activeView === 'advancedEdgeSlots'; $: showStatusView = activeView === 'status'; - $: showGamesView = activeView === 'tuning'; + $: showTuningView = activeView === 'tuning'; $: showWorkspaceViews = showAdvancedControllerView || (tuningReady && (activeView === 'tuning' || activeView === 'advancedButtonMapping')); $: steamInputStatus = snapshot?.steamInput; @@ -764,8 +757,6 @@ stopTriggerInputPolling(); }; - const profileScopeCount = (game: SupportedGame) => countProfilesForGame(game, profiles); - const openAddGameDialog = async () => { addGameOpen = true; addGameError = ''; @@ -2032,8 +2023,8 @@ {:else if snapshot} - +
- - + + + {:else} + + + + {/if} +
+{/if} + +{#if openMenu === 'game'} + +{/if} + +{#if openMenu === 'profile'} + +{/if} + + void onImportFile(event)} +/> diff --git a/web/src/lib/features/tuning/gameSelect.ts b/web/src/lib/features/tuning/gameSelect.ts new file mode 100644 index 0000000..3f894d1 --- /dev/null +++ b/web/src/lib/features/tuning/gameSelect.ts @@ -0,0 +1,135 @@ +import type { SupportedGame } from '../../types'; + +// Pure presentation model for the Tuning header's game dropdown. Builds the +// grouped menu (Running now / Everyday / Supported games / footer actions) +// from the discovered games list plus the current selection. No fetching, no +// component state: types in, types out. + +export type TuningScopeKind = 'none' | 'global' | 'game'; + +export type GameSelectEntry = + | { kind: 'game'; id: string; game: SupportedGame; label: string; running: boolean; current: boolean } + | { kind: 'everyday'; id: 'everyday'; label: string; detail: string; current: boolean } + | { kind: 'setup-guide'; id: 'setup-guide'; label: string; enabled: boolean } + | { kind: 'add-game'; id: 'add-game'; label: string }; + +export type GameSelectGroup = { + id: 'running' | 'everyday' | 'supported' | 'actions'; + label: string | null; + entries: GameSelectEntry[]; +}; + +export type GameSelectModel = { + groups: GameSelectGroup[]; + /** Header title for the closed state of the dropdown. */ + title: string; +}; + +const gameEntry = (game: SupportedGame, options: BuildGameSelectOptions): GameSelectEntry => ({ + kind: 'game', + id: game.gameId, + game, + label: game.name, + running: Boolean(game.running), + current: options.scope === 'game' && options.selectedGameId === game.gameId +}); + +export type BuildGameSelectOptions = { + /** Discovered games, already sorted running > installed > name. */ + games: SupportedGame[]; + scope: TuningScopeKind; + selectedGameId: string; + /** + * Game whose setup guide the footer should offer (usually the selected + * game when it has telemetry support). Null hides the entry. + */ + setupGuideGame?: SupportedGame | null; + /** Whether the setup-guide entry is actionable yet. */ + setupGuideEnabled?: boolean; +}; + +export function buildGameSelectModel(options: BuildGameSelectOptions): GameSelectModel { + const games = options.games ?? []; + const running = games.filter((game) => game.running); + const rest = games.filter((game) => !game.running); + + const groups: GameSelectGroup[] = []; + + if (running.length) { + groups.push({ + id: 'running', + label: 'Running now', + entries: running.map((game) => gameEntry(game, options)) + }); + } + + groups.push({ + id: 'everyday', + label: 'Everyday', + entries: [ + { + kind: 'everyday', + id: 'everyday', + label: 'Everyday (no game)', + detail: 'Global Profile', + current: options.scope !== 'game' + } + ] + }); + + if (rest.length) { + groups.push({ + id: 'supported', + label: 'Supported games', + entries: rest.map((game) => gameEntry(game, options)) + }); + } + + const actions: GameSelectEntry[] = []; + if (options.setupGuideGame) { + actions.push({ + kind: 'setup-guide', + id: 'setup-guide', + label: `Setup guide for ${options.setupGuideGame.name}…`, + enabled: Boolean(options.setupGuideEnabled) + }); + } + actions.push({ kind: 'add-game', id: 'add-game', label: '+ Add a game manually…' }); + groups.push({ id: 'actions', label: null, entries: actions }); + + return { + groups, + title: headerTitle(options) + }; +} + +export function headerTitle(options: { + games: SupportedGame[]; + scope: TuningScopeKind; + selectedGameId: string; +}): string { + if (options.scope === 'game' && options.selectedGameId) { + const game = (options.games ?? []).find((item) => item.gameId === options.selectedGameId); + if (game) return game.name; + } + return 'Everyday (no game)'; +} + +export type TelemetryChipState = 'fresh' | 'quiet' | null; + +/** + * Telemetry chip state for the header band. Only games with telemetry + * support that are actually running get a chip; Fresh means packets are + * arriving, Quiet means the game is up but its data feed is silent. + */ +export function telemetryChipState(options: { + scope: TuningScopeKind; + selectedGame: SupportedGame | null; + adapterRunning: boolean; + packetRateHz: number; +}): TelemetryChipState { + if (options.scope !== 'game') return null; + const game = options.selectedGame; + if (!game || !game.running || game.supportLevel !== 'telemetry') return null; + return options.adapterRunning && options.packetRateHz > 0 ? 'fresh' : 'quiet'; +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index a51de4f..2d05afd 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -3,6 +3,7 @@ @import "./base.css"; @import "./shell-v2.css"; @import "./status.css"; +@import "./tuning.css"; @import "./games.css"; @import "./controllers.css"; @import "./games-catalog.css"; diff --git a/web/src/styles/tuning.css b/web/src/styles/tuning.css new file mode 100644 index 0000000..bfd385b --- /dev/null +++ b/web/src/styles/tuning.css @@ -0,0 +1,322 @@ +/* Tuning canvas — header band, game dropdown, profile menu (Task 5). + Visual register: calm, flat, hairlines, PS-blue accent. */ + +.tuning-header { + position: relative; + min-height: 80px; + background-color: var(--bg-rail); + background-position: center 30%; + background-size: cover; + border-bottom: 1px solid var(--hairline); +} + +.tuning-header-scrim { + position: absolute; + inset: 0; + background: + linear-gradient(90deg, rgba(20, 20, 23, 0.92) 0%, rgba(20, 20, 23, 0.55) 45%, rgba(20, 20, 23, 0.25) 100%), + linear-gradient(0deg, var(--bg) 0%, rgba(20, 20, 23, 0) 60%); +} + +.tuning-header-content { + position: relative; + min-height: 80px; + padding: 12px 16px 10px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px 12px; + flex-wrap: wrap; +} + +.tuning-header-identity { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.tuning-header-thumb { + flex: 0 0 auto; + width: 32px; + height: 48px; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.tuning-header-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.tuning-header-titles { + min-width: 0; +} + +.tuning-header-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.tuning-header-game { + display: inline-flex; + align-items: center; + gap: 6px; + background: none; + border: 0; + padding: 2px 0; + color: var(--ink); + cursor: pointer; + font: inherit; + min-width: 0; + max-width: 100%; +} + +.tuning-header-game strong { + font-size: 15px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tuning-caret { + color: var(--ink-muted); + font-size: 11px; + flex: 0 0 auto; +} + +.tuning-header-game:hover .tuning-caret, +.tuning-header-profile:hover .tuning-caret { + color: var(--ink); +} + +.tuning-telemetry-chip { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 999px; + padding: 3px 10px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.tuning-telemetry-chip.fresh { + background: var(--ok-tint); + color: var(--ok); +} + +.tuning-telemetry-chip.quiet { + background: rgba(250, 204, 21, 0.12); + color: var(--warn); +} + +.tuning-header-profile-row { + margin-top: 2px; + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.tuning-header-profile { + display: inline-flex; + align-items: baseline; + gap: 5px; + background: none; + border: 0; + padding: 0; + color: var(--ink-muted); + cursor: pointer; + font-size: 11px; + font-family: inherit; + min-width: 0; +} + +.tuning-header-profile:disabled { + cursor: default; + opacity: 0.6; +} + +.tuning-header-profile strong { + color: var(--ink); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tuning-unsaved-note { + font-size: 10px; + color: var(--accent-text); + white-space: nowrap; +} + +.tuning-header-controller { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 11px; + color: var(--ink-muted); + white-space: nowrap; + flex: 0 0 auto; +} + +.tuning-controller-dot { + color: var(--ok); + font-size: 9px; +} + +/* --- inline profile name editor (save-as / rename) ------------------------ */ + +.tuning-profile-editor { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--hairline); +} + +.tuning-profile-editor label { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 0 1 280px; +} + +.tuning-profile-editor label span { + font-size: 10px; + letter-spacing: 0.02em; + color: var(--ink-muted); +} + +.tuning-profile-editor input { + background: var(--surface-raised); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-s); + color: var(--ink); + font: inherit; + font-size: 12px; + padding: 6px 8px; +} + +/* --- dropdown menus -------------------------------------------------------- */ + +.tuning-menu { + position: fixed; + z-index: var(--z-dropdown); + width: 252px; + max-height: min(60vh, 420px); + overflow-y: auto; + background: var(--surface-raised); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-l); + padding: 6px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); +} + +.tuning-menu-label { + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); + padding: 6px 8px 4px; +} + +.tuning-menu-divider { + border-top: 1px solid var(--hairline-strong); + margin: 6px 4px; +} + +.tuning-menu-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + width: 100%; + text-align: left; + background: none; + border: 0; + border-radius: var(--radius-s); + padding: 6px 8px; + color: var(--ink); + font-family: inherit; + font-size: 12px; + cursor: pointer; +} + +.tuning-menu-item:hover, +.tuning-menu-item:focus-visible { + background: var(--surface); + outline: none; +} + +.tuning-menu-item:focus-visible { + box-shadow: inset 0 0 0 1px var(--accent-outline); +} + +.tuning-menu-item:disabled { + color: var(--ink-muted); + cursor: default; + opacity: 0.55; +} + +.tuning-menu-item.current { + background: var(--accent-tint); + color: var(--accent-text); +} + +.tuning-menu-item.accent { + color: var(--accent-text); +} + +.tuning-menu-item-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tuning-menu-item-meta { + flex: 0 0 auto; + font-size: 9px; + color: var(--ink-muted); +} + +.tuning-menu-item.current .tuning-menu-item-meta { + color: var(--accent-text); +} + +.tuning-running-dot { + color: var(--ok); + margin-right: 6px; + font-size: 10px; +} + +@media (max-width: 480px) { + .tuning-header-content { + padding: 10px 12px 8px; + } + + .tuning-header-controller { + order: -1; + width: 100%; + justify-content: flex-end; + } + + .tuning-header-game strong { + font-size: 14px; + max-width: 60vw; + } +} From 2604db2a6ed5ee61d7c8ea2ae71ffa1ce0016dcf Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 20:07:43 -0500 Subject: [PATCH 10/23] ui-rework: tuning menus close on tab-out and scroll; aria-label refresh Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 2 +- .../lib/features/tuning/TuningHeader.svelte | 32 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index d5aa119..74f9b94 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -2025,7 +2025,7 @@ {:else if snapshot} -
+
- + + + + {:else} + + + + {/if} +
+{/if} + +{#if open} + +{/if} + + void onImportFile(event)} +/> diff --git a/web/src/lib/features/tuning/SavedRail.svelte b/web/src/lib/features/tuning/SavedRail.svelte new file mode 100644 index 0000000..95c2eb7 --- /dev/null +++ b/web/src/lib/features/tuning/SavedRail.svelte @@ -0,0 +1,143 @@ + + + +{#snippet diffRows()} + {#each rows as item (item.id)} +
+ {item.label} + + {#if item.dirty} + {item.savedValue} + → {item.currentValue} + {:else} + {item.savedValue} + {/if} + +
+ {/each} +{/snippet} + +{#snippet actionButtons()} + + +{/snippet} + + + +{#if dirtyCount > 0} +
+ {#if barExpanded} +
+ {@render diffRows()} +
+ + 3s · nothing saved +
+
+ {/if} +
+ +
+ {@render actionButtons()} +
+
+
+{/if} diff --git a/web/src/lib/features/tuning/TuningCanvas.svelte b/web/src/lib/features/tuning/TuningCanvas.svelte index 945c1e3..3ae20f5 100644 --- a/web/src/lib/features/tuning/TuningCanvas.svelte +++ b/web/src/lib/features/tuning/TuningCanvas.svelte @@ -4,43 +4,48 @@ never by control type. Extra width grows the trigger-curve instruments only; small columns and sliders keep intrinsic caps so no region stretches dead. - Task 7 wraps `.canvas-grid` in a `.work-and-rail` flex container and docks - the saved rail beside it; keep `.canvas-grid` a direct child here. + Task 7: `.canvas-grid` lives inside the `.work-and-rail` flex wrapper with + the saved rail (slot "rail") docked beside it. The rail is furniture — fixed + width, sticky, outside the column wrap; only the grid reflows. The `below` slot parks content that has not been re-homed yet (Tasks 8-10 move it); nothing previously rendered may be lost. -->
-
-
-
Brake · L2
-
- -
-
- -
-
Throttle · R2
-
- -
-
- -
-
Road feel
-
- -
-

More effects appear here for games that send them.

-
- -
-
Lights
-
- -
-
+
+
+
+
Brake · L2
+
+ +
+
+ +
+
Throttle · R2
+
+ +
+
+ +
+
Road feel
+
+ +
+

More effects appear here for games that send them.

+
+ +
+
Lights
+
+ +
+
+
+ +
{#if $$slots.below} diff --git a/web/src/lib/features/tuning/TuningHeader.svelte b/web/src/lib/features/tuning/TuningHeader.svelte index 9b68c3c..1401c61 100644 --- a/web/src/lib/features/tuning/TuningHeader.svelte +++ b/web/src/lib/features/tuning/TuningHeader.svelte @@ -1,6 +1,7 @@ @@ -262,8 +253,8 @@ type="button" bind:this={gameTrigger} aria-haspopup="menu" - aria-expanded={openMenu === 'game'} - onclick={() => void openMenuFor('game')} + aria-expanded={gameMenuOpen} + onclick={() => void toggleGameMenu()} > {gameModel.title} @@ -287,16 +278,16 @@ type="button" bind:this={profileTrigger} aria-haspopup="menu" - aria-expanded={openMenu === 'profile'} + aria-expanded={profileMenuOpen} disabled={!profiles.length} - onclick={() => void openMenuFor('profile')} + onclick={toggleProfileMenu} > Profile: {profileName} - {#if profileConfigDirty} - · unsaved changes + {#if unsavedNote} + {unsavedNote} {/if}
@@ -313,51 +304,41 @@
-{#if saveAsOpen || renameProfileId} -
- {#if saveAsOpen} - - - - {:else} - - - - {/if} -
-{/if} + -{#if openMenu === 'game'} +{#if gameMenuOpen} -{#if dirtyCount > 0} +{#if anyDirty}
{#if barExpanded}
diff --git a/web/src/lib/features/tuning/savedDiff.ts b/web/src/lib/features/tuning/savedDiff.ts index 439bb11..fc379eb 100644 --- a/web/src/lib/features/tuning/savedDiff.ts +++ b/web/src/lib/features/tuning/savedDiff.ts @@ -11,6 +11,11 @@ // `profileConfigDirty` flag in App.svelte. import { + DEFAULT_BODY_FEEL, + DEFAULT_BODY_RUMBLE_MODE, + DEFAULT_LIGHTBAR_BRIGHTNESS, + DEFAULT_LIGHTBAR_COLOR, + DEFAULT_REDLINE_COLOR, defaultTriggerCurve, normalizeStickDeadzone, normalizeTriggerCurve, @@ -151,10 +156,15 @@ const tuningGroupRow = ( changed += 1; } } + if (changed > 0) { + // Group rows summarize many fields; there is no single saved value to + // strike through, so both sides carry the same "N of M edited" text and + // the rail renders it once, without a strikethrough. + const edited = `${changed} of ${keys.length} edited`; + return row(id, label, edited, edited, true); + } const clean = `${keys.length} settings`; - return changed > 0 - ? row(id, label, 'saved', `${changed} edited`, true) - : row(id, label, clean, clean, false); + return row(id, label, clean, clean, false); }; const effectValue = ( @@ -224,27 +234,27 @@ export const savedDiffRows = ( row( 'body-feel', 'Body feel', - saved.trigger.vibrationMode ?? 'Balanced', + saved.trigger.vibrationMode ?? DEFAULT_BODY_FEEL, draft.vibrationMode ), row( 'lightbar', 'Lightbar', saved.lightbar?.enabled ?? true - ? `On · ${normalizeTriggerPercent(saved.lightbar?.brightness ?? 72)}%` + ? `On · ${normalizeTriggerPercent(saved.lightbar?.brightness ?? DEFAULT_LIGHTBAR_BRIGHTNESS)}%` : 'Off', draft.lightbarEnabled ? `On · ${normalizeTriggerPercent(draft.lightbarBrightness)}%` : 'Off' ), row( 'lightbar-color', 'Lightbar color', - (saved.lightbar?.color ?? '#4cc9f0').toLowerCase(), + (saved.lightbar?.color ?? DEFAULT_LIGHTBAR_COLOR).toLowerCase(), draft.lightbarColor.toLowerCase() ), row( 'redline-color', 'Redline color', - (saved.lightbar?.rpmColor ?? '#ff3a2e').toLowerCase(), + (saved.lightbar?.rpmColor ?? DEFAULT_REDLINE_COLOR).toLowerCase(), draft.rpmColor.toLowerCase() ), row( @@ -262,7 +272,7 @@ export const savedDiffRows = ( ]; if (options.includeForza) { - const savedMode = saved.forza?.bodyRumbleMode ?? 'native_passthrough'; + const savedMode = saved.forza?.bodyRumbleMode ?? DEFAULT_BODY_RUMBLE_MODE; const modeLabel = (mode: ForzaBodyRumbleMode) => mode === 'dscc_full_control' ? 'DSCC full control' : 'Game native'; rows.push(row('rumble-source', 'Rumble source', modeLabel(savedMode), modeLabel(draft.forzaBodyRumbleMode))); From c66516da206f0ce9c6469c7e1f42cdd2b4affa8f Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 21:49:45 -0500 Subject: [PATCH 16/23] ui-rework: per-game setup guide with passive verification and chip re-entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a game that has never produced telemetry swaps the tuning canvas for its setup walkthrough (a canvas state, not a pop-up): numbered steps with exact Data Out menu path and copy buttons for IP/port, plus a LISTENING box bound to live telemetry that flips green and swaps the canvas in without a click. Zero-setup games get a "No setup needed" reassurance with a pre-tune offer. Verification persists per game id in localStorage; once verified the guide only re-opens manually — via the now-clickable telemetry chip (FRESH · setup ↗ / QUIET · fix ↗ / neutral / one-time-setup variants), the game dropdown's setup entry, or the Status needs-attention deep link. Telemetry loss never yanks the canvas; it only flags Status and the chip. Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 100 ++++++ web/src/app/setupVerification.ts | 42 +++ web/src/lib/features/status/StatusView.svelte | 11 +- web/src/lib/features/tuning/SetupGuide.svelte | 194 +++++++++++ .../lib/features/tuning/TuningHeader.svelte | 70 +++- web/src/lib/features/tuning/gameSelect.ts | 19 +- .../lib/features/tuning/setupRequirements.ts | 108 ++++++ web/src/styles/tuning.css | 324 ++++++++++++++++++ 8 files changed, 848 insertions(+), 20 deletions(-) create mode 100644 web/src/app/setupVerification.ts create mode 100644 web/src/lib/features/tuning/SetupGuide.svelte create mode 100644 web/src/lib/features/tuning/setupRequirements.ts diff --git a/web/src/App.svelte b/web/src/App.svelte index 3345016..717b647 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -86,6 +86,13 @@ import TriggerCurvesPanel from './lib/features/haptics/TriggerCurvesPanel.svelte'; import TuningCanvas from './lib/features/tuning/TuningCanvas.svelte'; import SavedRail from './lib/features/tuning/SavedRail.svelte'; + import SetupGuide from './lib/features/tuning/SetupGuide.svelte'; + import { telemetryPortFromAdapter } from './lib/features/tuning/setupRequirements'; + import { + loadVerifiedSetupGameIds, + markSetupVerified, + type VerifiedSetupGameIds + } from './app/setupVerification'; import { savedDiffRows, unsavedChangeCount } from './lib/features/tuning/savedDiff'; import { clampUnit, @@ -283,6 +290,13 @@ let updateDismissalLoaded = false; let onboardingOpen = false; let onboardingLoaded = false; + // Per-game setup state (Task 8). `verifiedSetupGameIds` mirrors the + // persisted "packets seen at least once" flags; `setupGuideManual` tracks a + // deliberate re-entry (chip, dropdown, Status deep-link) and resets when the + // selected game changes. Unverified games pin the guide open on their own. + let verifiedSetupGameIds: VerifiedSetupGameIds = loadVerifiedSetupGameIds(); + let setupGuideManual = false; + let setupGuideGameTracker = ''; let renameProfileId = ''; let renameProfileName = ''; let profileRenameBusy = false; @@ -521,6 +535,70 @@ $: steamInputStatus = snapshot?.steamInput; $: inputBridgeStatus = snapshot?.inputBridge; $: telemetryPacketRate = adapter?.packetRateHz ?? 0; + // --- per-game setup guide state (Task 8) -------------------------------- + $: telemetryPort = telemetryPortFromAdapter(adapter); + $: selectedGameSetupVerified = + selectedTuningScope !== 'game' || !selectedTuningGame + ? true + : Boolean(verifiedSetupGameIds[selectedTuningGame.gameId]); + // Fresh Telemetry attributed to the selected game: it must be the game the + // agent actually detected, with its Telemetry Adapter running and packets + // arriving. Telemetry loss never yanks the canvas — it only flips the chip + // to Quiet and surfaces a Status finding. + $: selectedGameTelemetryFresh = Boolean( + selectedTuningScope === 'game' && + selectedTuningGame?.running && + selectedTuningGame.supportLevel === 'telemetry' && + snapshot?.gameDetection.activeGameId === selectedTuningGame.gameId && + adapter?.state === 'running' && + telemetryPacketRate > 0 + ); + $: if (selectedTuningGameId !== setupGuideGameTracker) { + setupGuideGameTracker = selectedTuningGameId; + setupGuideManual = false; + } + $: showSetupGuide = + showTuningView && + tuningReady && + selectedTuningScope === 'game' && + Boolean(selectedTuningGame) && + (setupGuideManual || !selectedGameSetupVerified); + + const markSelectedGameSetupVerified = () => { + const gameId = selectedTuningScope === 'game' ? (selectedTuningGame?.gameId ?? '') : ''; + if (!gameId) return; + verifiedSetupGameIds = markSetupVerified(verifiedSetupGameIds, gameId); + }; + + // Passive completion: SetupGuide calls this when the first packets arrive + // (or via "Start tuning" on the zero-setup variant). The canvas swaps in + // because the unverified pin disappears — no click required. + const completeSetupGuide = () => { + markSelectedGameSetupVerified(); + setupGuideManual = false; + }; + + const toggleSetupGuide = () => { + if (!selectedGameSetupVerified) return; // pinned open until verified + setupGuideManual = !setupGuideManual; + }; + + const openSetupGuide = () => { + if (selectedTuningScope !== 'game') return; + setupGuideManual = true; + }; + + // Status → Needs attention deep-link: land on #/tuning with the guide open + // for the detected game. + const openSetupGuideFromStatus = async () => { + const game = selectedGame ?? selectedTuningGame ?? null; + if (!game) { + navigateToView('tuning'); + return; + } + await selectTuningGame(game); + setupGuideManual = true; + }; $: telemetryRateText = `${telemetryPacketRate >= 100 ? telemetryPacketRate.toFixed(0) : telemetryPacketRate.toFixed(1)} Hz`; $: telemetryRateDetail = telemetryRateStatusText(adapter); $: systemReadoutTitle = selectedTuningScope === 'global' ? 'Profile Scope' : 'Telemetry Rate'; @@ -2244,6 +2322,7 @@ onSubmitRename={submitControllerRename} onCancelRename={cancelControllerRename} onRenameKeydown={handleControllerRenameKeydown} + onOpenSetupGuide={openSetupGuideFromStatus} /> {/if} {#if showTuningView} @@ -2253,6 +2332,10 @@ {discoveredGames} adapterRunning={adapter?.state === 'running'} packetRateHz={telemetryPacketRate} + setupVerified={selectedGameSetupVerified} + setupGuideOpen={showSetupGuide} + onToggleSetupGuide={toggleSetupGuide} + onOpenSetupGuide={openSetupGuide} {controller} profiles={profileContextProfiles} {activeProfileId} @@ -2400,6 +2483,22 @@ {forzaIntensityFromPercent} /> {/snippet} + {#if showSetupGuide && selectedTuningGame} + + + {:else} {@render triggerCurveEditor('L2')} @@ -2516,6 +2615,7 @@ {/if} + {/if} {/if} {/if} diff --git a/web/src/app/setupVerification.ts b/web/src/app/setupVerification.ts new file mode 100644 index 0000000..fab0881 --- /dev/null +++ b/web/src/app/setupVerification.ts @@ -0,0 +1,42 @@ +// Per-game setup verification flags. A game is "verified" once telemetry +// packets have been seen for it at least once; after that the setup guide +// never auto-shows again (re-entry stays manual via the telemetry chip or the +// game dropdown). Persists in localStorage alongside the other UI preferences +// (see onboardingState.ts / updateState.ts for the same pattern). + +const SETUP_VERIFIED_KEY = 'dscc-setup-verified-v1'; + +export type VerifiedSetupGameIds = Record; + +export function loadVerifiedSetupGameIds(): VerifiedSetupGameIds { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(SETUP_VERIFIED_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const next: VerifiedSetupGameIds = {}; + for (const key of Object.keys(parsed)) { + if ((parsed as Record)[key]) next[key] = true; + } + return next; + } catch { + return {}; + } +} + +export function markSetupVerified( + current: VerifiedSetupGameIds, + gameId: string +): VerifiedSetupGameIds { + if (!gameId || current[gameId]) return current; + const next: VerifiedSetupGameIds = { ...current, [gameId]: true }; + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(SETUP_VERIFIED_KEY, JSON.stringify(next)); + } catch { + // Verification is a convenience flag; the guide simply auto-shows again. + } + } + return next; +} diff --git a/web/src/lib/features/status/StatusView.svelte b/web/src/lib/features/status/StatusView.svelte index b9f23c3..ff3ec65 100644 --- a/web/src/lib/features/status/StatusView.svelte +++ b/web/src/lib/features/status/StatusView.svelte @@ -22,7 +22,8 @@ onBeginRename = () => {}, onSubmitRename = () => {}, onCancelRename = () => {}, - onRenameKeydown = () => {} + onRenameKeydown = () => {}, + onOpenSetupGuide = () => {} }: { controllers?: ControllerStatus[]; controller?: ControllerStatus | undefined; @@ -40,6 +41,7 @@ onSubmitRename?: () => void | Promise; onCancelRename?: () => void; onRenameKeydown?: (event: KeyboardEvent) => void; + onOpenSetupGuide?: () => void | Promise; } = $props(); type Finding = { id: string; text: string; detail?: string }; @@ -248,6 +250,13 @@ {#if finding.detail}
{finding.detail}
{/if} + {#if finding.id === 'telemetry-quiet'} + + {/if}
{/each} {:else} diff --git a/web/src/lib/features/tuning/SetupGuide.svelte b/web/src/lib/features/tuning/SetupGuide.svelte new file mode 100644 index 0000000..9a87c12 --- /dev/null +++ b/web/src/lib/features/tuning/SetupGuide.svelte @@ -0,0 +1,194 @@ + + +
+ {#if model.required} +
+

One-time setup for {game.name}

+

+ {#if verified} + Already verified — these are the values DSCC is listening with. + {:else} + About 2 minutes, once. Then it's automatic forever. + {/if} +

+
+ +
+
    + {#each model.steps as step, index (step.id)} +
  1. + +
    +
    + {step.title} + + {step.state === 'done' ? ' — done' : step.state === 'now' ? ' — current step' : ''} + +
    +
    + {step.detail}{#if step.menuPath}{step.menuPath}{step.detailAfterPath ?? ''}{/if} +
    + {#if step.copyValues?.length} +
    + {#each step.copyValues as item (item.label)} +
    + {item.label} + {item.value} + +
    + {/each} + {#if copyFailed} +
    Copy is blocked in this browser — select the value and copy it yourself.
    + {/if} +
    + {/if} +
    +
  2. + {/each} +
+ + +
+ {:else} +
+ ● No setup needed +

{game.name} is ready when you are

+

+ DSCC detects this game on its own. Start the game and your tuned feel loads with it — + nothing to configure. +

+
+
+

Meanwhile, you can pre-tune its profile with base feel — no game required.

+ +
+ {/if} +
diff --git a/web/src/lib/features/tuning/TuningHeader.svelte b/web/src/lib/features/tuning/TuningHeader.svelte index 1401c61..b76b45e 100644 --- a/web/src/lib/features/tuning/TuningHeader.svelte +++ b/web/src/lib/features/tuning/TuningHeader.svelte @@ -18,6 +18,8 @@ discoveredGames = [], adapterRunning = false, packetRateHz = 0, + setupVerified = true, + setupGuideOpen = false, controller = undefined, profiles = [], activeProfileId = '', @@ -38,6 +40,8 @@ onSelectGlobal = () => {}, onSelectGame = () => {}, onOpenAddGame = () => {}, + onToggleSetupGuide = () => {}, + onOpenSetupGuide = () => {}, onSelectProfile = () => {}, onSaveProfile = () => {}, onBeginSaveAs = () => {}, @@ -58,6 +62,8 @@ discoveredGames?: SupportedGame[]; adapterRunning?: boolean; packetRateHz?: number; + setupVerified?: boolean; + setupGuideOpen?: boolean; controller?: ControllerStatus | undefined; profiles?: ProfileSummary[]; activeProfileId?: string; @@ -78,6 +84,8 @@ onSelectGlobal?: () => void | Promise; onSelectGame?: (game: SupportedGame) => void | Promise; onOpenAddGame?: () => void | Promise; + onToggleSetupGuide?: () => void; + onOpenSetupGuide?: () => void; onSelectProfile?: (profileId: string) => void | Promise; onSaveProfile?: () => void | Promise; onBeginSaveAs?: () => void; @@ -99,14 +107,43 @@ games: discoveredGames, scope, selectedGameId: selectedGame?.gameId ?? '', - setupGuideGame: - scope === 'game' && selectedGame?.supportLevel === 'telemetry' ? selectedGame : null, - setupGuideEnabled: false + setupGuideGame: scope === 'game' ? selectedGame : null, + setupGuideEnabled: true }) ); const chipState = $derived( - telemetryChipState({ scope, selectedGame, adapterRunning, packetRateHz }) + telemetryChipState({ scope, selectedGame, adapterRunning, packetRateHz, setupVerified }) ); + const chipPresentation = $derived.by(() => { + switch (chipState) { + case 'fresh': + return { + label: 'Telemetry Fresh', + suffix: '· setup ↗', + title: 'Game data is arriving — the driving feel is live. Open the setup guide to check the port or re-copy values.' + }; + case 'quiet': + return { + label: 'Telemetry Quiet', + suffix: '· fix ↗', + title: 'The game is running but its data feed is silent. Open the guide to fix it.' + }; + case 'setup': + return { + label: 'One-Time Setup Needed', + suffix: '', + title: "About 2 minutes, once. Then it's automatic forever." + }; + case 'none': + return { + label: 'No Setup Needed', + suffix: '· setup ↗', + title: 'This game needs no telemetry feed. Open the guide for details.' + }; + default: + return null; + } + }); const heroArt = $derived(scope === 'game' ? gameArtwork(selectedGame, 'hero') : null); const thumbArt = $derived(scope === 'game' ? gameArtwork(selectedGame, 'banner') : null); @@ -217,10 +254,10 @@ }); const pickGameEntry = (entry: GameSelectEntry) => { - if (entry.kind === 'setup-guide') return; closeGameMenu(); if (entry.kind === 'everyday') void onSelectGlobal(); else if (entry.kind === 'game') void onSelectGame(entry.game); + else if (entry.kind === 'setup-guide') onOpenSetupGuide(); else if (entry.kind === 'add-game') void onOpenAddGame(); }; @@ -259,17 +296,22 @@ {gameModel.title} - {#if chipState} - - ● {chipState === 'fresh' ? 'Telemetry Fresh' : 'Telemetry Quiet'} - + ● {chipPresentation.label}{#if chipPresentation.suffix} + {chipPresentation.suffix} + {/if} + {/if}
@@ -384,7 +426,7 @@ type="button" role="menuitem" disabled={!entry.enabled} - title="The setup walkthrough opens here in an upcoming update." + onclick={() => pickGameEntry(entry)} > {entry.label} diff --git a/web/src/lib/features/tuning/gameSelect.ts b/web/src/lib/features/tuning/gameSelect.ts index 3f894d1..7226e3f 100644 --- a/web/src/lib/features/tuning/gameSelect.ts +++ b/web/src/lib/features/tuning/gameSelect.ts @@ -115,21 +115,30 @@ export function headerTitle(options: { return 'Everyday (no game)'; } -export type TelemetryChipState = 'fresh' | 'quiet' | null; +export type TelemetryChipState = 'fresh' | 'quiet' | 'setup' | 'none' | null; /** - * Telemetry chip state for the header band. Only games with telemetry - * support that are actually running get a chip; Fresh means packets are - * arriving, Quiet means the game is up but its data feed is silent. + * Telemetry chip state for the header band. The chip is the door back into + * the setup guide: + * - `fresh` (green): packets are arriving for the running game. + * - `quiet` (yellow): the game is up but its data feed is silent. + * - `setup` (accent): a telemetry game that has never verified — one-time + * setup is in progress. + * - `none` (neutral): the selected game needs no telemetry feed at all. */ export function telemetryChipState(options: { scope: TuningScopeKind; selectedGame: SupportedGame | null; adapterRunning: boolean; packetRateHz: number; + /** Telemetry packets have been seen for this game at least once. */ + setupVerified: boolean; }): TelemetryChipState { if (options.scope !== 'game') return null; const game = options.selectedGame; - if (!game || !game.running || game.supportLevel !== 'telemetry') return null; + if (!game) return null; + if (game.supportLevel !== 'telemetry') return 'none'; + if (!options.setupVerified) return 'setup'; + if (!game.running) return null; return options.adapterRunning && options.packetRateHz > 0 ? 'fresh' : 'quiet'; } diff --git a/web/src/lib/features/tuning/setupRequirements.ts b/web/src/lib/features/tuning/setupRequirements.ts new file mode 100644 index 0000000..22b930d --- /dev/null +++ b/web/src/lib/features/tuning/setupRequirements.ts @@ -0,0 +1,108 @@ +import type { AdapterStatus, SupportedGame } from '../../types'; + +// Pure setup-walkthrough model for the Tuning canvas (Task 8). Each Game +// Module's metadata plus live telemetry state derive `{required, steps, +// verified}` — no fetching, no storage, no component state. Persistence of +// "verified once" lives in app/setupVerification.ts. + +export const TELEMETRY_TARGET_IP = '127.0.0.1'; +export const DEFAULT_TELEMETRY_PORT = 5300; + +export type SetupStepState = 'done' | 'now' | 'todo'; + +export type SetupCopyValue = { + label: string; + value: string; +}; + +export type SetupStep = { + id: 'found' | 'data-out' | 'drive'; + state: SetupStepState; + title: string; + detail: string; + /** Exact in-game menu path, rendered emphasized before `detailAfterPath`. */ + menuPath?: string; + detailAfterPath?: string; + copyValues?: SetupCopyValue[]; +}; + +export type SetupModel = { + /** False for games DSCC reads without any in-game configuration. */ + required: boolean; + /** Telemetry packets have been seen for this game at least once. */ + verified: boolean; + /** Live freshness: packets are arriving right now. */ + fresh: boolean; + port: number; + steps: SetupStep[]; +}; + +/** + * The UDP port the active Telemetry Adapter listens on. The agent reports its + * bound address inside the adapter's config string (for example + * `127.0.0.1:5300`); fall back to the well-known Forza Data Out default when + * no adapter or no port is visible (the mock fixture, early startup). + */ +export function telemetryPortFromAdapter(adapter: AdapterStatus | null | undefined): number { + for (const text of [adapter?.config, adapter?.setupHint]) { + const match = text?.match(/:(\d{2,5})(?!\d)/); + if (match) { + const port = Number(match[1]); + if (Number.isInteger(port) && port > 0 && port <= 65535) return port; + } + } + return DEFAULT_TELEMETRY_PORT; +} + +const foundDetail = (game: SupportedGame): string => { + if (game.running) return 'Running now.'; + if (game.source === 'local_app') return 'Added as a local app.'; + if (game.installed) return 'Installed via Steam.'; + return 'DSCC spots the game on its own once it starts.'; +}; + +export function deriveSetupModel(options: { + game: SupportedGame; + /** Fresh Telemetry attributed to this game (it is the detected game). */ + telemetryFresh: boolean; + /** Packets were seen for this game at least once (persisted). */ + verified: boolean; + port?: number; +}): SetupModel { + const { game, telemetryFresh, verified } = options; + const port = options.port ?? DEFAULT_TELEMETRY_PORT; + + if (game.supportLevel !== 'telemetry') { + return { required: false, verified, fresh: telemetryFresh, port, steps: [] }; + } + + const found = game.installed || game.running; + const steps: SetupStep[] = [ + { + id: 'found', + state: found ? 'done' : 'now', + title: `${game.name} found`, + detail: foundDetail(game) + }, + { + id: 'data-out', + state: telemetryFresh ? 'done' : found ? 'now' : 'todo', + title: "Turn on the game's telemetry feed", + detail: `In ${game.name}: `, + menuPath: 'Settings → HUD and Gameplay → Data Out', + detailAfterPath: '. Some versions call it UDP Race Telemetry. Set:', + copyValues: [ + { label: 'IP address', value: TELEMETRY_TARGET_IP }, + { label: 'Port', value: String(port) } + ] + }, + { + id: 'drive', + state: telemetryFresh ? 'done' : 'todo', + title: 'Drive', + detail: 'Get in a car. The moment data arrives, this page becomes the tuning canvas.' + } + ]; + + return { required: true, verified, fresh: telemetryFresh, port, steps }; +} diff --git a/web/src/styles/tuning.css b/web/src/styles/tuning.css index 2535398..f776ebc 100644 --- a/web/src/styles/tuning.css +++ b/web/src/styles/tuning.css @@ -120,6 +120,36 @@ color: var(--warn); } +.tuning-telemetry-chip.setup { + background: var(--accent-tint); + color: var(--accent-text); +} + +.tuning-telemetry-chip.none { + background: var(--surface-raised); + color: var(--ink-muted); +} + +/* The chip is the door back into the setup guide (Task 8). */ +.tuning-telemetry-chip.clickable { + border: 0; + font-family: inherit; + cursor: pointer; + transition: filter var(--speed) var(--ease); +} + +.tuning-telemetry-chip.clickable:hover, +.tuning-telemetry-chip.clickable:focus-visible { + filter: brightness(1.3); +} + +.tuning-chip-suffix { + color: var(--ink-muted); + font-weight: 500; + text-transform: none; + letter-spacing: normal; +} + .tuning-header-profile-row { margin-top: 2px; display: flex; @@ -693,6 +723,300 @@ border-radius: var(--radius-m); } +/* --- per-game setup guide (Task 8; layout truth game-setup.html) ------------ + + A canvas state, not a pop-up: replaces the tuning grid (rail included) + until the game's requirements verify. Numbered steps because setup IS a + sequence; the LISTENING box flips green passively when packets arrive. */ + +.setup-guide { + padding: 16px; + max-width: 980px; +} + +.setup-guide-head h2 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--ink); +} + +.setup-guide-sub { + margin: 3px 0 0; + font-size: 11px; + color: var(--ink-muted); +} + +.setup-guide-grid { + display: flex; + gap: 26px; + margin-top: 16px; + flex-wrap: wrap; +} + +.setup-steps { + flex: 1 1 380px; + min-width: 280px; + max-width: 560px; + margin: 0; + padding: 0; + list-style: none; +} + +.setup-step { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.setup-stepnum { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--surface-raised); + color: var(--ink-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + flex-shrink: 0; +} + +.setup-stepnum.done { + background: var(--ok-tint); + color: var(--ok); +} + +.setup-stepnum.now { + background: var(--accent); + color: #fff; +} + +.setup-step-body { + flex: 1; + min-width: 0; + padding-bottom: 14px; + border-left: 1px solid var(--hairline); + margin-left: -15px; + padding-left: 24px; +} + +.setup-step:last-child .setup-step-body { + border-left-color: transparent; + padding-bottom: 0; +} + +.setup-step-title { + font-size: 12px; + font-weight: 600; + color: var(--ink); +} + +.setup-step-title.settled { + color: var(--ink-muted); +} + +.setup-step-detail { + margin-top: 3px; + font-size: 11px; + color: var(--ink-muted); +} + +.setup-step-detail b { + color: var(--ink); +} + +.setup-copy-card { + margin-top: 8px; + padding: 9px 12px; + display: inline-block; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-m); +} + +.setup-copy-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--ink); +} + +.setup-copy-row + .setup-copy-row { + margin-top: 5px; +} + +.setup-copy-label { + min-width: 72px; + color: var(--ink-muted); +} + +.setup-kbd { + background: var(--surface-raised); + border: 1px solid var(--hairline-strong); + border-radius: 4px; + padding: 1px 6px; + font-family: ui-monospace, 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--ink); + user-select: all; +} + +.setup-copy-button { + background: none; + border: 0; + padding: 0; + font-family: inherit; + font-size: 10px; + color: var(--accent-text); + cursor: pointer; +} + +.setup-copy-button:hover, +.setup-copy-button:focus-visible { + text-decoration: underline; +} + +.setup-copy-fallback { + margin-top: 6px; + font-size: 10px; + color: var(--warn); +} + +/* LISTENING box: bound to live telemetry; flips to --ok on its own. */ +.setup-listening { + flex: 0 1 280px; + min-width: 250px; + align-self: flex-start; + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-m); + transition: border-color var(--speed) var(--ease); +} + +.setup-listening.ok { + border-color: var(--ok); +} + +.setup-lbl { + font-size: 10px; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.setup-listening-line { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 11px; + color: var(--ink); +} + +.setup-listening-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-bright); + flex: 0 0 auto; +} + +.setup-listening.ok .setup-listening-dot { + background: var(--ok); +} + +.setup-listening-note { + margin: 6px 0 0; + font-size: 10px; + color: var(--ink-muted); +} + +.setup-listening.ok .setup-listening-note { + color: var(--ok); +} + +.setup-stuck { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--hairline); +} + +.setup-stuck summary { + font-size: 10px; + color: var(--accent-text); + cursor: pointer; +} + +.setup-stuck ul { + margin: 8px 0 0; + padding-left: 16px; + font-size: 10px; + color: var(--ink-muted); + display: grid; + gap: 4px; +} + +.setup-adapter-hint { + margin: 8px 0 0; + font-size: 10px; + color: var(--ink-muted); +} + +/* Zero-setup variant: reassurance + pre-tune offer. */ +.setup-zero-chip { + display: inline-flex; + align-items: center; + gap: 5px; + margin-bottom: 8px; + border-radius: 999px; + padding: 3px 10px; + background: var(--ok-tint); + color: var(--ok); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.setup-pretune { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 14px; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-m); +} + +.setup-pretune p { + margin: 0; + font-size: 11px; + color: var(--ink-muted); +} + +.setup-pretune-button { + background: var(--accent); + color: #fff; + border: 0; + border-radius: var(--radius-s); + padding: 6px 14px; + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.setup-pretune-button:hover, +.setup-pretune-button:focus-visible { + filter: brightness(1.15); +} + @media (max-width: 480px) { .tuning-header-content { padding: 10px 12px 8px; From 0a4f8ef8e42c8ac3ad94c6f7370afd2cb3b762bf Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 22:06:35 -0500 Subject: [PATCH 17/23] ui-rework: setup guide review fixes (deep-link race, chip affordance, a11y) Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 1 + web/src/lib/features/tuning/SetupGuide.svelte | 10 ++++++++-- .../lib/features/tuning/TuningHeader.svelte | 3 ++- .../lib/features/tuning/setupRequirements.ts | 19 +++++++++++++++---- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 717b647..25ebe3a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -597,6 +597,7 @@ return; } await selectTuningGame(game); + setupGuideGameTracker = game.gameId; setupGuideManual = true; }; $: telemetryRateText = `${telemetryPacketRate >= 100 ? telemetryPacketRate.toFixed(0) : telemetryPacketRate.toFixed(1)} Hz`; diff --git a/web/src/lib/features/tuning/SetupGuide.svelte b/web/src/lib/features/tuning/SetupGuide.svelte index 9a87c12..2b2ae8d 100644 --- a/web/src/lib/features/tuning/SetupGuide.svelte +++ b/web/src/lib/features/tuning/SetupGuide.svelte @@ -87,6 +87,12 @@ }, 1600); }; + $effect(() => { + return () => { + window.clearTimeout(copyResetTimer); + }; + }); + const stepNumber = (index: number) => index + 1; @@ -104,7 +110,7 @@
-
    +
      {#each model.steps as step, index (step.id)}
{/each} {#if copyFailed} -
Copy is blocked in this browser — select the value and copy it yourself.
+
Copy is blocked in this browser — select the value and copy it yourself.
{/if}
{/if} diff --git a/web/src/lib/features/tuning/TuningHeader.svelte b/web/src/lib/features/tuning/TuningHeader.svelte index b76b45e..7b43f55 100644 --- a/web/src/lib/features/tuning/TuningHeader.svelte +++ b/web/src/lib/features/tuning/TuningHeader.svelte @@ -304,7 +304,8 @@ class:setup={chipState === 'setup'} class:none={chipState === 'none'} type="button" - title={chipPresentation.title} + disabled={!setupVerified} + title={!setupVerified ? 'The setup guide is open below — it closes by itself once setup verifies.' : chipPresentation.title} aria-pressed={setupGuideOpen} onclick={onToggleSetupGuide} > diff --git a/web/src/lib/features/tuning/setupRequirements.ts b/web/src/lib/features/tuning/setupRequirements.ts index 22b930d..622cd0d 100644 --- a/web/src/lib/features/tuning/setupRequirements.ts +++ b/web/src/lib/features/tuning/setupRequirements.ts @@ -44,10 +44,21 @@ export type SetupModel = { * no adapter or no port is visible (the mock fixture, early startup). */ export function telemetryPortFromAdapter(adapter: AdapterStatus | null | undefined): number { - for (const text of [adapter?.config, adapter?.setupHint]) { - const match = text?.match(/:(\d{2,5})(?!\d)/); - if (match) { - const port = Number(match[1]); + // Try address shape first: ipv4:port (e.g., 127.0.0.1:5300) + const config = adapter?.config; + if (config) { + const addressMatch = config.match(/(?:\d{1,3}\.){3}\d{1,3}:(\d{1,5})/); + if (addressMatch) { + const port = Number(addressMatch[1]); + if (Number.isInteger(port) && port > 0 && port <= 65535) return port; + } + } + // Fall back to hint field if config didn't match + const hint = adapter?.setupHint; + if (hint) { + const addressMatch = hint.match(/(?:\d{1,3}\.){3}\d{1,3}:(\d{1,5})/); + if (addressMatch) { + const port = Number(addressMatch[1]); if (Number.isInteger(port) && port > 0 && port <= 65535) return port; } } From 596e59c6edb1c685e0e9770bd7b4c0fe4836ce83 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 22:20:04 -0500 Subject: [PATCH 18/23] ui-rework: advanced section (controller details, edge slots, button mapping reskin) Co-Authored-By: Claude Fable 5 --- web/src/App.svelte | 44 +- web/src/app/navigation.ts | 5 + web/src/components/AppSidebar.svelte | 6 +- .../controllers/ControllersView.svelte | 603 +++++++----------- .../features/controllers/EdgeSlotsView.svelte | 94 +++ web/src/styles/button-mapping/base.css | 20 +- web/src/styles/button-mapping/editor.css | 34 +- .../button-mapping/layout/controller-art.css | 14 +- .../button-mapping/layout/inspector.css | 10 +- .../styles/button-mapping/layout/panels.css | 6 +- web/src/styles/button-mapping/mirror.css | 12 +- web/src/styles/button-mapping/stage.css | 4 +- web/src/styles/controllers.css | 511 ++++++++------- 13 files changed, 680 insertions(+), 683 deletions(-) create mode 100644 web/src/lib/features/controllers/EdgeSlotsView.svelte diff --git a/web/src/App.svelte b/web/src/App.svelte index 25ebe3a..6ccd498 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -80,6 +80,7 @@ type TuningScope } from './app/profileWorkspace'; import ControllersView from './lib/features/controllers/ControllersView.svelte'; + import EdgeSlotsView from './lib/features/controllers/EdgeSlotsView.svelte'; import GlobalFeelPanel from './lib/features/haptics/GlobalFeelPanel.svelte'; import LightbarControls from './lib/features/haptics/LightbarControls.svelte'; import TelemetryRoutingPanel from './lib/features/haptics/TelemetryRoutingPanel.svelte'; @@ -499,8 +500,9 @@ $: selectedTuningGame = profileWorkspace.selectedTuningGame; $: tuningReady = profileWorkspace.tuningReady; $: buttonMappingReady = profileWorkspace.buttonMappingReady; + $: edgeSlotsReady = isEdgeTargetController(controller); $: { - const guardedView = guardView(activeView, { tuningReady, buttonMappingReady }); + const guardedView = guardView(activeView, { tuningReady, buttonMappingReady, edgeSlotsReady }); if (guardedView !== activeView) { activeView = guardedView; setViewHash(guardedView); @@ -523,15 +525,16 @@ $: activeProfileHeaderName = profileWorkspace.activeProfileHeaderName; $: activeProfileHeaderMeta = profileWorkspace.activeProfileHeaderMeta; $: buttonMappingActive = activeView === 'advancedButtonMapping'; - // Temporary view composition (Tasks 6-9 replace the rest): 'status' renders the - // new StatusView; 'tuning' renders the TuningHeader plus the haptics workspace; - // 'advancedEdgeSlots' renders ControllersView because the Edge onboard slots UI - // currently lives inside it. Views the guard rejects land on 'status'. - $: showAdvancedControllerView = activeView === 'advancedController' || activeView === 'advancedEdgeSlots'; + // View composition: each route renders its own view; views the guard rejects + // land on 'status' (Edge onboard slots land on Controller details instead). + $: showAdvancedControllerView = activeView === 'advancedController'; + $: showEdgeSlotsView = activeView === 'advancedEdgeSlots'; $: showStatusView = activeView === 'status'; $: showTuningView = activeView === 'tuning'; $: showWorkspaceViews = - showAdvancedControllerView || (tuningReady && (activeView === 'tuning' || activeView === 'advancedButtonMapping')); + showAdvancedControllerView || + showEdgeSlotsView || + (tuningReady && (activeView === 'tuning' || activeView === 'advancedButtonMapping')); $: steamInputStatus = snapshot?.steamInput; $: inputBridgeStatus = snapshot?.inputBridge; $: telemetryPacketRate = adapter?.packetRateHz ?? 0; @@ -731,7 +734,7 @@ const appViewFromHash = (): AppView => { if (typeof window === 'undefined') return 'status'; - return viewFromHash(window.location.hash, { tuningReady, buttonMappingReady }); + return viewFromHash(window.location.hash, { tuningReady, buttonMappingReady, edgeSlotsReady }); }; const setViewHash = (view: AppView) => { @@ -747,7 +750,7 @@ }; const navigateToView = (view: AppView) => { - view = guardView(view, { tuningReady, buttonMappingReady }); + view = guardView(view, { tuningReady, buttonMappingReady, edgeSlotsReady }); activeView = view; setViewHash(view); }; @@ -2166,7 +2169,7 @@
{#snippet footer()} @@ -2389,12 +2392,6 @@ inputBridge={snapshot?.inputBridge ?? null} activeGameName={selectedGame?.name ?? null} activeInputProvider={selectedGame?.inputProvider ?? currentControllerConfig?.inputMode ?? 'native_dualsense'} - {edgeProfiles} - {edgeProfilesLoading} - {edgeProfilesBusySlot} - {edgeProfilesError} - {edgeSlotsReadTooltip} - edgeSlotWriteLabel={edgeSlotWriteLabel()} onSelect={selectTargetController} onBeginRename={beginControllerRename} onSubmitRename={submitControllerRename} @@ -2405,6 +2402,21 @@ onSetStickDeadzone={setStickDeadzone} onStartInputBridge={startControllerInputBridge} onStopInputBridge={stopControllerInputBridge} + {supportBundleBusy} + onDownloadSupportBundle={exportSupportBundle} + /> + {/if} + + {#if showEdgeSlotsView} + controller && void loadEdgeProfiles(controller.id, true)} onWriteEdgeSlot={writeCurrentConfigToEdgeSlot} {edgeSlotName} diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index 54e6bb7..3b98a40 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -10,6 +10,8 @@ export type AppViewDefinition = { export type ViewReadiness = { tuningReady: boolean; buttonMappingReady: boolean; + /** True when the Target Controller is a DualSense Edge. */ + edgeSlotsReady: boolean; }; export const appViews: AppViewDefinition[] = [ @@ -53,6 +55,9 @@ export function hashForView(view: AppView): string { export function guardView(view: AppView, readiness: ViewReadiness): AppView { if (view === 'tuning' && !readiness.tuningReady) return 'status'; if (view === 'advancedButtonMapping' && !readiness.buttonMappingReady) return 'status'; + // Edge onboard slots only exist on a DualSense Edge; direct hash navigation + // lands on Controller details, which explains the selected controller. + if (view === 'advancedEdgeSlots' && !readiness.edgeSlotsReady) return 'advancedController'; return view; } diff --git a/web/src/components/AppSidebar.svelte b/web/src/components/AppSidebar.svelte index c51251b..42f4ea3 100644 --- a/web/src/components/AppSidebar.svelte +++ b/web/src/components/AppSidebar.svelte @@ -26,13 +26,17 @@ const itemDisabled = (id: AppView): boolean => (id === 'tuning' && !readiness.tuningReady) || - (id === 'advancedButtonMapping' && !readiness.buttonMappingReady); + (id === 'advancedButtonMapping' && !readiness.buttonMappingReady) || + (id === 'advancedEdgeSlots' && !readiness.edgeSlotsReady); const itemTooltip = (id: AppView): string => { if (id === 'tuning' && !readiness.tuningReady) return 'Select a controller before tuning haptics.'; if (id === 'advancedButtonMapping' && !readiness.buttonMappingReady) { return 'Select a game or local app scope before editing mappings.'; } + if (id === 'advancedEdgeSlots' && !readiness.edgeSlotsReady) { + return 'Onboard slots are available when the Target Controller is a DualSense Edge.'; + } return viewTooltips[id]; }; diff --git a/web/src/lib/features/controllers/ControllersView.svelte b/web/src/lib/features/controllers/ControllersView.svelte index a269427..d4c9876 100644 --- a/web/src/lib/features/controllers/ControllersView.svelte +++ b/web/src/lib/features/controllers/ControllersView.svelte @@ -1,7 +1,6 @@ -
-
-
- Hardware -

Controllers

-
-
- {#if controllers.length} - {#each controllers as item, index (item.id)} - - {/each} - {:else} -
- No controller detected - Controller unavailable -
- {/if} -
- - {#if controller?.family === 'DualSense Edge'} -
-
-
- Onboard Memory - Edge Slots -
- - - -
- - {#if edgeProfilesError} -

{edgeProfilesError}

- {:else if edgeProfiles?.warning} -

{edgeProfiles.warning}

- {/if} +
+
+

Controller details

+ {alias} · live readouts for checking, not for everyday tuning +
-
- {#if edgeProfiles?.slots.length} - {#each edgeProfiles.slots as slot (slot.slotId)} -
- -
- {slot.shortcut} - {edgeSlotName(slot)} - {edgeSlotStatus(slot)} -
-
- {#if slot.editable} - - - - {/if} -
- {/each} - {:else} -
-
- Fn Slots - {edgeProfilesLoading ? 'Reading slots' : 'No slot data'} - {edgeProfilesLoading ? 'controller scan' : 'unavailable'} -
-
- {/if} +
+ {#if controllers.length > 1} +
+
Controllers
+
+ {#each controllers as item, index (item.id)} + + {/each}
-
+
{/if} -
-
-
-
- Selected - {controllerModelText(controller)} - {statusTone(controller)} + {#if !controller} +
+
Live input
+
+ No controller selected + Connect a DualSense controller to view live input, trigger travel, and calibration readings. +
- {#if controller} -
-
-
Connection
-
{controllerTransportDetail(controller)}
-
-
-
Battery
-
{controllerBatteryDetail(controller)}
+ {:else} +
+
Live input
+
+
+ L2 + {percent(l2Value)}
-
-
Permission
-
{controllerPermissionDetail(controller)}
+ +
+ R2 + {percent(r2Value)}
-
-
Diagnostics
-
{controllerDiagnosticDetail(controller)}
+ +
+ Sticks + {stickDriftLine}
-
-
Sanitized ID
-
{controller.id}
+
+ Feed + {inputFreshness(inputState)}
-
- {/if} -
- - {#if !controller} -
- No controller selected - Connect a DualSense controller to view input routing, live stick plots, trigger travel, and calibration readings. -
- {:else} -
-
-
- Input Path - {inputPathTitle()} -
- {activeGameName ?? 'No active app'} -
-
-
-
Provider
-
{inputPathDetail()}
-
-
-
Duplicate Input
-
{duplicateInputDetail()}
-
-
-
Bridge Session
-
{bridgeSessionState()}
-
-
-
- - - - - - -
- {#if controllerBridgeConfigured && !appRequiresBridge} -

Bridge sessions start only while the selected local app is active.

- {/if} - {#if inputBridge?.warnings.length && inputPathTitle() === 'DSCC Input Bridge'} -

{inputBridge.warnings[0]}

- {/if} -
- -
-
-
- Power Diagnostics - {hasPowerMetrics(controller) ? 'Output Cadence' : 'Awaiting Agent Metrics'} -
- {controller.transport} -
-
-
-
Write Rate
-
{formatHz(powerDiagnostics?.outputWriteRateHz)}
-
-
-
Cadence
-
{formatMs(powerDiagnostics?.outputCadenceMs)}
-
-
-
Suppressed
-
{formatCount(powerDiagnostics?.suppressedRedundantReports)}
-
-
Keepalive
-
{formatMs(powerDiagnostics?.keepaliveIntervalMs)}
-
-
-
Last Write
-
{formatMs(powerDiagnostics?.lastWriteAgeMs)}
-
-
-
Rumble Path
-
{formatFlag(powerDiagnostics?.nativeRumblePassthrough, 'native passthrough', 'DSCC shaped')}
-
-
-
Trigger Policy
-
{formatFlag(powerDiagnostics?.adaptiveTriggersRetained, 'adaptive triggers retained', 'adaptive triggers not retained')}
-
-
-
- {#each powerSuggestions as suggestion} -

{suggestion}

- {/each} -
-
- -
-
-
- Live Input - {inputFresh ? 'Streaming' : 'Unavailable'} -
- {inputFreshness(inputState)} -
-
-
-
- Left Stick - {signedPercent(leftStick.x)} / {signedPercent(leftStick.y)} -
- -
-
-
Drift
-
{percent(leftStick.magnitude)}
+
+
+
+ Left stick + {signedPercent(leftStick.x)} / {signedPercent(leftStick.y)}
-
-
Suggested DZ
-
{suggestedDeadzone(leftStick)}
+ -
-
- Deadzone - void onSetStickDeadzone('left', event.currentTarget.valueAsNumber)} - /> - {leftStickDeadzone}% -
-
- -
-
- Right Stick - {signedPercent(rightStick.x)} / {signedPercent(rightStick.y)} -
- -
-
-
Drift
-
{percent(rightStick.magnitude)}
+
+
Off-center now{percent(leftStick.magnitude)}
+
Suggested deadzone{suggestedDeadzone(leftStick)}
-
-
Suggested DZ
-
{suggestedDeadzone(rightStick)}
+
+ Deadzone + void onSetStickDeadzone('left', event.currentTarget.valueAsNumber)} + /> + {leftStickDeadzone}%
-
-
- Deadzone - void onSetStickDeadzone('right', event.currentTarget.valueAsNumber)} - /> - {rightStickDeadzone}% -
-
-
+ +
+
+ Right stick + {signedPercent(rightStick.x)} / {signedPercent(rightStick.y)} +
+ +
+
Off-center now{percent(rightStick.magnitude)}
+
Suggested deadzone{suggestedDeadzone(rightStick)}
+
+
+ Deadzone + void onSetStickDeadzone('right', event.currentTarget.valueAsNumber)} + /> + {rightStickDeadzone}% +
+
+
-
-
-
- L2 - {percent(l2Value)} -
- -
-
-
- R2 - {percent(r2Value)} -
- -
+
+ {#each visibleButtons as button (button.id)} +
+ {button.label} + {button.id === 'l2' || button.id === 'r2' ? percent(button.value) : button.pressed ? 'ON' : 'OFF'} +
+ {/each} +
-
- {#each visibleButtons as button (button.id)} -
- {button.label} - {button.id === 'l2' || button.id === 'r2' ? percent(button.value) : button.pressed ? 'ON' : 'OFF'} -
- {/each} -
- +
+
Connection
+
+
Controller{controllerModelText(controller)}
+
State{statusTone(controller)}
+
Transport{controllerTransportDetail(controller)}
+
Battery{controllerBatteryDetail(controller)}
+
Permission{controllerPermissionDetail(controller)}
+
Diagnostics{controllerDiagnosticDetail(controller)}
+
Sanitized ID{controller.id}
+
-
-
-
- Calibration - Session Readings +
Input path
+
+
Path{inputPathTitle()}
+
Active app{activeGameName ?? 'No active app'}
+
Provider{inputPathDetail()}
+
Duplicate input{duplicateInputDetail()}
+
Bridge session{bridgeSessionState()}
-
-
-
-
Left Range
-
{stickRange(observed.leftStick)}
+
+ + + + +
-
-
Right Range
-
{stickRange(observed.rightStick)}
+ {#if controllerBridgeConfigured && !appRequiresBridge} +

Bridge sessions start only while the selected local app is active.

+ {/if} + {#if inputBridge?.warnings.length && inputPathTitle() === 'DSCC Input Bridge'} +

{inputBridge.warnings[0]}

+ {/if} +
+ +
+
Power
+
+
Write rate{formatHz(powerDiagnostics?.outputWriteRateHz)}
+
Cadence{formatMs(powerDiagnostics?.outputCadenceMs)}
+
Suppressed writes{formatCount(powerDiagnostics?.suppressedRedundantReports)}
+
Keepalive{formatMs(powerDiagnostics?.keepaliveIntervalMs)}
+
Last write{formatMs(powerDiagnostics?.lastWriteAgeMs)}
+
Rumble path{formatFlag(powerDiagnostics?.nativeRumblePassthrough, 'native passthrough', 'DSCC shaped')}
+
Trigger policy{formatFlag(powerDiagnostics?.adaptiveTriggersRetained, 'adaptive triggers retained', 'adaptive triggers not retained')}
-
-
L2 Range
-
{rangePair(observed.l2Min, observed.l2Max)}
+
+ {#each powerSuggestions as suggestion} +

{suggestion}

+ {/each}
-
-
R2 Range
-
{rangePair(observed.r2Min, observed.r2Max)}
+
+ +
+
Session readings
+
+
Left range{stickRange(observed.leftStick)}
+
Right range{stickRange(observed.rightStick)}
+
L2 range{rangePair(observed.l2Min, observed.l2Max)}
+
R2 range{rangePair(observed.r2Min, observed.r2Max)}
-
-
+

Ranges are observed while this page is open; move the sticks and pull the triggers to fill them in.

+
{/if} + +
+
Support
+
+ Having trouble? +

Download a Support Bundle — sanitized diagnostics, no private data.

+ +
+
diff --git a/web/src/lib/features/controllers/EdgeSlotsView.svelte b/web/src/lib/features/controllers/EdgeSlotsView.svelte new file mode 100644 index 0000000..a3d6eed --- /dev/null +++ b/web/src/lib/features/controllers/EdgeSlotsView.svelte @@ -0,0 +1,94 @@ + + +
+
+

Edge onboard slots

+ {alias} · profiles stored on the controller itself, for checking, not for everyday tuning +
+ +
+
+
+
Onboard memory
+ + + +
+ + {#if edgeProfilesError} +

{edgeProfilesError}

+ {:else if edgeProfiles?.warning} +

{edgeProfiles.warning}

+ {/if} + +
+ {#if edgeProfiles?.slots.length} + {#each edgeProfiles.slots as slot (slot.slotId)} +
+ +
+ {slot.shortcut} + {edgeSlotName(slot)} + {edgeSlotStatus(slot)} +
+
+ {#if slot.editable} + + + + {/if} +
+ {/each} + {:else} +
+
+ Fn Slots + {edgeProfilesLoading ? 'Reading slots' : 'No slot data'} + {edgeProfilesLoading ? 'controller scan' : 'unavailable'} +
+
+ {/if} +
+
+
+
diff --git a/web/src/styles/button-mapping/base.css b/web/src/styles/button-mapping/base.css index 1a99abc..34772df 100644 --- a/web/src/styles/button-mapping/base.css +++ b/web/src/styles/button-mapping/base.css @@ -29,7 +29,7 @@ display: inline-flex; align-items: center; gap: 6px; - color: var(--actuation); + color: var(--accent-text); font-family: "JetBrains Mono", monospace; font-size: 10px; font-weight: 800; @@ -38,7 +38,7 @@ } .dm-mapping-eyebrow em { - color: var(--tungsten); + color: var(--ink-muted); font-style: normal; font-weight: 700; letter-spacing: 0; @@ -60,7 +60,7 @@ flex-wrap: wrap; gap: 8px; margin: 0; - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; line-height: 1.3; text-align: right; @@ -68,18 +68,18 @@ } .dm-mapping-context strong { - color: var(--haptic); + color: var(--ink); font-weight: 700; } .dm-mapping-context em { - color: var(--tungsten); + color: var(--ink-muted); font-style: normal; font-weight: 500; } .dm-mapping-context-count { - color: var(--actuation); + color: var(--accent-text); font-family: "JetBrains Mono", monospace; font-weight: 700; } @@ -125,7 +125,7 @@ .dm-paddle-preset-title svg { flex: 0 0 auto; - color: var(--haptic); + color: var(--ink); } .dm-paddle-key-field { @@ -136,7 +136,7 @@ .dm-paddle-preset-title span, .dm-paddle-key-field span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 9px; font-weight: 800; text-transform: uppercase; @@ -152,7 +152,7 @@ .dm-paddle-preset-title em { overflow: hidden; - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-style: normal; line-height: 1.15; @@ -203,5 +203,5 @@ cursor: not-allowed; border-color: rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.04); - color: var(--tungsten); + color: var(--ink-muted); } diff --git a/web/src/styles/button-mapping/editor.css b/web/src/styles/button-mapping/editor.css index 0175127..cea8bfb 100644 --- a/web/src/styles/button-mapping/editor.css +++ b/web/src/styles/button-mapping/editor.css @@ -22,7 +22,7 @@ } .dm-mapping-tray-labels span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; letter-spacing: 0.06em; @@ -41,7 +41,7 @@ .dm-mapping-tray-labels em { overflow: hidden; - color: var(--actuation); + color: var(--accent-text); font-family: "JetBrains Mono", monospace; font-size: 11px; font-style: normal; @@ -65,7 +65,7 @@ } .dm-mapping-tray-field span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; letter-spacing: 0.06em; @@ -149,12 +149,12 @@ .dm-target-combo-trigger > svg { flex: 0 0 auto; - color: var(--tungsten); + color: var(--ink-muted); transition: transform 180ms ease-out, color 160ms ease-out; } .dm-target-combo-trigger.open > svg { - color: var(--actuation); + color: var(--accent-text); transform: rotate(180deg); } @@ -181,12 +181,12 @@ gap: 8px; padding: 8px 10px; border-bottom: 1px solid rgba(226, 232, 240, 0.08); - color: var(--tungsten); + color: var(--ink-muted); } .dm-target-combo-searchbar > svg { flex: 0 0 auto; - color: var(--tungsten); + color: var(--ink-muted); } .dm-target-combo-searchbar input { @@ -203,7 +203,7 @@ } .dm-target-combo-searchbar input::placeholder { - color: var(--tungsten); + color: var(--ink-muted); } .dm-target-combo-list { @@ -218,7 +218,7 @@ position: sticky; top: 0; padding: 8px 12px 4px; - color: var(--actuation); + color: var(--accent-text); background: rgba(10, 10, 12, 0.97); font-family: "JetBrains Mono", monospace; font-size: 10px; @@ -233,7 +233,7 @@ width: 100%; padding: 7px 14px; border: 0; - color: var(--haptic); + color: var(--ink); background: transparent; font: 600 12px/1.2 Inter, sans-serif; letter-spacing: 0; @@ -249,20 +249,20 @@ } .dm-target-combo-option.active { - color: var(--actuation); + color: var(--accent-text); background: rgba(0, 112, 204, 0.18); - box-shadow: inset 2px 0 0 var(--actuation); + box-shadow: inset 2px 0 0 var(--accent-bright); } .dm-target-combo-empty { padding: 16px; - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; text-align: center; } .dm-target-combo-empty strong { - color: var(--haptic); + color: var(--ink); } .dm-mapping-tray-actions { @@ -274,7 +274,7 @@ .dm-mapping-tray-message { margin: 0 8px 0 0; - color: var(--tungsten); + color: var(--ink-muted); font-family: "JetBrains Mono", monospace; font-size: 11px; line-height: 1.25; @@ -287,7 +287,7 @@ min-width: 96px; padding: 0 18px; border: 0; - color: var(--haptic); + color: var(--ink); background: transparent; box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.16); font: 700 12px/1 Inter, sans-serif; @@ -303,7 +303,7 @@ .dm-mapping-action.primary { color: #FFFFFF; - background: var(--actuation); + background: var(--accent); box-shadow: inset 0 0 0 1px rgba(0, 112, 204, 0.85), 0 0 18px rgba(0, 112, 204, 0.35); } diff --git a/web/src/styles/button-mapping/layout/controller-art.css b/web/src/styles/button-mapping/layout/controller-art.css index bbce11c..5532ca6 100644 --- a/web/src/styles/button-mapping/layout/controller-art.css +++ b/web/src/styles/button-mapping/layout/controller-art.css @@ -126,7 +126,7 @@ } .dm-controller-focus-card span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -145,7 +145,7 @@ } .dm-controller-focus-card .dm-controller-focus-hint { - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-weight: 600; letter-spacing: 0; @@ -178,10 +178,10 @@ place-items: center; width: 32px; height: 32px; - color: var(--actuation); + color: var(--accent-text); background: rgba(0, 112, 204, 0.14); box-shadow: inset 0 0 0 1px rgba(0, 112, 204, 0.4); - font-family: var(--mono); + font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; font-weight: 800; letter-spacing: 0.04em; @@ -205,7 +205,7 @@ .dm-callout-cluster-title { display: block; - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -226,7 +226,7 @@ .dm-steam-binding-row { min-width: 0; border: 0; - color: var(--haptic); + color: var(--ink); background: rgba(10, 10, 12, 0.5); box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.055), @@ -324,7 +324,7 @@ .dm-steam-binding-row code, .dm-map-quick-grid span, .dm-steam-binding-empty span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; letter-spacing: 0; diff --git a/web/src/styles/button-mapping/layout/inspector.css b/web/src/styles/button-mapping/layout/inspector.css index aa0e1e1..5af6754 100644 --- a/web/src/styles/button-mapping/layout/inspector.css +++ b/web/src/styles/button-mapping/layout/inspector.css @@ -89,7 +89,7 @@ } .dm-steam-binding-row em { - color: var(--haptic); + color: var(--ink); font-size: 12px; font-style: normal; font-weight: 700; @@ -134,7 +134,7 @@ .dm-map-editor-summary span, .dm-map-field span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -159,7 +159,7 @@ border-radius: 6px; color: #FFFFFF; background: rgba(18, 18, 20, 0.92); - font: 800 12px/1 var(--mono); + font: 800 12px/1 "JetBrains Mono", ui-monospace, monospace; outline: none; } @@ -190,8 +190,8 @@ .dm-map-editor-message { margin: 0; - color: var(--tungsten); - font-family: var(--mono); + color: var(--ink-muted); + font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; diff --git a/web/src/styles/button-mapping/layout/panels.css b/web/src/styles/button-mapping/layout/panels.css index 566a424..f94f0b0 100644 --- a/web/src/styles/button-mapping/layout/panels.css +++ b/web/src/styles/button-mapping/layout/panels.css @@ -17,7 +17,7 @@ } .dm-page-status { - color: var(--actuation); + color: var(--accent-text); font-size: 11px; font-weight: 800; } @@ -42,7 +42,7 @@ .dm-map-placeholder span, .dm-button-bind-row span { display: block; - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -67,7 +67,7 @@ display: block; margin-top: 4px; overflow: hidden; - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; line-height: 1.25; text-overflow: ellipsis; diff --git a/web/src/styles/button-mapping/mirror.css b/web/src/styles/button-mapping/mirror.css index 50f6974..8af41ff 100644 --- a/web/src/styles/button-mapping/mirror.css +++ b/web/src/styles/button-mapping/mirror.css @@ -34,7 +34,7 @@ } .dm-steam-layout-title { - color: var(--haptic); + color: var(--ink); font-size: 14px; font-weight: 800; letter-spacing: 0.04em; @@ -45,7 +45,7 @@ .dm-steam-empty-note { max-width: 52ch; margin: -4px auto 4px; - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; line-height: 1.4; text-align: center; @@ -95,7 +95,7 @@ } .dm-steam-group-title { - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; font-weight: 900; letter-spacing: 0.08em; @@ -111,7 +111,7 @@ min-height: 26px; padding: 3px 5px; border: 0; - color: var(--haptic); + color: var(--ink); background: transparent; font: inherit; letter-spacing: 0; @@ -128,7 +128,7 @@ outline: none; color: #FFFFFF; background: rgba(226, 232, 240, 0.08); - box-shadow: inset 3px 0 0 var(--actuation); + box-shadow: inset 3px 0 0 var(--accent-bright); } .dm-steam-row strong { @@ -149,7 +149,7 @@ } .dm-steam-static-row { - color: var(--haptic); + color: var(--ink); } .dm-steam-input-icon { diff --git a/web/src/styles/button-mapping/stage.css b/web/src/styles/button-mapping/stage.css index 7782048..17e6587 100644 --- a/web/src/styles/button-mapping/stage.css +++ b/web/src/styles/button-mapping/stage.css @@ -116,7 +116,7 @@ gap: 8px; padding: 0; border: 0; - color: var(--haptic); + color: var(--ink); background: transparent; z-index: 3; transition: transform 180ms ease-out, filter 180ms ease-out; @@ -299,7 +299,7 @@ } .dm-mapping-chip.active .dm-mapping-chip-binding { - color: var(--actuation); + color: var(--accent-text); } .dm-mapping-chip.edge .dm-mapping-chip-icon { diff --git a/web/src/styles/controllers.css b/web/src/styles/controllers.css index 3a033a0..e1a09f7 100644 --- a/web/src/styles/controllers.css +++ b/web/src/styles/controllers.css @@ -1,351 +1,376 @@ -.dm-controllers-page { - display: grid; - grid-template-columns: minmax(260px, 330px) minmax(0, 1160px); - justify-content: center; - gap: clamp(16px, 2vw, 26px); - flex: 1 1 0; - width: min(1500px, 100%); - min-height: 0; - margin: 0 auto; +/* Advanced — Controller details + Edge onboard slots. + Layout truth: status-advanced-v2-psblue mockup. Calm, flat, hairlines; + live meters use --accent-bright. Live readouts are for checking, not for + everyday tuning. */ + +.ctl-view { + font-size: 12px; + line-height: 1.45; + color: var(--ink); } -.dm-controllers-workbench { - display: grid; - gap: 12px; - min-width: 0; - align-content: start; +.ctl-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 10px; } -.dm-controller-overview, -.dm-input-path-panel, -.dm-power-diagnostics-panel, -.dm-live-input-panel, -.dm-calibration-readout, -.dm-controller-empty-state { +.ctl-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--ink); +} + +.ctl-sub { + font-size: 11px; + color: var(--ink-muted); +} + +/* Groups row — intrinsic widths, wraps naturally (same rhythm as Status). */ +.ctl-groups { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 16px 22px; + margin-top: 14px; +} + +.ctl-group { + flex: 1 1 280px; + min-width: 260px; + max-width: 420px; display: grid; - gap: 14px; - min-width: 0; - padding: 14px; - border: 1px solid rgba(113, 113, 122, 0.24); - border-radius: 6px; - background: - linear-gradient(180deg, rgba(226, 232, 240, 0.034), rgba(226, 232, 240, 0.012)), - rgba(14, 15, 18, 0.82); - box-shadow: inset 0 1px 0 rgba(226, 232, 240, 0.052); + gap: 6px; + align-content: start; } -.dm-controller-empty-state { - align-content: center; - min-height: 360px; - justify-items: center; - text-align: center; +.ctl-group.narrow { + flex: 1 1 240px; + min-width: 230px; + max-width: 360px; } -.dm-controller-empty-state strong { - color: #FFFFFF; - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 22px; - font-weight: 760; +.ctl-group-controllers { + flex: 1 1 100%; + max-width: none; } -.dm-controller-empty-state span { - width: min(440px, 100%); - color: var(--tungsten); - font-size: 13px; - font-weight: 650; - line-height: 1.45; +.ctl-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.dm-controller-overview { - grid-template-columns: minmax(210px, 0.8fr) minmax(0, 1.6fr); - align-items: start; +.ctl-sublabel { + margin-top: 10px; } -.dm-controller-overview-copy, -.dm-live-panel-head > div, -.dm-stick-head, -.dm-trigger-meter > div:first-child { - display: grid; - gap: 4px; - min-width: 0; +.ctl-controller-list { + display: flex; + flex-wrap: wrap; + gap: 10px; } -.dm-controller-overview-copy span, -.dm-live-panel-head span, -.dm-stick-head span, -.dm-trigger-meter span, -.dm-calibration-grid dt, -.dm-controller-metric-grid dt, -.dm-stick-module dt { - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - line-height: 1.15; - text-transform: uppercase; -} - -.dm-controller-overview-copy strong, -.dm-live-panel-head strong, -.dm-trigger-meter strong { - color: #FFFFFF; - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 19px; - font-weight: 760; - line-height: 1.05; -} - -.dm-controller-overview-copy small { - color: var(--tungsten); - font-size: 12px; - font-weight: 700; +/* Bounded fields only where content needs one. */ +.ctl-surf { + padding: 12px 14px; + background: var(--surface); + border-radius: var(--radius-m); } -.dm-controller-metric-grid, -.dm-calibration-grid { +.ctl-empty { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 14px; - margin: 0; - min-width: 0; + gap: 4px; } -.dm-controller-metric-grid > div, -.dm-calibration-grid > div { - min-width: 0; +.ctl-empty strong { + font-weight: 600; } -.dm-controller-metric-grid > div.wide { - grid-column: 1 / -1; +.ctl-empty span { + font-size: 11px; + color: var(--ink-muted); } -.dm-controller-metric-grid dd, -.dm-calibration-grid dd, -.dm-stick-module dd { - margin: 0; - color: #FFFFFF; - font-size: 12px; - font-weight: 650; - line-height: 1.35; - overflow-wrap: anywhere; +.ctl-mut { + color: var(--ink-muted); } -.dm-controller-metric-grid dd.mono { - color: var(--haptic); +.ctl-mono { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 11px; - font-weight: 550; + overflow-wrap: anywhere; +} + +/* Hairline rows (srow pattern from the mockup). */ +.ctl-rows { + display: grid; } -.dm-live-panel-head { +.ctl-row { display: flex; - align-items: center; justify-content: space-between; + align-items: baseline; gap: 12px; - min-width: 0; + padding: 6px 0; + font-size: 11px; + border-bottom: 1px solid var(--hairline); } -.dm-live-panel-head code, -.dm-stick-head code, -.dm-button-state code { - color: var(--haptic); - font-family: "JetBrains Mono", ui-monospace, monospace; - font-size: 11px; - font-weight: 750; +.ctl-row:last-child { + border-bottom: 0; } -.dm-input-path-actions { - display: grid; - grid-template-columns: repeat(3, minmax(70px, 1fr)) minmax(8px, 0.4fr) repeat(2, minmax(76px, 0.9fr)); - gap: 8px; - align-items: center; - min-width: 0; +.ctl-row > span:last-child { + text-align: right; } -.dm-input-path-actions button { - min-height: 32px; - padding: 0 10px; - border: 1px solid rgba(113, 113, 122, 0.34); - border-radius: 5px; - color: rgba(226, 232, 240, 0.82); - background: rgba(10, 10, 12, 0.42); - font-size: 12px; - font-weight: 760; +/* Live meters — label/value row plus a 4px accent-bright bar. */ +.ctl-meter-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + font-size: 11px; } -.dm-input-path-actions button.active, -.dm-input-path-actions button.primary { - border-color: rgba(0, 112, 204, 0.82); - color: #FFFFFF; - background: rgba(0, 112, 204, 0.2); +.ctl-meter-row + .ctl-meter, +.ctl-meter + .ctl-meter-row, +.ctl-meter-row + .ctl-meter-row { + margin-top: 3px; } -.dm-input-path-actions button:disabled { - cursor: not-allowed; - opacity: 0.48; +.ctl-meter + .ctl-meter-row { + margin-top: 8px; } -.dm-power-suggestion-list { - display: grid; - gap: 8px; - margin: 0; +.ctl-meter { + height: 4px; + border-radius: 2px; + background: var(--surface-raised); + overflow: hidden; } -.dm-power-suggestion-list p { - margin: 0; - padding: 8px 10px; - border: 1px solid rgba(113, 113, 122, 0.2); - border-radius: 5px; - color: rgba(226, 232, 240, 0.82); - background: rgba(10, 10, 12, 0.28); - font-size: 12px; - font-weight: 650; - line-height: 1.35; +.ctl-meter span { + display: block; + width: var(--trigger-fill, 0%); + height: 100%; + border-radius: inherit; + background: var(--accent-bright); } -.dm-live-stick-grid { +/* Stick plots — flat field, hairline crosshair, accent-bright dot. */ +.ctl-stick-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; - min-width: 0; + margin-top: 10px; } -.dm-stick-module { +.ctl-stick { display: grid; - gap: 10px; + gap: 8px; min-width: 0; - padding: 12px; - border: 1px solid rgba(113, 113, 122, 0.22); - border-radius: 6px; - background: rgba(10, 10, 12, 0.32); + padding: 12px 14px; + background: var(--surface); + border-radius: var(--radius-m); } -.dm-stick-head, -.dm-trigger-meter > div:first-child { - grid-template-columns: 1fr auto; - align-items: center; -} - -.dm-stick-plot { +.ctl-stick-plot { position: relative; - width: min(240px, 100%); + width: min(150px, 100%); aspect-ratio: 1; margin: 0 auto; - border: 1px solid rgba(113, 113, 122, 0.3); + border: 1px solid var(--hairline-strong); border-radius: 50%; background: - linear-gradient(rgba(113, 113, 122, 0.22), rgba(113, 113, 122, 0.22)) 50% 0 / 1px 100% no-repeat, - linear-gradient(90deg, rgba(113, 113, 122, 0.22), rgba(113, 113, 122, 0.22)) 0 50% / 100% 1px no-repeat, - radial-gradient(circle at center, rgba(0, 112, 204, 0.16), transparent 58%), - rgba(5, 5, 7, 0.48); + linear-gradient(var(--hairline), var(--hairline)) 50% 0 / 1px 100% no-repeat, + linear-gradient(90deg, var(--hairline), var(--hairline)) 0 50% / 100% 1px no-repeat; } -.dm-stick-ring { +.ctl-stick-ring { position: absolute; - inset: calc(50% - var(--stick-mag) / 2); - border: 1px solid rgba(27, 177, 124, 0.46); + inset: calc(50% - var(--stick-mag, 8%) / 2); + border: 1px solid var(--accent-outline); border-radius: 50%; - opacity: 0.66; } -.dm-stick-dot { +.ctl-stick-dot { position: absolute; - left: var(--stick-x); - top: var(--stick-y); - width: 13px; - height: 13px; - border: 2px solid #FFFFFF; + left: var(--stick-x, 50%); + top: var(--stick-y, 50%); + width: 11px; + height: 11px; border-radius: 50%; - background: var(--actuation); - box-shadow: 0 0 16px rgba(0, 112, 204, 0.42); + background: var(--accent-bright); transform: translate(-50%, -50%); } -.dm-stick-module dl { +.ctl-deadzone-row { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: auto 1fr auto; + align-items: center; gap: 8px; - margin: 0; + font-size: 11px; + color: var(--ink-muted); } -.dm-stick-tuning-row { - display: grid; - grid-template-columns: minmax(68px, auto) minmax(120px, 1fr) 44px; - align-items: center; - gap: 8px; - margin-top: 10px; - color: var(--muted); - font-size: 0.78rem; +.ctl-deadzone-row input[type='range'] { + width: 100%; + min-width: 0; + accent-color: var(--accent-bright); } -.dm-stick-tuning-row code { - color: var(--text); - text-align: right; +.ctl-deadzone-row .ctl-mono { + color: var(--ink); } -.dm-trigger-meter-grid { +/* Live button states — flat chips; pressed lights the accent. */ +.ctl-button-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); + gap: 6px; + margin-top: 10px; } -.dm-trigger-meter { - display: grid; +.ctl-button-state { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; min-width: 0; - padding: 12px; - border: 1px solid rgba(113, 113, 122, 0.22); - border-radius: 6px; - background: rgba(10, 10, 12, 0.32); + padding: 5px 8px; + font-size: 11px; + border: 1px solid var(--hairline); + border-radius: var(--radius-s); + background: var(--surface); } -.dm-trigger-bar { - position: relative; - height: 12px; +.ctl-button-state.pressed { + border-color: var(--accent-outline); + background: var(--accent-tint); +} + +.ctl-button-state span:first-child { overflow: hidden; - border-radius: 999px; - background: rgba(113, 113, 122, 0.22); + text-overflow: ellipsis; + white-space: nowrap; } -.dm-trigger-bar span { - display: block; - width: var(--trigger-fill); - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--actuation), #1BB17C); +.ctl-button-state .ctl-mono { + color: var(--ink-muted); } -.dm-button-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); +.ctl-button-state.pressed .ctl-mono { + color: var(--accent-text); +} + +/* Buttons — quiet by default, accent for the primary action. */ +.ctl-actions { + display: flex; + flex-wrap: wrap; gap: 8px; - min-width: 0; + margin-top: 10px; +} + +.ctl-button { + min-height: 28px; + padding: 4px 12px; + font: inherit; + font-size: 11px; + font-weight: 600; + color: var(--ink); + background: var(--surface-raised); + border: 0; + border-radius: var(--radius-s); + cursor: pointer; + transition: background var(--speed) var(--ease); +} + +.ctl-button.active { + color: var(--accent-text); + background: var(--accent-tint); + outline: 1px solid var(--accent-outline); +} + +.ctl-button.primary { + color: #ffffff; + background: var(--accent); +} + +.ctl-button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.ctl-button:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 1px; +} + +.ctl-note { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: var(--ink-muted); +} + +.ctl-note.error { + color: var(--danger); +} + +.ctl-suggestions { + display: grid; + gap: 6px; + margin-top: 8px; +} + +/* Support group */ +.ctl-support { + display: grid; + gap: 6px; + justify-items: start; + font-size: 11px; +} + +/* Edge onboard slots */ +.edge-slot-list { + display: grid; } -.dm-button-state { +.edge-slot-row { display: flex; align-items: center; justify-content: space-between; - gap: 8px; - min-width: 0; - min-height: 34px; - padding: 8px 9px; - border: 1px solid rgba(113, 113, 122, 0.22); - border-radius: 5px; - background: rgba(10, 10, 12, 0.34); + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--hairline); +} + +.edge-slot-row:last-child { + border-bottom: 0; } -.dm-button-state.pressed { - border-color: rgba(27, 177, 124, 0.64); - background: rgba(27, 177, 124, 0.14); +.edge-slot-row.disabled { + opacity: 0.6; } -.dm-button-state span { +.edge-slot-copy { + display: grid; + gap: 2px; min-width: 0; - overflow: hidden; - color: #FFFFFF; +} + +.edge-slot-copy strong { font-size: 12px; - font-weight: 760; - line-height: 1.15; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: 600; + color: var(--ink); +} + +.edge-slot-copy small { + font-size: 11px; + color: var(--ink-muted); } From 494edfff527c8fd40021e855d8fa6581cc589b1a Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 22:36:31 -0500 Subject: [PATCH 19/23] ui-rework: centralize mono font token, drop dead power-metrics helper - Add --font-mono to :root block in tokens.css with JetBrains Mono stack - Replace 11 hard-coded font-family instances across controllers.css and button-mapping/*.css with var(--font-mono) - Convert font shorthand properties to longhand to use the token - Delete unused hasPowerMetrics() function from ControllersView.svelte (zero call sites) Co-Authored-By: Claude Fable 5 --- .../features/controllers/ControllersView.svelte | 6 ------ web/src/styles/button-mapping/base.css | 9 ++++++--- web/src/styles/button-mapping/editor.css | 16 +++++++++++----- web/src/styles/button-mapping/mirror.css | 2 +- web/src/styles/button-mapping/stage.css | 2 +- web/src/styles/controllers.css | 2 +- web/src/styles/tokens.css | 3 +++ 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/web/src/lib/features/controllers/ControllersView.svelte b/web/src/lib/features/controllers/ControllersView.svelte index d4c9876..a272a32 100644 --- a/web/src/lib/features/controllers/ControllersView.svelte +++ b/web/src/lib/features/controllers/ControllersView.svelte @@ -346,12 +346,6 @@ return value ? yes : no; } - function hasPowerMetrics(item: ControllerStatus | undefined) { - const diagnostics = item?.powerDiagnostics; - if (!diagnostics) return false; - return Object.values(diagnostics).some((value) => value !== null && value !== undefined); - } - function batteryFriendlySuggestions( item: ControllerStatus | undefined, config: ControllerConfiguration | null diff --git a/web/src/styles/button-mapping/base.css b/web/src/styles/button-mapping/base.css index 34772df..a4f3c23 100644 --- a/web/src/styles/button-mapping/base.css +++ b/web/src/styles/button-mapping/base.css @@ -30,7 +30,7 @@ align-items: center; gap: 6px; color: var(--accent-text); - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-size: 10px; font-weight: 800; letter-spacing: 0.18em; @@ -80,7 +80,7 @@ .dm-mapping-context-count { color: var(--accent-text); - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-weight: 700; } @@ -166,7 +166,10 @@ border: 1px solid rgba(255, 255, 255, 0.11); background: rgba(2, 5, 9, 0.86); color: #FFFFFF; - font: 800 12px/1 "JetBrains Mono", monospace; + font-family: var(--font-mono); + font-weight: 800; + font-size: 12px; + line-height: 1; text-align: center; text-transform: uppercase; } diff --git a/web/src/styles/button-mapping/editor.css b/web/src/styles/button-mapping/editor.css index cea8bfb..fae3def 100644 --- a/web/src/styles/button-mapping/editor.css +++ b/web/src/styles/button-mapping/editor.css @@ -42,7 +42,7 @@ .dm-mapping-tray-labels em { overflow: hidden; color: var(--accent-text); - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-size: 11px; font-style: normal; font-weight: 700; @@ -83,7 +83,10 @@ border: 0; box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.12); outline: none; - font: 700 12px/1 "JetBrains Mono", monospace; + font-family: var(--font-mono); + font-weight: 700; + font-size: 12px; + line-height: 1; letter-spacing: 0.01em; } @@ -116,7 +119,10 @@ color: #FFFFFF; background: rgba(10, 10, 12, 0.72); box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.12); - font: 700 12px/1 "JetBrains Mono", monospace; + font-family: var(--font-mono); + font-weight: 700; + font-size: 12px; + line-height: 1; letter-spacing: 0.01em; text-align: left; transition: box-shadow 160ms ease-out, background 160ms ease-out; @@ -220,7 +226,7 @@ padding: 8px 12px 4px; color: var(--accent-text); background: rgba(10, 10, 12, 0.97); - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-size: 10px; font-weight: 800; letter-spacing: 0.08em; @@ -275,7 +281,7 @@ .dm-mapping-tray-message { margin: 0 8px 0 0; color: var(--ink-muted); - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-size: 11px; line-height: 1.25; max-width: 240px; diff --git a/web/src/styles/button-mapping/mirror.css b/web/src/styles/button-mapping/mirror.css index 8af41ff..51cf8da 100644 --- a/web/src/styles/button-mapping/mirror.css +++ b/web/src/styles/button-mapping/mirror.css @@ -159,7 +159,7 @@ min-width: 20px; height: 20px; color: #FFFFFF; - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-size: 10px; font-weight: 900; } diff --git a/web/src/styles/button-mapping/stage.css b/web/src/styles/button-mapping/stage.css index 17e6587..6ee992f 100644 --- a/web/src/styles/button-mapping/stage.css +++ b/web/src/styles/button-mapping/stage.css @@ -191,7 +191,7 @@ .dm-mapping-chip-glyph { color: #FFFFFF; - font-family: "JetBrains Mono", monospace; + font-family: var(--font-mono); font-size: 11px; font-weight: 800; letter-spacing: 0.04em; diff --git a/web/src/styles/controllers.css b/web/src/styles/controllers.css index e1a09f7..e4132a8 100644 --- a/web/src/styles/controllers.css +++ b/web/src/styles/controllers.css @@ -100,7 +100,7 @@ } .ctl-mono { - font-family: "JetBrains Mono", ui-monospace, monospace; + font-family: var(--font-mono); font-size: 11px; overflow-wrap: anywhere; } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 158a467..89e3bc2 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -43,6 +43,9 @@ --z-toast: 50; --z-tooltip: 60; + /* typography */ + --font-mono: "JetBrains Mono", ui-monospace, monospace; /* literal values only: ports, IPs, raw readouts */ + color: var(--ink); background: var(--bg); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; From 1b2871cac53cf4988ef814f2945e4d645e15f5b6 Mon Sep 17 00:00:00 2001 From: Kyle McDowell Date: Wed, 10 Jun 2026 23:08:28 -0500 Subject: [PATCH 20/23] ui-rework: remove HUD-era shell, styles, and routed views Delete ViewNav, ContextRibbon, GamesView, ProfileConsole, HapticsView, HapticsAside and the stylesheets only they used (shell.css, ribbon.css, games.css, games-catalog.css, workspace.css, haptics/profile-console.css, button-mapping/stage.css). Still-used rules migrate to their new homes: controller card styles into controllers.css, notice banners into feedback.css, the RGB console and mini-button variants into the surviving haptics files, dm-view-hidden into button-mapping/base.css. Retire the temporary alias tokens in tokens.css and migrate every remaining usage to the calm-console tokens; success toasts now use --ok. Prune dead selectors from responsive.css and base.css, resolve the shell-v2 scroll-model breadcrumbs, and reword audit-flagged comments. Update the onboarding tutorial and controller card copy to the new IA names and copy law. Co-Authored-By: Claude Fable 5 --- web/scripts/visual-smoke.mjs | 2 +- web/src/App.svelte | 6 +- web/src/app/navigation.ts | 2 +- web/src/components/ContextRibbon.svelte | 411 -------------- web/src/components/OnboardingTutorial.svelte | 10 +- web/src/components/ViewNav.svelte | 39 -- .../controllers/ControllerCard.svelte | 2 +- web/src/lib/features/games/GamesView.svelte | 165 ------ web/src/lib/features/games/addGameDialog.css | 58 +- .../lib/features/haptics/HapticsAside.svelte | 231 -------- .../lib/features/haptics/HapticsView.svelte | 12 - .../features/profiles/ProfileConsole.svelte | 159 ------ web/src/styles/app.css | 3 - web/src/styles/base.css | 7 +- web/src/styles/button-mapping.css | 1 - web/src/styles/button-mapping/base.css | 6 + web/src/styles/button-mapping/stage.css | 311 ----------- web/src/styles/controllers.css | 251 +++++++++ web/src/styles/dialogs.css | 4 +- web/src/styles/feedback.css | 73 ++- web/src/styles/games-catalog.css | 301 ---------- web/src/styles/games.css | 524 ------------------ web/src/styles/haptics.css | 1 - web/src/styles/haptics/controls.css | 79 ++- web/src/styles/haptics/curves.css | 30 +- web/src/styles/haptics/profile-console.css | 144 ----- web/src/styles/haptics/routing.css | 50 +- web/src/styles/responsive.css | 189 +------ web/src/styles/ribbon.css | 428 -------------- web/src/styles/shell-v2.css | 15 +- web/src/styles/shell.css | 235 -------- web/src/styles/tokens.css | 22 - web/src/styles/tuning.css | 13 +- web/src/styles/workspace.css | 27 - 34 files changed, 477 insertions(+), 3334 deletions(-) delete mode 100644 web/src/components/ContextRibbon.svelte delete mode 100644 web/src/components/ViewNav.svelte delete mode 100644 web/src/lib/features/games/GamesView.svelte delete mode 100644 web/src/lib/features/haptics/HapticsAside.svelte delete mode 100644 web/src/lib/features/haptics/HapticsView.svelte delete mode 100644 web/src/lib/features/profiles/ProfileConsole.svelte delete mode 100644 web/src/styles/button-mapping/stage.css delete mode 100644 web/src/styles/games-catalog.css delete mode 100644 web/src/styles/games.css delete mode 100644 web/src/styles/haptics/profile-console.css delete mode 100644 web/src/styles/ribbon.css delete mode 100644 web/src/styles/shell.css delete mode 100644 web/src/styles/workspace.css diff --git a/web/scripts/visual-smoke.mjs b/web/scripts/visual-smoke.mjs index 76e189c..b8d4533 100644 --- a/web/scripts/visual-smoke.mjs +++ b/web/scripts/visual-smoke.mjs @@ -16,7 +16,7 @@ const routeChecks = [ { hash: '#/tuning', pattern: /Tuning|Profile/i }, { hash: '#/advanced/controller', pattern: /Controller details|Live input/i }, // Button mapping needs a game scope first; main() selects one via the - // toolbar before this route runs. + // toolbar on the previously loaded page, so this check must stay LAST. { hash: '#/advanced/button-mapping', pattern: /Button Mapping|Default mirror/i } ]; // Old routes keep working forever; each lands on the new home for its content. diff --git a/web/src/App.svelte b/web/src/App.svelte index 6ccd498..b1e29d5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1087,6 +1087,7 @@ Boolean(currentControllerConfig && profileSaveBaselineSignature) && profileConfigSignature(buildControllerConfig()) !== profileSaveBaselineSignature; + // Saved rail diff (Task 7). The object literal mirrors // currentProfileDraftValues() but names every draft variable directly so // Svelte re-derives the snapshot the moment any tunable value moves (a @@ -2215,8 +2216,9 @@ {:else if snapshot} - +