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. diff --git a/web/scripts/visual-smoke.mjs b/web/scripts/visual-smoke.mjs index 7afd905..b8d4533 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 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. +const legacyRedirectChecks = [{ from: '#/games', to: '#/tuning' }]; const viewports = [ { width: 1366, height: 768 }, { width: 1440, height: 900 }, @@ -114,6 +118,16 @@ 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 + // 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); const snapshot = await routeSnapshot(page); @@ -122,8 +136,17 @@ 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)) { - 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`); + } + } + + 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) { diff --git a/web/src/App.svelte b/web/src/App.svelte index e5366bc..313f4d6 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,15 +1,14 @@ -
+
+ + {#snippet footer()} + + {/snippet} + + +
{#if loading}
@@ -1995,56 +2221,64 @@
{: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 cec87f4..9d2a8e2 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -1,42 +1,68 @@ -export type AppView = 'games' | 'controllers' | 'haptics' | 'buttonMapping'; +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; + /** True when the Target Controller is a DualSense Edge. */ + edgeSlotsReady: boolean; }; export const appViews: AppViewDefinition[] = [ - { id: 'games', label: 'Profiles', hash: '#/games' }, - { id: 'controllers', label: 'Controllers', hash: '#/controllers' }, - { id: 'haptics', label: 'Adaptive Triggers & Haptics', hash: '#/adaptive-triggers-haptics' }, - { id: 'buttonMapping', label: 'Button Mapping', hash: '#/button-mapping' } + { 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 = { - 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' +}; + +/** Every hash the router answers to: current view hashes plus old-route redirects. */ +export const knownViewHashes: string[] = [ + ...appViews.map((item) => item.hash), + ...Object.keys(legacyRedirects) +]; + +export function isViewHash(hash: string): boolean { + return knownViewHashes.includes(hash); +} + 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'; + // 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; } -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/app/profileDraft.ts b/web/src/app/profileDraft.ts index 2c4ac2a..6c72c46 100644 --- a/web/src/app/profileDraft.ts +++ b/web/src/app/profileDraft.ts @@ -1,4 +1,9 @@ import { + DEFAULT_BODY_FEEL, + DEFAULT_BODY_RUMBLE_MODE, + DEFAULT_LIGHTBAR_BRIGHTNESS, + DEFAULT_LIGHTBAR_COLOR, + DEFAULT_REDLINE_COLOR, defaultTriggerCurve, defaultTriggerCurvePoints, normalizeStickDeadzone, @@ -132,7 +137,7 @@ const defaultForzaTelemetryConfig = ( defaults: ForzaTuningDefaults, effects: ForzaEffectConfiguration[] = defaults.effects ): EditableControllerConfig['forza'] => ({ - bodyRumbleMode: 'native_passthrough', + bodyRumbleMode: DEFAULT_BODY_RUMBLE_MODE, effects: cloneForzaEffects(effects), brake: cloneForzaTuning(defaults.brake), abs: cloneForzaTuning(defaults.abs), @@ -172,7 +177,7 @@ const forzaTelemetryFromDraft = ( ); export const normalizeForzaBodyRumbleMode = (mode: string | undefined | null): ForzaBodyRumbleMode => - mode === 'dscc_full_control' ? 'dscc_full_control' : 'native_passthrough'; + mode === 'dscc_full_control' ? 'dscc_full_control' : DEFAULT_BODY_RUMBLE_MODE; export const defaultButtonAssignments = (edge = false): EditableControllerConfig['buttons'] => [ { key: 'Cross', label: 'Cross' }, @@ -279,7 +284,7 @@ export function baseForzaTriggerDefaults(): EditableControllerConfig['trigger'] effect: 'Adaptive resistance', intensity: 'Strong (Standard)', vibration: 'Medium', - vibrationMode: 'Balanced' + vibrationMode: DEFAULT_BODY_FEEL }; } @@ -301,13 +306,13 @@ export function buildDefaultControllerConfig(options: DraftConfigOptions): Edita effect: 'Adaptive resistance', intensity: 'Strong (Standard)', vibration: 'Medium', - vibrationMode: 'Balanced' + vibrationMode: DEFAULT_BODY_FEEL }, lightbar: { enabled: true, - color: '#4cc9f0', - rpmColor: '#ff3a2e', - brightness: 72 + color: DEFAULT_LIGHTBAR_COLOR, + rpmColor: DEFAULT_REDLINE_COLOR, + brightness: DEFAULT_LIGHTBAR_BRIGHTNESS }, forza: defaultForzaTelemetryConfig(forzaDefaults), sticks: { @@ -446,13 +451,13 @@ export function profileConfigSignature( effect: config.trigger.effect, intensity: config.trigger.intensity, vibration: config.trigger.vibration, - vibrationMode: config.trigger.vibrationMode ?? 'Balanced' + vibrationMode: config.trigger.vibrationMode ?? DEFAULT_BODY_FEEL }, lightbar: { enabled: config.lightbar?.enabled ?? true, - color: config.lightbar?.color ?? '#4cc9f0', - rpmColor: config.lightbar?.rpmColor ?? '#ff3a2e', - brightness: normalizeTriggerPercent(config.lightbar?.brightness ?? 72) + color: config.lightbar?.color ?? DEFAULT_LIGHTBAR_COLOR, + rpmColor: config.lightbar?.rpmColor ?? DEFAULT_REDLINE_COLOR, + brightness: normalizeTriggerPercent(config.lightbar?.brightness ?? DEFAULT_LIGHTBAR_BRIGHTNESS) }, forza: { bodyRumbleMode: forza.bodyRumbleMode, 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/components/AppSidebar.svelte b/web/src/components/AppSidebar.svelte new file mode 100644 index 0000000..e6c4284 --- /dev/null +++ b/web/src/components/AppSidebar.svelte @@ -0,0 +1,81 @@ + + + diff --git a/web/src/components/ContextRibbon.svelte b/web/src/components/ContextRibbon.svelte deleted file mode 100644 index e99a26d..0000000 --- a/web/src/components/ContextRibbon.svelte +++ /dev/null @@ -1,411 +0,0 @@ - - - - -
-
-
- - {#if controllerPickerOpen} -
- - - {#each connectedControllers as item (item.id)} - - {/each} -
- {/if} -
- -
- - {#if scopePickerOpen} -
- - {#if discoveredGames.length} - - {#each discoveredGames as game (game.gameId)} - {@const gameArt = game.artwork?.capsuleUrl ?? game.artwork?.bannerUrl ?? game.artwork?.iconUrl} - - {/each} - {/if} -
- {/if} -
- -
- - {#if profilePickerOpen && profileContextProfiles.length} -
- {#each profileContextProfiles as profile (profile.id)} - - {/each} -
- {/if} -
-
- -
- -
- -
-
- -
-
- Controller Glyphs - {glyphOverrideEnabled ? 'PlayStation Icons' : 'Game Default'} - {glyphStatus} -
- -
-
-
-
diff --git a/web/src/components/OnboardingTutorial.svelte b/web/src/components/OnboardingTutorial.svelte index 9c60478..9ceceec 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; }; @@ -27,25 +29,25 @@ { eyebrow: 'Start here', 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', + body: 'Status shows your controller, game detection, and telemetry at a glance. In Tuning, choose Global or a supported game from the header.', + actionLabel: 'Open Status', + targetView: 'status', icon: Gamepad2 }, { eyebrow: 'Safety first', 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', + actionLabel: 'Open Tuning', + targetView: 'tuning', icon: RadioTower }, { eyebrow: 'Tune feel', 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', + actionLabel: 'Open Tuning', + targetView: 'tuning', icon: SlidersHorizontal }, { diff --git a/web/src/components/Tooltip.svelte b/web/src/components/Tooltip.svelte index b93680c..658e083 100644 --- a/web/src/components/Tooltip.svelte +++ b/web/src/components/Tooltip.svelte @@ -3,6 +3,8 @@ export let side: 'top' | 'right' | 'bottom' | 'left' = 'top'; export let align: 'start' | 'center' | 'end' = 'center'; export let block = false; + let extraClass = ''; + export { extraClass as class }; const id = `dscc-tooltip-${Math.random().toString(36).slice(2)}`; let hostEl: HTMLSpanElement | undefined; @@ -47,7 +49,7 @@ - import Tooltip from './Tooltip.svelte'; - import type { AppView, AppViewDefinition } from '../app/navigation'; - - export let views: AppViewDefinition[] = []; - export let activeView: AppView = 'games'; - 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 tooltip = - view.id === 'buttonMapping' && !buttonMappingReady - ? 'Select a game or local app scope before editing mappings.' - : view.id === 'haptics' && !tuningReady - ? 'Select a controller before tuning haptics.' - : tooltips[view.id]; - return { ...view, disabled, tooltip }; - }); - - - diff --git a/web/src/lib/features/controllers/ControllerCard.svelte b/web/src/lib/features/controllers/ControllerCard.svelte index 5888bb0..9ad91d8 100644 --- a/web/src/lib/features/controllers/ControllerCard.svelte +++ b/web/src/lib/features/controllers/ControllerCard.svelte @@ -121,7 +121,7 @@
{controllerDiagnosticDetail(item)}
-
HID ID
+
Sanitized ID
{item.id}
{#if item.capabilities.length} diff --git a/web/src/lib/features/controllers/ControllersView.svelte b/web/src/lib/features/controllers/ControllersView.svelte index a269427..a272a32 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/lib/features/games/GamesView.svelte b/web/src/lib/features/games/GamesView.svelte deleted file mode 100644 index f196f77..0000000 --- a/web/src/lib/features/games/GamesView.svelte +++ /dev/null @@ -1,165 +0,0 @@ - - -
-
-
- Profiles -

Games

-
- -
-
- Target Controller - {targetSummary} - {targetDetail} -
- {#if connectedControllers.length > 1} - - {#each connectedControllers as item (item.id)} - - {/each} - {/if} -
- -
- - - -
- - {#if discoveredGames.length} -
- {#each discoveredGames as game (game.gameId)} - {@const heroArt = gameArtwork(game, 'hero') ?? gameArtwork(game, 'banner')} - {@const tileArt = gameArtwork(game, 'banner') ?? gameArtwork(game, 'capsule') ?? gameArtwork(game, 'icon')} - {@const details = gameMediaDetails(game)} - {@const scopedProfiles = profileScopeCount(game)} - - {/each} - -
- {:else} -
- No supported games discovered - {detectionSignalText || 'Steam library data unavailable'} - -
- {/if} -
-
diff --git a/web/src/lib/features/games/addGameDialog.css b/web/src/lib/features/games/addGameDialog.css index 85ba718..07e8d8b 100644 --- a/web/src/lib/features/games/addGameDialog.css +++ b/web/src/lib/features/games/addGameDialog.css @@ -36,7 +36,7 @@ .dm-add-game-head span { display: block; - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-weight: 800; letter-spacing: 0.04em; @@ -55,7 +55,7 @@ .dm-add-game-head p { margin: 0; max-width: 60ch; - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; line-height: 1.4; } @@ -67,7 +67,7 @@ height: 28px; border: 1px solid rgba(113, 113, 122, 0.32); border-radius: 6px; - color: var(--haptic); + color: var(--ink); background: rgba(18, 18, 20, 0.6); cursor: pointer; } @@ -86,7 +86,7 @@ border: 1px solid rgba(113, 113, 122, 0.28); border-radius: 6px; background: rgba(10, 10, 12, 0.62); - color: var(--tungsten); + color: var(--ink-muted); } .dm-add-game-tabs { @@ -104,7 +104,7 @@ border: 0; border-radius: 5px; padding: 6px 10px; - color: var(--tungsten); + color: var(--ink-muted); background: transparent; font-size: 12px; font-weight: 800; @@ -134,7 +134,7 @@ } .dm-add-game-search input::placeholder { - color: var(--tungsten); + color: var(--ink-muted); } .dm-add-game-error { @@ -167,7 +167,7 @@ .dm-local-app-field { display: grid; gap: 6px; - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-weight: 800; letter-spacing: 0.04em; @@ -221,7 +221,7 @@ } .dm-local-validation span { - color: var(--tungsten); + color: var(--ink-muted); font-family: "JetBrains Mono", monospace; font-size: 11px; } @@ -240,7 +240,7 @@ gap: 4px; padding: 24px 8px; text-align: center; - color: var(--tungsten); + color: var(--ink-muted); font-size: 13px; } @@ -331,7 +331,7 @@ display: flex; flex-wrap: wrap; gap: 4px 6px; - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; } @@ -344,7 +344,7 @@ } .dm-add-game-copy small { - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; line-height: 1.3; } @@ -382,7 +382,7 @@ padding: 6px 12px; border: 1px solid rgba(113, 113, 122, 0.42); border-radius: 5px; - color: var(--haptic); + color: var(--ink); background: rgba(10, 10, 12, 0.42); font-size: 12px; font-weight: 700; @@ -414,7 +414,7 @@ align-items: center; gap: 4px 6px; padding: 10px 20px 0; - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-family: "JetBrains Mono", "Space Mono", ui-monospace, monospace; } @@ -423,7 +423,7 @@ appearance: none; border: 0; background: transparent; - color: var(--haptic); + color: var(--ink); font: inherit; cursor: pointer; padding: 2px 4px; @@ -466,7 +466,7 @@ .dm-add-game-browse-empty { padding: 16px 8px; text-align: center; - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; } @@ -478,7 +478,7 @@ padding: 7px 10px; border: 0; border-radius: 5px; - color: var(--haptic); + color: var(--ink); background: transparent; font: inherit; text-align: left; @@ -494,11 +494,11 @@ } .dm-add-game-browse-row.dir .dm-add-game-browse-icon { - color: var(--actuation); + color: var(--accent); } .dm-add-game-browse-row.exe .dm-add-game-browse-icon { - color: var(--haptic); + color: var(--ink); } .dm-add-game-browse-row.selected { @@ -523,17 +523,17 @@ } .dm-add-game-browse-meta { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-family: "JetBrains Mono", monospace; } .dm-add-game-browse-affordance { - color: var(--tungsten); + color: var(--ink-muted); } .dm-add-game-browse-affordance.selected-pill { - color: var(--actuation); + color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: 0.04em; @@ -541,7 +541,7 @@ } .dm-add-game-browse-affordance.hint-pill { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 700; letter-spacing: 0.04em; @@ -551,7 +551,7 @@ .dm-add-game-browse-row:hover .dm-add-game-browse-affordance.hint-pill { opacity: 1; - color: var(--haptic); + color: var(--ink); } .dm-add-game-pick-selected { @@ -566,7 +566,7 @@ } .dm-add-game-pick-selected-label { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; letter-spacing: 0.04em; @@ -575,7 +575,7 @@ } .dm-add-game-pick-selected-empty { - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-style: italic; } @@ -602,7 +602,7 @@ height: 16px; border: 0; border-radius: 50%; - color: var(--haptic); + color: var(--ink); background: transparent; cursor: pointer; } @@ -621,7 +621,7 @@ padding: 4px 8px; border: 1px solid rgba(113, 113, 122, 0.32); border-radius: 999px; - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; letter-spacing: 0.04em; @@ -635,7 +635,7 @@ gap: 12px; padding: 12px 20px 16px; border-top: 1px solid rgba(113, 113, 122, 0.18); - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-weight: 700; letter-spacing: 0.02em; @@ -646,7 +646,7 @@ padding: 7px 14px; border: 1px solid rgba(113, 113, 122, 0.34); border-radius: 5px; - color: var(--haptic); + color: var(--ink); background: rgba(10, 10, 12, 0.42); font-size: 12px; font-weight: 800; diff --git a/web/src/lib/features/haptics/HapticsAside.svelte b/web/src/lib/features/haptics/HapticsAside.svelte deleted file mode 100644 index 0b246ba..0000000 --- a/web/src/lib/features/haptics/HapticsAside.svelte +++ /dev/null @@ -1,297 +0,0 @@ - - - diff --git a/web/src/lib/features/haptics/HapticsView.svelte b/web/src/lib/features/haptics/HapticsView.svelte deleted file mode 100644 index 0354dd4..0000000 --- a/web/src/lib/features/haptics/HapticsView.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - -
- -
diff --git a/web/src/lib/features/haptics/TelemetryRoutingPanel.svelte b/web/src/lib/features/haptics/TelemetryRoutingPanel.svelte index 5303539..229c493 100644 --- a/web/src/lib/features/haptics/TelemetryRoutingPanel.svelte +++ b/web/src/lib/features/haptics/TelemetryRoutingPanel.svelte @@ -37,6 +37,14 @@ route: 'body_both' }); + // Semantic-column rendering (Task 6): the tuning canvas renders one + // embedded instance per column with a filtered `forzaEffectMetas` subset + // (showChrome=false), and parks the stream head + body source chrome below + // the grid in a chrome-only instance (showEffects=false). Defaults keep the + // original all-in-one panel behavior. + export let showChrome = true; + export let showEffects = true; + export let enabledForzaEffectCount = 0; export let allForzaEffectsEnabled = false; export let forzaEffectMetas: ForzaEffectMeta[] = []; @@ -226,6 +234,7 @@ }; +{#if showChrome}
Haptic Routing @@ -233,13 +242,15 @@
{enabledForzaEffectCount}/{forzaEffectMetas.length} - + {#if showEffects} + + {/if}
+{/if} +{#if showChrome}
Body Source @@ -274,7 +287,20 @@ {/each}
+{/if} +{#if showEffects} +{#if !showChrome} +
+ +
+{/if}
- + - +
{meta.label}
- + - + -
- - - - void onImportFile(event)} /> - - - - - - - - - - - - - - - - - - -
-
- - {#if saveAsProfileOpen} -
- -
- - -
-
- {/if} - - {#if renameProfileId} -
- -
- - -
-
- {/if} - diff --git a/web/src/lib/features/status/StatusView.svelte b/web/src/lib/features/status/StatusView.svelte new file mode 100644 index 0000000..ff3ec65 --- /dev/null +++ b/web/src/lib/features/status/StatusView.svelte @@ -0,0 +1,269 @@ + + +
+

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.id)} +
+ {finding.text} + {#if finding.detail} +
{finding.detail}
+ {/if} + {#if finding.id === 'telemetry-quiet'} + + {/if} +
+ {/each} + {:else} +
Nothing else needs you.
+
This box stays empty when all is well.
+ {/if} +
+
+
+
diff --git a/web/src/lib/features/tuning/ProfileMenu.svelte b/web/src/lib/features/tuning/ProfileMenu.svelte new file mode 100644 index 0000000..a964ce7 --- /dev/null +++ b/web/src/lib/features/tuning/ProfileMenu.svelte @@ -0,0 +1,316 @@ + + + + + +{#if saveAsOpen || renameProfileId} +
+ {#if saveAsOpen} + + + + {: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..d7da522 --- /dev/null +++ b/web/src/lib/features/tuning/SavedRail.svelte @@ -0,0 +1,163 @@ + + + +{#snippet diffRows()} + {#each rows as item (item.id)} +
+ {item.label} + + {#if item.dirty && item.savedValue !== item.currentValue} + {item.savedValue} + → {item.currentValue} + {:else if item.dirty} + + {item.currentValue} + {:else} + {item.savedValue} + {/if} + +
+ {/each} + {#if outsideOnly} +
+ Changes outside this panel are unsaved. +
+ {/if} +{/snippet} + +{#snippet actionButtons()} + + +{/snippet} + + + +{#if anyDirty} +
+ {#if barExpanded} +
+ {@render diffRows()} +
+ + 3s · nothing saved +
+
+ {/if} +
+ +
+ {@render actionButtons()} +
+
+
+{/if} diff --git a/web/src/lib/features/tuning/SetupGuide.svelte b/web/src/lib/features/tuning/SetupGuide.svelte new file mode 100644 index 0000000..2b2ae8d --- /dev/null +++ b/web/src/lib/features/tuning/SetupGuide.svelte @@ -0,0 +1,200 @@ + + +
+ {#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/TuningCanvas.svelte b/web/src/lib/features/tuning/TuningCanvas.svelte new file mode 100644 index 0000000..3ae20f5 --- /dev/null +++ b/web/src/lib/features/tuning/TuningCanvas.svelte @@ -0,0 +1,56 @@ + + +
+
+
+
+
Brake · L2
+
+ +
+
+ +
+
Throttle · R2
+
+ +
+
+ +
+
Road feel
+
+ +
+

More effects appear here for games that send them.

+
+ +
+
Lights
+
+ +
+
+
+ + +
+ + {#if $$slots.below} +
+ +
+ {/if} +
diff --git a/web/src/lib/features/tuning/TuningHeader.svelte b/web/src/lib/features/tuning/TuningHeader.svelte new file mode 100644 index 0000000..7b43f55 --- /dev/null +++ b/web/src/lib/features/tuning/TuningHeader.svelte @@ -0,0 +1,442 @@ + + + + +
+ {#if heroArt} + + {/if} +
+
+ {#if scope === 'game' && selectedGame} + + {/if} +
+
+ + {#if chipState && chipPresentation} + + {/if} +
+
+ + {#if unsavedNote} + {unsavedNote} + {/if} +
+
+
+
+ {controllerAlias} + {#if controller?.connected} + + {/if} + {#if batteryText} + {batteryText} + {/if} +
+
+
+ + + +{#if gameMenuOpen} + +{/if} diff --git a/web/src/lib/features/tuning/gameSelect.ts b/web/src/lib/features/tuning/gameSelect.ts new file mode 100644 index 0000000..9de0ff3 --- /dev/null +++ b/web/src/lib/features/tuning/gameSelect.ts @@ -0,0 +1,146 @@ +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; + } + /* "Everyday" is a presentation label and must always pair with its + domain term, Global Profile (CONTEXT.md / spec §13). */ + return 'Everyday · Global Profile'; +} + +export type TelemetryChipState = 'fresh' | 'quiet' | 'setup' | 'none' | null; + +/** + * 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) 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/savedDiff.ts b/web/src/lib/features/tuning/savedDiff.ts new file mode 100644 index 0000000..fc379eb --- /dev/null +++ b/web/src/lib/features/tuning/savedDiff.ts @@ -0,0 +1,312 @@ +// Saved-vs-draft diff for the tuning saved rail (Task 7). +// +// Pure and total: given the SAVED profile config (the baseline captured when a +// profile is loaded or saved) and the current working draft values, derive +// human-labeled rows covering the tunable surface. Dirty rows carry both the +// saved and current value formatted for display; the rail renders +// `saved (strikethrough) → current`, clean rows show the saved value muted. +// +// Comparison normalizes through the same helpers the draft/save path uses +// (hapticsModel normalizers) so the diff agrees with the signature-based +// `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, + normalizeTriggerCurvePoints, + normalizeTriggerPercent +} from '../haptics/hapticsModel'; +import { forzaEffectMetas, forzaRoutes } from '../haptics/hapticsOptions'; +import type { + ControllerConfiguration, + ForzaAbsTuningConfiguration, + ForzaBodyRumbleMode, + ForzaBrakeTuningConfiguration, + ForzaEffectConfiguration, + ForzaRevLimiterTuningConfiguration, + ForzaShiftTuningConfiguration, + ForzaThrottleTuningConfiguration, + TriggerCurvePoint +} from '../../types'; + +export type SavedDiffRow = { + id: string; + label: string; + savedValue: string; + currentValue: string; + dirty: boolean; +}; + +/** The saved baseline: the editable slice of a controller configuration. */ +export type SavedProfileConfig = Pick< + ControllerConfiguration, + 'trigger' | 'lightbar' | 'forza' | 'sticks' +>; + +/** Structural mirror of App's working-draft values (app/profileDraft.ts). */ +export type SavedDiffDraft = { + l2From: number; + l2To: number; + r2From: number; + r2To: number; + l2Curve: number; + r2Curve: number; + l2CurvePoints: TriggerCurvePoint[]; + r2CurvePoints: TriggerCurvePoint[]; + triggerEffect: string; + triggerIntensity: string; + vibrationIntensity: string; + vibrationMode: string; + lightbarEnabled: boolean; + lightbarColor: string; + rpmColor: string; + lightbarBrightness: number; + forzaBodyRumbleMode: ForzaBodyRumbleMode; + forzaEffects: ForzaEffectConfiguration[]; + forzaBrakeTuning: ForzaBrakeTuningConfiguration; + forzaAbsTuning: ForzaAbsTuningConfiguration; + forzaThrottleTuning: ForzaThrottleTuningConfiguration; + forzaShiftTuning: ForzaShiftTuningConfiguration; + forzaRevLimiterTuning: ForzaRevLimiterTuningConfiguration; + leftStickDeadzone: number; + rightStickDeadzone: number; +}; + +export type SavedDiffOptions = { + /** Include the telemetry effect/tuning rows (game scope only). */ + includeForza: boolean; + /** Maps a raw 0-255 effect intensity to a display percent (hapticsState). */ + intensityPercent: (intensity: number) => number; +}; + +const routeLabelByValue = new Map(forzaRoutes.map((route) => [route.value as string, route.label])); + +const routeLabel = (route: string) => routeLabelByValue.get(route) ?? route; + +const roundish = (value: number) => Math.round(value * 1000) / 1000; + +const numbersEqual = (a: number, b: number) => roundish(a) === roundish(b); + +const pointsEqual = (a: TriggerCurvePoint[], b: TriggerCurvePoint[]) => + a.length === b.length && + a.every( + (point, index) => + numbersEqual(point.input, b[index].input) && numbersEqual(point.output, b[index].output) + ); + +type NormalizedCurve = { + from: number; + to: number; + curve: number; + points: TriggerCurvePoint[]; +}; + +const normalizedCurve = ( + side: 'l2' | 'r2', + from: number, + to: number, + curve: number, + points: TriggerCurvePoint[] | undefined +): NormalizedCurve => { + const safeFrom = normalizeTriggerPercent(from); + const safeTo = Math.max(safeFrom, normalizeTriggerPercent(to)); + const safeCurve = normalizeTriggerCurve(curve, defaultTriggerCurve(side)); + return { + from: safeFrom, + to: safeTo, + curve: safeCurve, + points: normalizeTriggerCurvePoints(points, safeCurve) + }; +}; + +const formatRange = (value: NormalizedCurve) => `${value.from}–${value.to}%`; + +const formatCurve = (value: NormalizedCurve) => + `x${value.curve.toFixed(2)} · ${value.points.length} pts`; + +const row = ( + id: string, + label: string, + savedValue: string, + currentValue: string, + dirty = savedValue !== currentValue +): SavedDiffRow => ({ id, label, savedValue, currentValue, dirty }); + +/** A generic per-field comparison for the deep telemetry tuning groups. */ +const tuningGroupRow = ( + id: string, + label: string, + saved: T, + current: T +): SavedDiffRow => { + const keys = Object.keys(saved) as Array; + let changed = 0; + for (const key of keys) { + const savedValue = saved[key]; + const currentValue = current[key]; + if (typeof savedValue === 'number' && typeof currentValue === 'number') { + if (!numbersEqual(savedValue, currentValue)) changed += 1; + } else if (savedValue !== currentValue) { + 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 row(id, label, clean, clean, false); +}; + +const effectValue = ( + effect: ForzaEffectConfiguration, + intensityPercent: SavedDiffOptions['intensityPercent'] +) => (effect.enabled ? `${intensityPercent(effect.intensity)}% · ${routeLabel(effect.route)}` : 'Off'); + +const effectById = ( + effects: ForzaEffectConfiguration[], + id: string, + fallback: ForzaEffectConfiguration +) => effects.find((effect) => effect.id === id) ?? fallback; + +const curveRows = ( + side: 'l2' | 'r2', + label: string, + saved: SavedProfileConfig, + draft: SavedDiffDraft +): SavedDiffRow[] => { + const savedCurve = normalizedCurve( + side, + saved.trigger[`${side}From`], + saved.trigger[`${side}To`], + saved.trigger[`${side}Curve`], + saved.trigger[`${side}CurvePoints`] + ); + const draftCurve = normalizedCurve( + side, + draft[`${side}From`], + draft[`${side}To`], + draft[`${side}Curve`], + draft[`${side}CurvePoints`] + ); + + const curveDirty = + !numbersEqual(savedCurve.curve, draftCurve.curve) || + !pointsEqual(savedCurve.points, draftCurve.points); + const savedLabel = formatCurve(savedCurve); + let currentLabel = formatCurve(draftCurve); + // A moved point can leave the summary text identical; say so in plain words. + if (curveDirty && currentLabel === savedLabel) currentLabel = 'reshaped'; + + return [ + row(`${side}-range`, `${label} range`, formatRange(savedCurve), formatRange(draftCurve)), + row(`${side}-curve`, `${label} curve`, savedLabel, currentLabel, curveDirty) + ]; +}; + +/** + * Derive the saved-vs-current rows for the saved rail. + * + * Returns [] when there is no saved baseline yet (config still loading). + */ +export const savedDiffRows = ( + saved: SavedProfileConfig | null | undefined, + draft: SavedDiffDraft, + options: SavedDiffOptions +): SavedDiffRow[] => { + if (!saved) return []; + + const rows: SavedDiffRow[] = [ + ...curveRows('l2', 'Brake', saved, draft), + ...curveRows('r2', 'Throttle', saved, draft), + row('trigger-mode', 'Trigger mode', saved.trigger.effect, draft.triggerEffect), + row('trigger-force', 'Trigger force', saved.trigger.intensity, draft.triggerIntensity), + row('body-rumble', 'Body rumble', saved.trigger.vibration, draft.vibrationIntensity), + row( + 'body-feel', + 'Body feel', + saved.trigger.vibrationMode ?? DEFAULT_BODY_FEEL, + draft.vibrationMode + ), + row( + 'lightbar', + 'Lightbar', + saved.lightbar?.enabled ?? true + ? `On · ${normalizeTriggerPercent(saved.lightbar?.brightness ?? DEFAULT_LIGHTBAR_BRIGHTNESS)}%` + : 'Off', + draft.lightbarEnabled ? `On · ${normalizeTriggerPercent(draft.lightbarBrightness)}%` : 'Off' + ), + row( + 'lightbar-color', + 'Lightbar color', + (saved.lightbar?.color ?? DEFAULT_LIGHTBAR_COLOR).toLowerCase(), + draft.lightbarColor.toLowerCase() + ), + row( + 'redline-color', + 'Redline color', + (saved.lightbar?.rpmColor ?? DEFAULT_REDLINE_COLOR).toLowerCase(), + draft.rpmColor.toLowerCase() + ), + row( + 'left-deadzone', + 'Left stick deadzone', + `${normalizeStickDeadzone(saved.sticks?.leftDeadzone ?? 0)}%`, + `${normalizeStickDeadzone(draft.leftStickDeadzone)}%` + ), + row( + 'right-deadzone', + 'Right stick deadzone', + `${normalizeStickDeadzone(saved.sticks?.rightDeadzone ?? 0)}%`, + `${normalizeStickDeadzone(draft.rightStickDeadzone)}%` + ) + ]; + + if (options.includeForza) { + 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))); + + const savedEffects = saved.forza?.effects ?? []; + for (const meta of forzaEffectMetas) { + const fallback: ForzaEffectConfiguration = { + id: meta.id, + enabled: true, + intensity: meta.defaultIntensity, + route: meta.defaultRoute + }; + const savedEffect = effectById(savedEffects, meta.id, fallback); + const draftEffect = effectById(draft.forzaEffects, meta.id, fallback); + rows.push( + row( + `effect-${meta.id}`, + meta.label, + effectValue(savedEffect, options.intensityPercent), + effectValue(draftEffect, options.intensityPercent) + ) + ); + } + + if (saved.forza?.brake) rows.push(tuningGroupRow('brake-detail', 'Brake feel detail', saved.forza.brake, draft.forzaBrakeTuning)); + if (saved.forza?.abs) rows.push(tuningGroupRow('abs-detail', 'ABS detail', saved.forza.abs, draft.forzaAbsTuning)); + if (saved.forza?.throttle) rows.push(tuningGroupRow('throttle-detail', 'Throttle feel detail', saved.forza.throttle, draft.forzaThrottleTuning)); + if (saved.forza?.shift) rows.push(tuningGroupRow('shift-detail', 'Gear-shift detail', saved.forza.shift, draft.forzaShiftTuning)); + if (saved.forza?.revLimiter) rows.push(tuningGroupRow('rev-detail', 'Rev limiter detail', saved.forza.revLimiter, draft.forzaRevLimiterTuning)); + } + + return rows; +}; + +/** Count of dirty rows — drives the header "N unsaved changes" note. */ +export const unsavedChangeCount = (rows: SavedDiffRow[]) => + rows.reduce((count, item) => count + (item.dirty ? 1 : 0), 0); diff --git a/web/src/lib/features/tuning/setupRequirements.ts b/web/src/lib/features/tuning/setupRequirements.ts new file mode 100644 index 0000000..622cd0d --- /dev/null +++ b/web/src/lib/features/tuning/setupRequirements.ts @@ -0,0 +1,119 @@ +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 { + // 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; + } + } + 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/app.css b/web/src/styles/app.css index 13a84d0..46c3d9d 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1,13 +1,11 @@ @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 "./games.css"; +@import "./shell-v2.css"; +@import "./status.css"; +@import "./tuning.css"; @import "./controllers.css"; -@import "./games-catalog.css"; @import "./feedback.css"; -@import "./workspace.css"; @import "./button-mapping.css"; @import "./haptics.css"; @import "./dialogs.css"; diff --git a/web/src/styles/base.css b/web/src/styles/base.css index bde1665..2839147 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -7,13 +7,11 @@ body, #app { width: 100%; min-width: 320px; - height: 100%; margin: 0; } body { - overflow: hidden; - background: var(--obsidian); + background: var(--bg); } button, @@ -34,13 +32,12 @@ input:disabled { } code, -.dm-system-readout, .dm-module-title code, .dm-slider-row code, .dm-fader-value, .dm-led-row code, .dm-effects-count code, .dm-curve-tooltip { - font-family: "JetBrains Mono", "Space Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-mono); font-variant-numeric: tabular-nums; } diff --git a/web/src/styles/button-mapping.css b/web/src/styles/button-mapping.css index 14b1aba..8b22273 100644 --- a/web/src/styles/button-mapping.css +++ b/web/src/styles/button-mapping.css @@ -1,6 +1,5 @@ @import "./button-mapping/base.css"; @import "./button-mapping/mirror.css"; -@import "./button-mapping/stage.css"; @import "./button-mapping/editor.css"; @import "./button-mapping/responsive.css"; @import "./button-mapping/layout.css"; diff --git a/web/src/styles/button-mapping/base.css b/web/src/styles/button-mapping/base.css index 1a99abc..553766c 100644 --- a/web/src/styles/button-mapping/base.css +++ b/web/src/styles/button-mapping/base.css @@ -29,8 +29,8 @@ display: inline-flex; align-items: center; gap: 6px; - color: var(--actuation); - font-family: "JetBrains Mono", monospace; + color: var(--accent-text); + font-family: var(--font-mono); font-size: 10px; font-weight: 800; letter-spacing: 0.18em; @@ -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,19 +68,19 @@ } .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); - font-family: "JetBrains Mono", monospace; + color: var(--accent-text); + font-family: var(--font-mono); 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; @@ -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; } @@ -203,5 +206,11 @@ 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); +} + +/* Hides the button-mapping workspace while it stays mounted for fast + re-entry. Moved from the retired workspace.css. */ +.dm-view-hidden { + display: none !important; } diff --git a/web/src/styles/button-mapping/editor.css b/web/src/styles/button-mapping/editor.css index 0175127..fae3def 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,8 +41,8 @@ .dm-mapping-tray-labels em { overflow: hidden; - color: var(--actuation); - font-family: "JetBrains Mono", monospace; + color: var(--accent-text); + font-family: var(--font-mono); font-size: 11px; font-style: normal; font-weight: 700; @@ -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; @@ -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; @@ -149,12 +155,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 +187,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 +209,7 @@ } .dm-target-combo-searchbar input::placeholder { - color: var(--tungsten); + color: var(--ink-muted); } .dm-target-combo-list { @@ -218,9 +224,9 @@ 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-family: var(--font-mono); font-size: 10px; font-weight: 800; letter-spacing: 0.08em; @@ -233,7 +239,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 +255,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,8 +280,8 @@ .dm-mapping-tray-message { margin: 0 8px 0 0; - color: var(--tungsten); - font-family: "JetBrains Mono", monospace; + color: var(--ink-muted); + font-family: var(--font-mono); font-size: 11px; line-height: 1.25; max-width: 240px; @@ -287,7 +293,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 +309,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..51cf8da 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 { @@ -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 deleted file mode 100644 index 7782048..0000000 --- a/web/src/styles/button-mapping/stage.css +++ /dev/null @@ -1,311 +0,0 @@ -.dm-mapping-stage { - position: relative; - align-self: center; - flex: 0 0 auto; - width: 100%; - max-width: 1400px; - aspect-ratio: 2 / 1; - contain: layout paint; - isolation: isolate; -} - -.dm-mapping-stage::before { - content: ""; - position: absolute; - inset: 6% 8%; - background: - radial-gradient(ellipse at 50% 56%, rgba(0, 112, 204, 0.18), rgba(0, 112, 204, 0.02) 50%, transparent 70%); - filter: blur(36px); - opacity: 0.85; - z-index: 0; - pointer-events: none; -} - -/* The SVG fills the stage and draws leader lines. */ -.dm-mapping-lines { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 1; - overflow: visible; -} - -.dm-mapping-lines line { - stroke: rgba(226, 232, 240, 0.22); - stroke-width: 0.16; - stroke-linecap: round; - vector-effect: non-scaling-stroke; - transition: stroke 180ms ease-out, stroke-width 180ms ease-out, filter 220ms ease-out; -} - -.dm-mapping-lines line.focused { - stroke: rgba(0, 112, 204, 0.85); - stroke-width: 0.32; - filter: drop-shadow(0 0 4px rgba(0, 112, 204, 0.55)); -} - -.dm-mapping-lines line.active { - stroke: rgba(0, 112, 204, 1); - stroke-width: 0.4; - filter: drop-shadow(0 0 6px rgba(0, 112, 204, 0.85)); -} - -/* === Centered controller artwork (the centerpiece) ================ */ -.dm-mapping-figure { - position: absolute; - top: 50%; - left: 50%; - /* 54% of stage width — anchor coords in mappingChipLayout assume this. */ - width: 54%; - aspect-ratio: 1.5; - transform: translate(-50%, -50%); - z-index: 2; - pointer-events: none; -} - -.dm-mapping-figure .dm-controller-glow { - position: absolute; - inset: 4% 2% 0%; - background: radial-gradient(ellipse at 50% 50%, rgba(0, 112, 204, 0.28), transparent 60%); - filter: blur(44px); - opacity: 0.85; - pointer-events: none; - z-index: 0; -} - -.dm-mapping-figure .dm-controller-base, -.dm-mapping-figure .dm-controller-focus { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: contain; - pointer-events: none; -} - -.dm-mapping-figure .dm-controller-base { - z-index: 1; - opacity: 0.5; - filter: drop-shadow(0 18px 38px rgba(0, 0, 0, 0.55)); -} - -.dm-mapping-figure .dm-controller-focus { - z-index: 2; - opacity: 0; - filter: - drop-shadow(0 0 6px rgba(0, 112, 204, 0.65)) - drop-shadow(0 0 22px rgba(0, 112, 204, 0.45)); - transition: opacity 220ms ease-out; -} - -.dm-mapping-figure .dm-controller-focus.visible { - opacity: 1; -} - - -/* === Pill chips at the stage edges ================================= */ -/* The icon is a 38px disc; --chip-half-icon centers it on chip-x/y. */ -.dm-mapping-chip { - --chip-icon: 38px; - --chip-half-icon: 19px; - position: absolute; - display: inline-flex; - align-items: center; - gap: 8px; - padding: 0; - border: 0; - color: var(--haptic); - background: transparent; - z-index: 3; - transition: transform 180ms ease-out, filter 180ms ease-out; -} - -/* Left/right chips: icon center sits at chip-x, text flows toward the - page edge (away from the controller). */ -.dm-mapping-chip.left { - right: calc(100% - var(--chip-x) - var(--chip-half-icon)); - top: var(--chip-y); - flex-direction: row-reverse; - text-align: right; - transform: translateY(-50%); -} - -.dm-mapping-chip.right { - left: calc(var(--chip-x) - var(--chip-half-icon)); - top: var(--chip-y); - flex-direction: row; - text-align: left; - transform: translateY(-50%); -} - -/* Top/bottom chips: icon center sits at chip-y, text flows toward the - nearest page edge. */ -.dm-mapping-chip.top { - left: var(--chip-x); - bottom: calc(100% - var(--chip-y) - var(--chip-half-icon)); - flex-direction: column-reverse; - text-align: center; - transform: translateX(-50%); - gap: 4px; -} - -.dm-mapping-chip.bottom { - left: var(--chip-x); - top: calc(var(--chip-y) - var(--chip-half-icon)); - flex-direction: column; - text-align: center; - transform: translateX(-50%); - gap: 4px; -} - -.dm-mapping-chip-icon { - position: relative; - display: grid; - place-items: center; - flex: 0 0 auto; - width: 38px; - height: 38px; - border-radius: 999px; - background: - radial-gradient(circle at 50% 35%, rgba(40, 40, 48, 0.92), rgba(14, 14, 18, 0.92)), - rgba(10, 10, 12, 0.92); - box-shadow: - inset 0 0 0 1px rgba(226, 232, 240, 0.16), - 0 6px 18px rgba(0, 0, 0, 0.5); - transition: - background 180ms ease-out, - box-shadow 220ms ease-out, - transform 180ms ease-out; -} - -.dm-mapping-chip-icon img { - width: 22px; - height: 22px; - object-fit: contain; - filter: brightness(1.05) drop-shadow(0 0 4px rgba(0, 112, 204, 0.28)); - opacity: 0.92; - transition: filter 180ms ease-out, opacity 180ms ease-out; -} - -.dm-mapping-chip-glyph { - color: #FFFFFF; - font-family: "JetBrains Mono", monospace; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.04em; -} - -.dm-mapping-chip-text { - display: grid; - align-content: center; - gap: 2px; - min-width: 0; - /* Reserve enough room for typical binding labels ("Right Bumper", - "Left Trigger") without truncation; cap so a stray long label wraps - instead of stretching the rail into the controller. */ - width: clamp(86px, 9vw, 132px); - padding: 2px 4px; -} - -.dm-mapping-chip.left .dm-mapping-chip-text { - text-align: right; -} - -.dm-mapping-chip.right .dm-mapping-chip-text { - text-align: left; -} - -.dm-mapping-chip.top .dm-mapping-chip-text, -.dm-mapping-chip.bottom .dm-mapping-chip-text { - /* 6 trackpad chips share the top row; keep text narrow but wide enough - for "Unassigned" to sit on a single line. */ - width: clamp(64px, 6.4vw, 92px); - text-align: center; -} - -/* Top/bottom chips always render binding on a single line — vertical room is - scarce above and below the controller and a wrap would push neighbouring - chips into the figure. */ -.dm-mapping-chip.top .dm-mapping-chip-binding, -.dm-mapping-chip.bottom .dm-mapping-chip-binding { - -webkit-line-clamp: 1; - white-space: nowrap; - word-break: normal; - hyphens: none; -} - -.dm-mapping-chip-binding { - /* Allow 2-line wrap before clipping with ellipsis. */ - display: -webkit-box; - overflow: hidden; - color: #FFFFFF; - font-size: 12px; - font-weight: 700; - line-height: 1.2; - letter-spacing: 0.01em; - word-break: break-word; - hyphens: auto; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - text-overflow: ellipsis; -} - -.dm-mapping-chip:hover, -.dm-mapping-chip:focus-visible { - outline: none; -} - -.dm-mapping-chip:hover .dm-mapping-chip-icon, -.dm-mapping-chip:focus-visible .dm-mapping-chip-icon { - background: - radial-gradient(circle at 50% 35%, rgba(0, 112, 204, 0.34), rgba(8, 30, 56, 0.92)), - rgba(10, 10, 12, 0.92); - box-shadow: - inset 0 0 0 1px rgba(0, 112, 204, 0.72), - 0 0 18px rgba(0, 112, 204, 0.35), - 0 8px 22px rgba(0, 0, 0, 0.5); -} - -.dm-mapping-chip:hover .dm-mapping-chip-icon img, -.dm-mapping-chip:focus-visible .dm-mapping-chip-icon img { - opacity: 1; - filter: brightness(1.18) drop-shadow(0 0 8px rgba(0, 112, 204, 0.65)); -} - -.dm-mapping-chip.focused .dm-mapping-chip-icon { - background: - radial-gradient(circle at 50% 35%, rgba(0, 112, 204, 0.34), rgba(8, 30, 56, 0.92)), - rgba(10, 10, 12, 0.92); - box-shadow: - inset 0 0 0 1px rgba(0, 112, 204, 0.72), - 0 0 22px rgba(0, 112, 204, 0.35); -} - -.dm-mapping-chip.active .dm-mapping-chip-icon { - background: - radial-gradient(circle at 50% 35%, rgba(0, 112, 204, 0.52), rgba(0, 60, 124, 0.92)), - rgba(10, 10, 12, 0.96); - box-shadow: - inset 0 0 0 1px rgba(0, 112, 204, 1), - 0 0 24px rgba(0, 112, 204, 0.55), - 0 10px 28px rgba(0, 0, 0, 0.55); -} - -.dm-mapping-chip.active .dm-mapping-chip-icon img { - opacity: 1; - filter: brightness(1.25) drop-shadow(0 0 10px rgba(0, 112, 204, 0.85)); -} - -.dm-mapping-chip.active .dm-mapping-chip-binding { - color: var(--actuation); -} - -.dm-mapping-chip.edge .dm-mapping-chip-icon { - box-shadow: - inset 0 0 0 1px rgba(0, 112, 204, 0.38), - 0 6px 18px rgba(0, 0, 0, 0.5); -} - -/* === Tray (bottom inline editor) — no card, just controls ========= */ diff --git a/web/src/styles/controllers.css b/web/src/styles/controllers.css index 3a033a0..07704f4 100644 --- a/web/src/styles/controllers.css +++ b/web/src/styles/controllers.css @@ -1,351 +1,627 @@ -.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 { - 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); +.ctl-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--ink); } -.dm-controller-empty-state { - align-content: center; - min-height: 360px; - justify-items: center; - text-align: center; +.ctl-sub { + font-size: 11px; + color: var(--ink-muted); } -.dm-controller-empty-state strong { - color: #FFFFFF; - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 22px; - font-weight: 760; +/* 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; } -.dm-controller-empty-state span { - width: min(440px, 100%); - color: var(--tungsten); - font-size: 13px; - font-weight: 650; - line-height: 1.45; +.ctl-group { + flex: 1 1 280px; + min-width: 260px; + max-width: 420px; + display: grid; + gap: 6px; + align-content: start; } -.dm-controller-overview { - grid-template-columns: minmax(210px, 0.8fr) minmax(0, 1.6fr); - align-items: start; +.ctl-group.narrow { + flex: 1 1 240px; + min-width: 230px; + max-width: 360px; } -.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-group-controllers { + flex: 1 1 100%; + max-width: none; } -.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; +.ctl-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.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; +.ctl-sublabel { + margin-top: 10px; } -.dm-controller-overview-copy small { - color: var(--tungsten); - font-size: 12px; - font-weight: 700; +.ctl-controller-list { + display: flex; + flex-wrap: wrap; + gap: 10px; } -.dm-controller-metric-grid, -.dm-calibration-grid { +/* Bounded fields only where content needs one. */ +.ctl-surf { + padding: 12px 14px; + background: var(--surface); + border-radius: var(--radius-m); +} + +.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); - font-family: "JetBrains Mono", ui-monospace, monospace; +.ctl-mono { + font-family: var(--font-mono); 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); +} + +.ctl-deadzone-row input[type='range'] { + width: 100%; + min-width: 0; + accent-color: var(--accent-bright); +} + +.ctl-deadzone-row .ctl-mono { + color: var(--ink); } -.dm-stick-tuning-row { +/* Live button states — flat chips; pressed lights the accent. */ +.ctl-button-grid { display: grid; - grid-template-columns: minmax(68px, auto) minmax(120px, 1fr) 44px; + grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); + gap: 6px; + margin-top: 10px; +} + +.ctl-button-state { + display: flex; align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 5px 8px; + font-size: 11px; + border: 1px solid var(--hairline); + border-radius: var(--radius-s); + background: var(--surface); +} + +.ctl-button-state.pressed { + border-color: var(--accent-outline); + background: var(--accent-tint); +} + +.ctl-button-state span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctl-button-state .ctl-mono { + color: var(--ink-muted); +} + +.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; margin-top: 10px; - color: var(--muted); - font-size: 0.78rem; } -.dm-stick-tuning-row code { - color: var(--text); - text-align: right; +.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); } -.dm-trigger-meter-grid { +.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; - grid-template-columns: repeat(2, minmax(0, 1fr)); + 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; +} + +.edge-slot-row { + display: flex; + align-items: center; + justify-content: space-between; gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--hairline); } -.dm-trigger-meter { +.edge-slot-row:last-child { + border-bottom: 0; +} + +.edge-slot-row.disabled { + opacity: 0.6; +} + +.edge-slot-copy { display: grid; - gap: 8px; + gap: 2px; min-width: 0; - padding: 12px; - border: 1px solid rgba(113, 113, 122, 0.22); - border-radius: 6px; - background: rgba(10, 10, 12, 0.32); } -.dm-trigger-bar { - position: relative; - height: 12px; - overflow: hidden; - border-radius: 999px; - background: rgba(113, 113, 122, 0.22); +.edge-slot-copy strong { + font-size: 12px; + font-weight: 600; + color: var(--ink); } -.dm-trigger-bar span { - display: block; - width: var(--trigger-fill); - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--actuation), #1BB17C); +.edge-slot-copy small { + font-size: 11px; + color: var(--ink-muted); } -.dm-button-grid { +/* Controller card (rendered in the Controller details chooser). + Moved from the retired games.css; HUD-era alias tokens translated to the + calm-console tokens. */ +.dm-controller-card { display: grid; - grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + justify-items: center; gap: 8px; + min-height: 0; + padding: 12px 12px 14px; + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-m); + color: var(--ink); + background: var(--surface); + cursor: pointer; + text-align: left; + transition: + border-color var(--speed) var(--ease), + background-color var(--speed) var(--ease), + box-shadow var(--speed) var(--ease); +} + +.dm-controller-card:hover, +.dm-controller-card.active { + border-color: var(--accent-outline); + background-color: var(--accent-tint); +} + +.dm-controller-card:focus-visible, +.dm-controller-select-zone:focus-visible, +.dm-controller-rename-button:focus-visible, +.dm-controller-expand-button:focus-visible, +.dm-controller-rename-actions button:focus-visible { + outline: 2px solid var(--accent-bright); + outline-offset: 2px; +} + +.dm-controller-card.disconnected { + opacity: 0.62; +} + +.dm-controller-select-zone { + display: grid; + justify-items: center; + gap: 12px; + width: 100%; min-width: 0; + padding: 0; + border: 0; + color: inherit; + background: transparent; + text-align: center; } -.dm-button-state { +.dm-controller-card-top { display: flex; align-items: center; justify-content: space-between; - gap: 8px; + width: 100%; 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); } -.dm-button-state.pressed { - border-color: rgba(27, 177, 124, 0.64); - background: rgba(27, 177, 124, 0.14); +.dm-controller-card-top code { + color: var(--ink); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 800; +} + +.dm-battery-pill.compact { + font-size: 11px; +} + +.dm-controller-glyph { + flex: 0 0 auto; + color: var(--ink); + background: currentColor; + mask: url("/dualsense-controller.svg") center / contain no-repeat; + -webkit-mask: url("/dualsense-controller.svg") center / contain no-repeat; } -.dm-button-state span { +.dm-controller-glyph.controller-card { + width: min(140px, 78%); + height: 80px; + margin: 2px 0 0; + opacity: 0.95; +} + +.dm-controller-copy { + display: grid; + justify-items: center; + gap: 5px; min-width: 0; - overflow: hidden; - color: #FFFFFF; + text-align: center; +} + +.dm-controller-copy strong { + overflow-wrap: break-word; + line-height: 1.18; + font-size: 15px; +} + +.dm-controller-copy small { + overflow-wrap: break-word; + line-height: 1.3; + color: var(--ink-muted); +} + +.dm-controller-copy .dm-controller-id { + margin-top: 2px; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.02em; + opacity: 0.78; +} + +.dm-controller-capabilities { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 5px; + margin-top: 7px; +} + +.dm-controller-capabilities em { + padding: 3px 6px; + border-radius: 4px; + color: var(--ink); + background: var(--surface-raised); + font-size: 10px; + font-style: normal; + font-weight: 750; + line-height: 1; +} + +.dm-controller-capabilities.expanded { + justify-content: flex-start; + margin-top: 4px; +} + +.dm-controller-rename-button, +.dm-controller-expand-button, +.dm-controller-rename-actions button { + min-height: 26px; + padding: 0 9px; + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-s); + color: var(--ink); + background: var(--surface-raised); + font-size: 11px; + font-weight: 800; + cursor: pointer; +} + +.dm-controller-rename-button:hover, +.dm-controller-expand-button:hover, +.dm-controller-rename-actions button:hover { + border-color: var(--accent-outline); + background: var(--accent-tint); +} + +.dm-controller-card-actions { + display: inline-flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; +} + +.dm-controller-details { + width: 100%; + margin-top: 6px; + padding: 12px; + border-top: 1px solid var(--hairline); + background: var(--bg-rail); + border-radius: 0 0 var(--radius-s) var(--radius-s); + text-align: left; +} + +.dm-controller-details-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin: 0; +} + +.dm-controller-details-grid > div { + min-width: 0; +} + +.dm-controller-details-grid > div.wide { + grid-column: 1 / -1; +} + +.dm-controller-details-grid dt { + margin: 0 0 2px; + color: var(--ink-muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.dm-controller-details-grid dd { + margin: 0; font-size: 12px; - font-weight: 760; - line-height: 1.15; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: 600; + line-height: 1.35; + overflow-wrap: break-word; +} + +.dm-controller-details-grid dd.mono { + color: var(--ink); + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + overflow-wrap: anywhere; +} + +.dm-controller-rename-input { + width: min(240px, 100%); + height: 34px; + border: 1px solid var(--accent-outline); + border-radius: var(--radius-s); + color: var(--ink); + background: var(--surface-raised); + font-size: 13px; + font-weight: 800; + outline: none; + padding: 0 10px; + text-align: center; +} + +.dm-controller-rename-wrap { + display: grid; + justify-items: center; + gap: 7px; + width: 100%; +} + +.dm-controller-rename-actions { + display: flex; + justify-content: center; + gap: 6px; +} + +@media (max-width: 720px) { + .dm-controller-card { + min-height: 220px; + } } diff --git a/web/src/styles/dialogs.css b/web/src/styles/dialogs.css index 3db70d7..18ed5d8 100644 --- a/web/src/styles/dialogs.css +++ b/web/src/styles/dialogs.css @@ -87,7 +87,7 @@ height: 30px; border: 0; border-radius: 4px; - color: var(--haptic); + color: var(--ink); background: rgba(10, 10, 12, 0.78); box-shadow: inset 0 0 0 1px rgba(113, 113, 122, 0.22); font-family: "JetBrains Mono", monospace; @@ -123,7 +123,7 @@ } .ops-state span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 13px; } diff --git a/web/src/styles/feedback.css b/web/src/styles/feedback.css index 657edcc..9d9aba9 100644 --- a/web/src/styles/feedback.css +++ b/web/src/styles/feedback.css @@ -6,8 +6,8 @@ width: min(1380px, 100%); margin: 0 auto 10px; padding: 10px 12px; - border-left: 2px solid var(--actuation); - color: var(--haptic); + border-left: 2px solid var(--accent); + color: var(--ink); background: rgba(18, 18, 20, 0.92); box-shadow: inset 0 1px 0 rgba(226, 232, 240, 0.055), @@ -21,7 +21,7 @@ } .dm-support-copy span { - color: var(--actuation); + color: var(--accent); font-size: 10px; font-weight: 850; letter-spacing: 0.12em; @@ -39,7 +39,7 @@ .dm-support-copy p, .dm-support-message { margin: 0; - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; font-weight: 600; line-height: 1.35; @@ -99,17 +99,17 @@ } .dm-toast.success { - --toast-accent: #22C55E; - --toast-bg: rgba(34, 197, 94, 0.18); + --toast-accent: var(--ok); + --toast-bg: var(--ok-tint); } .dm-toast.info { - --toast-accent: var(--actuation); + --toast-accent: var(--accent); --toast-bg: rgba(0, 112, 204, 0.18); } .dm-toast.error { - --toast-accent: var(--overdrive); + --toast-accent: var(--danger); --toast-bg: rgba(240, 62, 62, 0.2); } @@ -128,3 +128,60 @@ line-height: 1.35; overflow-wrap: anywhere; } + +/* Inline notice banners (partial-data + update notices in the main column). + Moved from the retired games-catalog.css. */ +.dm-warning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + max-width: 1380px; + margin: 0 auto 10px; + padding: 8px 12px; + border: 0; + border-left: 2px solid var(--danger); + color: #FECACA; + background: rgba(240, 62, 62, 0.08); + font-size: 12px; +} + +.dm-warning button { + border: 1px solid rgba(254, 202, 202, 0.34); + border-radius: 4px; + color: #FECACA; + background: transparent; + font-size: 11px; +} + +.dm-warning.update { + border-left-color: var(--accent); + color: #D8ECFF; + background: var(--accent-tint); +} + +.dm-warning-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.dm-warning a { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 24px; + padding: 0 8px; + border: 1px solid rgba(216, 236, 255, 0.32); + border-radius: 4px; + color: #D8ECFF; + font-size: 11px; + font-weight: 750; + text-decoration: none; +} + +.dm-warning.update button { + border-color: rgba(216, 236, 255, 0.32); + color: #D8ECFF; +} diff --git a/web/src/styles/games-catalog.css b/web/src/styles/games-catalog.css deleted file mode 100644 index f0f4155..0000000 --- a/web/src/styles/games-catalog.css +++ /dev/null @@ -1,301 +0,0 @@ -.dm-scope-strip button { - display: inline-grid; - grid-template-columns: 30px minmax(0, max-content) auto; - align-items: center; - gap: 10px; - min-height: 0; - width: max-content; - max-width: 100%; - padding: 8px 12px; -} - -.dm-scope-strip { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; -} - -.dm-scope-chip { - display: inline-flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 12px; - min-width: 0; -} - -.dm-scope-chip .dm-scope-chip-label { - display: inline; - margin: 0; - color: var(--tungsten); - font-size: 11px; - font-weight: 800; - letter-spacing: 0.04em; - line-height: 1; - text-transform: uppercase; - white-space: nowrap; -} - -strong.dm-scope-chip-value { - display: inline; - margin: 0; - overflow: visible; - color: var(--actuation); - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 15px; - font-weight: 700; - line-height: 1; - letter-spacing: 0.01em; - text-overflow: clip; - white-space: nowrap; -} - -small.dm-scope-chip-detail { - display: inline; - margin: 0; - overflow: visible; - color: var(--tungsten); - font-size: 11px; - font-weight: 600; - line-height: 1.2; - letter-spacing: 0.01em; - text-overflow: clip; - text-transform: none; - white-space: normal; -} - -.dm-game-card { - position: relative; - display: grid; - grid-template-columns: 220px minmax(0, 1fr); - align-items: center; - gap: 16px; - min-height: 180px; - overflow: hidden; - padding: 14px 16px 14px 14px; -} - -.dm-game-card::before { - content: ""; - position: absolute; - inset: 0; - opacity: 0.34; - background-image: var(--game-hero); - background-size: cover; - background-position: center; - filter: saturate(1.08); -} - -.dm-game-card::after { - content: ""; - position: absolute; - inset: 0; - background: - linear-gradient(180deg, rgba(10, 10, 12, 0.16), rgba(10, 10, 12, 0.72) 58%, rgba(10, 10, 12, 0.92)), - linear-gradient(90deg, rgba(10, 10, 12, 0.86), rgba(10, 10, 12, 0.36)); -} - -.dm-game-card > * { - position: relative; - z-index: 1; -} - -.dm-game-grid > button.running { - border-color: rgba(34, 197, 94, 0.38); -} - -.dm-game-grid > button.custom .dm-game-card-media code { - color: var(--haptic); -} - -.dm-game-card-add { - grid-template-columns: 110px minmax(0, 1fr) !important; - align-items: center; - border-style: dashed !important; - border-color: rgba(0, 112, 204, 0.45) !important; - background: - linear-gradient(180deg, rgba(0, 112, 204, 0.08), rgba(0, 112, 204, 0.02)), - rgba(18, 18, 20, 0.5) !important; -} - -.dm-game-card-add::before, -.dm-game-card-add::after { - display: none; -} - -.dm-game-card-add:hover { - border-color: rgba(0, 112, 204, 0.78) !important; - background: rgba(0, 112, 204, 0.16) !important; -} - -.dm-add-game-icon { - display: grid; - place-items: center; - width: 96px; - height: 96px; - border-radius: 50%; - color: var(--actuation); - background: rgba(0, 112, 204, 0.12); - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 48px; - font-weight: 300; - line-height: 1; -} - -.dm-game-card-add .dm-game-copy small { - color: var(--tungsten); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.01em; - line-height: 1.4; - white-space: normal; - text-overflow: clip; - overflow: visible; - text-transform: none; -} - -.dm-game-card-media { - position: relative; - display: grid; - align-content: end; - gap: 8px; - min-width: 0; -} - -.dm-game-card-media img, -.dm-game-art-fallback { - width: 220px; - height: 150px; - border-radius: 6px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.34); - grid-column: 1; - grid-row: 1; -} - -.dm-game-card-media img { - position: relative; - z-index: 1; - object-fit: cover; - opacity: 0.92; -} - -.dm-game-art-fallback { - display: grid; - place-items: center; - overflow: hidden; - padding: 8px; - color: #FFFFFF; - background: - radial-gradient(circle at 32% 28%, rgba(0, 112, 204, 0.22), transparent 72%), - rgba(18, 18, 20, 0.62); -} - -.dm-game-art-fallback svg { - max-width: 100%; - max-height: 100%; - width: auto; - height: 100%; -} - -.dm-game-copy { - display: grid; - gap: 9px; - min-width: 0; -} - -.dm-game-card-media code { - justify-self: start; - color: var(--actuation); - font-family: "JetBrains Mono", monospace; - font-size: 10px; - font-weight: 800; -} - -.dm-game-grid > button.running code { - color: var(--ready); -} - -.dm-game-meta { - display: flex; - flex-wrap: wrap; - gap: 6px; - min-width: 0; -} - -.dm-game-meta em { - padding: 4px 7px; - border-radius: 4px; - color: var(--haptic); - background: rgba(226, 232, 240, 0.075); - font-size: 10px; - font-style: normal; - font-weight: 750; - line-height: 1; -} - -.dm-empty-choice { - display: grid; - align-content: center; - gap: 6px; - min-height: 90px; - padding: 16px; -} - -.dm-empty-choice.wide { - min-height: 150px; -} - -.dm-warning { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - max-width: 1380px; - margin: 0 auto 10px; - padding: 8px 12px; - border: 0; - border-left: 2px solid var(--overdrive); - color: #FECACA; - background: rgba(240, 62, 62, 0.08); - font-size: 12px; -} - -.dm-warning button { - border: 1px solid rgba(254, 202, 202, 0.34); - border-radius: 4px; - color: #FECACA; - background: transparent; - font-size: 11px; -} - -.dm-warning.update { - border-left-color: var(--actuation); - color: #D8ECFF; - background: rgba(0, 112, 204, 0.1); -} - -.dm-warning-actions { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 8px; -} - -.dm-warning a { - display: inline-flex; - align-items: center; - gap: 5px; - min-height: 24px; - padding: 0 8px; - border: 1px solid rgba(216, 236, 255, 0.32); - border-radius: 4px; - color: #D8ECFF; - font-size: 11px; - font-weight: 750; - text-decoration: none; -} - -.dm-warning.update button { - border-color: rgba(216, 236, 255, 0.32); - color: #D8ECFF; -} diff --git a/web/src/styles/games.css b/web/src/styles/games.css deleted file mode 100644 index 51d954e..0000000 --- a/web/src/styles/games.css +++ /dev/null @@ -1,524 +0,0 @@ -.dm-games-page { - display: grid; - grid-template-columns: minmax(0, 1220px); - justify-content: center; - gap: clamp(16px, 2vw, 26px); - flex: 1 1 0; - width: min(1240px, 100%); - min-height: 0; - margin: 18px auto 0; -} - -.dm-games-column { - display: grid; - align-content: start; - gap: 12px; - min-width: 0; -} - -.dm-games-column.wide { - min-width: 0; -} - -.dm-games-head { - display: grid; - gap: 5px; - min-width: 0; -} - -.dm-games-head span, -.dm-controller-choice small, -.dm-game-copy small, -.dm-empty-choice span { - color: var(--tungsten); - font-size: 11px; - font-weight: 800; - line-height: 1.2; - text-transform: uppercase; -} - -.dm-games-head h2 { - margin: 0; - color: #FFFFFF; - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: clamp(24px, 2.4vw, 34px); - font-weight: 700; - line-height: 1; -} - -.dm-controller-choice-list, -.dm-profile-target-strip, -.dm-scope-strip, -.dm-game-grid { - display: grid; - gap: 10px; - min-width: 0; -} - -.dm-profile-target-strip { - grid-template-columns: minmax(220px, 1fr) repeat(auto-fit, minmax(96px, max-content)); - align-items: center; - padding: 12px; - border: 1px solid rgba(113, 113, 122, 0.24); - border-radius: 6px; - background: rgba(18, 18, 20, 0.58); - box-shadow: inset 0 1px 0 rgba(226, 232, 240, 0.045); -} - -.dm-profile-target-strip div { - display: grid; - gap: 3px; - min-width: 0; -} - -.dm-profile-target-strip span, -.dm-profile-target-strip small { - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - line-height: 1.15; - text-transform: uppercase; -} - -.dm-profile-target-strip strong { - overflow: hidden; - color: #FFFFFF; - font-size: 14px; - font-weight: 760; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-profile-target-strip small { - font-size: 11px; - font-weight: 650; - text-transform: none; -} - -.dm-profile-target-strip button { - min-height: 30px; - padding: 0 11px; - 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: 11px; - font-weight: 800; -} - -.dm-profile-target-strip button:hover, -.dm-profile-target-strip button.active, -.dm-profile-target-strip button:focus-visible { - border-color: rgba(0, 112, 204, 0.72); - color: #FFFFFF; - background: rgba(0, 112, 204, 0.16); - outline: none; -} - -.dm-game-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -@media (max-width: 1100px) { - .dm-game-grid { - grid-template-columns: minmax(0, 1fr); - } -} - -.dm-controller-card, -.dm-scope-strip button, -.dm-game-grid > button, -.dm-empty-choice { - border: 1px solid rgba(113, 113, 122, 0.22); - border-radius: 6px; - color: var(--haptic); - background: - linear-gradient(180deg, rgba(226, 232, 240, 0.035), rgba(226, 232, 240, 0.012)), - rgba(18, 18, 20, 0.74); - box-shadow: inset 0 1px 0 rgba(226, 232, 240, 0.055); - cursor: pointer; - text-align: left; - transition: - border-color 150ms ease-out, - background-color 150ms ease-out, - box-shadow 150ms ease-out, - transform 150ms ease-out; -} - -.dm-controller-card { - display: grid; - justify-items: center; - gap: 8px; - min-height: 0; - padding: 12px 12px 14px; -} - -.dm-edge-slots { - display: grid; - gap: 10px; - padding: 12px; - border: 1px solid rgba(113, 113, 122, 0.26); - border-radius: 6px; - background: - linear-gradient(180deg, rgba(226, 232, 240, 0.035), rgba(226, 232, 240, 0.012)), - rgba(14, 15, 18, 0.82); - box-shadow: inset 0 1px 0 rgba(226, 232, 240, 0.05); -} - -.dm-edge-slots-head, -.dm-edge-slot-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - min-width: 0; -} - -.dm-edge-slots-head > div, -.dm-edge-slot-row > div, -.dm-edge-slot-copy { - display: grid; - gap: 3px; - min-width: 0; -} - -.dm-edge-slots-head span, -.dm-edge-slot-copy span, -.dm-edge-slot-copy small { - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - line-height: 1.15; - text-transform: uppercase; -} - -.dm-edge-slots-head strong, -.dm-edge-slot-copy strong { - min-width: 0; - overflow: hidden; - color: #FFFFFF; - font-size: 13px; - font-weight: 800; - line-height: 1.15; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-edge-slots-note { - margin: 0; - color: #A1A1AA; - font-size: 11px; - font-weight: 650; - line-height: 1.35; -} - -.dm-edge-slots-note.error { - color: #FF6B6B; -} - -.dm-edge-slot-list { - display: grid; - gap: 6px; - min-width: 0; -} - -.dm-edge-slot-row { - min-height: 48px; - padding: 8px 0; - border-top: 1px solid rgba(113, 113, 122, 0.22); -} - -.dm-edge-slot-row .dscc-tooltip.block { - flex: 1 1 auto; - width: auto; - min-width: 0; -} - -.dm-edge-slot-row:first-child { - border-top: 0; -} - -.dm-edge-slot-row.disabled { - opacity: 0.62; -} - -.dm-controller-select-zone { - display: grid; - justify-items: center; - gap: 12px; - width: 100%; - min-width: 0; - padding: 0; - border: 0; - color: inherit; - background: transparent; - text-align: center; -} - -.dm-controller-card-top { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - min-width: 0; -} - -.dm-controller-card-top code { - color: #FFFFFF; - font-family: "JetBrains Mono", monospace; - font-size: 11px; - font-weight: 800; -} - -.dm-battery-pill.compact { - font-size: 11px; -} - -.dm-controller-glyph.controller-card { - width: min(140px, 78%); - height: 80px; - margin: 2px 0 0; - opacity: 0.95; -} - -.dm-controller-copy { - display: grid; - justify-items: center; - gap: 5px; - min-width: 0; - text-align: center; -} - -.dm-controller-capabilities { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 5px; - margin-top: 7px; -} - -.dm-controller-capabilities em { - padding: 3px 6px; - border-radius: 4px; - color: var(--haptic); - background: rgba(226, 232, 240, 0.08); - font-size: 10px; - font-style: normal; - font-weight: 750; - line-height: 1; -} - -.dm-controller-rename-button, -.dm-controller-expand-button, -.dm-controller-rename-actions button { - min-height: 26px; - padding: 0 9px; - border: 1px solid rgba(113, 113, 122, 0.34); - border-radius: 5px; - color: var(--haptic); - background: rgba(10, 10, 12, 0.42); - font-size: 11px; - font-weight: 800; - cursor: pointer; -} - -.dm-controller-rename-button:hover, -.dm-controller-expand-button:hover, -.dm-controller-rename-actions button:hover { - border-color: rgba(0, 112, 204, 0.62); - background: rgba(0, 112, 204, 0.14); -} - -.dm-controller-card-actions { - display: inline-flex; - flex-wrap: wrap; - justify-content: center; - gap: 6px; -} - -.dm-controller-details { - width: 100%; - margin-top: 6px; - padding: 12px; - border-top: 1px solid rgba(113, 113, 122, 0.18); - background: rgba(10, 10, 12, 0.32); - border-radius: 0 0 6px 6px; - text-align: left; -} - -.dm-controller-details-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 14px; - margin: 0; -} - -.dm-controller-details-grid > div { - min-width: 0; -} - -.dm-controller-details-grid > div.wide { - grid-column: 1 / -1; -} - -.dm-controller-details-grid dt { - margin: 0 0 2px; - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.dm-controller-details-grid dd { - margin: 0; - color: #FFFFFF; - font-size: 12px; - font-weight: 600; - line-height: 1.35; - overflow-wrap: break-word; -} - -.dm-controller-details-grid dd.mono { - color: var(--haptic); - font-family: "JetBrains Mono", "Space Mono", ui-monospace, monospace; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.02em; - overflow-wrap: anywhere; -} - -.dm-controller-capabilities.expanded { - justify-content: flex-start; - margin-top: 4px; -} - -.dm-controller-rename-input { - width: min(240px, 100%); - height: 34px; - border: 1px solid rgba(0, 112, 204, 0.58); - border-radius: 6px; - color: #FFFFFF; - background: rgba(10, 10, 12, 0.82); - font-size: 13px; - font-weight: 800; - outline: none; - padding: 0 10px; - text-align: center; -} - -.dm-controller-rename-wrap { - display: grid; - justify-items: center; - gap: 7px; - width: 100%; -} - -.dm-controller-rename-actions { - display: flex; - justify-content: center; - gap: 6px; -} - -.dm-controller-card:hover, -.dm-controller-card.active, -.dm-scope-strip button:hover, -.dm-scope-strip button.active, -.dm-game-grid > button:hover, -.dm-game-grid > button.active { - border-color: rgba(0, 112, 204, 0.68); - background-color: rgba(0, 112, 204, 0.1); - box-shadow: - inset 0 1px 0 rgba(226, 232, 240, 0.07), - inset 0 0 0 1px rgba(0, 112, 204, 0.18), - 0 14px 34px rgba(0, 0, 0, 0.26); -} - -.dm-controller-card:focus-visible, -.dm-controller-select-zone:focus-visible, -.dm-controller-rename-button:focus-visible, -.dm-controller-expand-button:focus-visible, -.dm-controller-rename-actions button:focus-visible, -.dm-scope-strip button:focus-visible, -.dm-game-grid > button:focus-visible, -.dm-input-path-actions button:focus-visible, -.dm-mapping-action:focus-visible { - outline: 2px solid rgba(0, 112, 204, 0.72); - outline-offset: 2px; -} - -.dm-controller-card.disconnected { - opacity: 0.62; -} - -.dm-controller-glyph.small { - width: 38px; - height: 38px; -} - -.dm-controller-choice-list strong, -.dm-scope-strip strong, -.dm-game-copy strong, -.dm-empty-choice strong { - display: block; - overflow: hidden; - color: #FFFFFF; - font-size: 14px; - font-weight: 800; - line-height: 1.18; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-controller-choice-list small, -.dm-scope-strip small, -.dm-game-copy small { - display: block; - margin-top: 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-controller-copy strong { - overflow: visible; - overflow-wrap: break-word; - word-break: normal; - white-space: normal; - text-overflow: clip; - line-height: 1.18; - font-size: 15px; - letter-spacing: 0; - hyphens: none; -} - -.dm-controller-copy small { - overflow-wrap: break-word; - word-break: normal; - white-space: normal; - text-overflow: clip; - line-height: 1.3; -} - -.dm-controller-copy .dm-controller-id { - margin-top: 2px; - font-family: "JetBrains Mono", monospace; - font-size: 10px; - letter-spacing: 0.02em; - opacity: 0.78; -} - -.dm-profile-controller-summary { - display: grid; - justify-items: center; - gap: 12px; - padding: 14px; - border: 1px solid rgba(113, 113, 122, 0.26); - border-radius: 6px; - background: - linear-gradient(180deg, rgba(226, 232, 240, 0.035), rgba(226, 232, 240, 0.012)), - rgba(14, 15, 18, 0.82); - box-shadow: inset 0 1px 0 rgba(226, 232, 240, 0.05); - text-align: center; -} diff --git a/web/src/styles/haptics.css b/web/src/styles/haptics.css index cb34bb4..cfb96f3 100644 --- a/web/src/styles/haptics.css +++ b/web/src/styles/haptics.css @@ -1,4 +1,3 @@ @import "./haptics/routing.css"; @import "./haptics/curves.css"; @import "./haptics/controls.css"; -@import "./haptics/profile-console.css"; diff --git a/web/src/styles/haptics/controls.css b/web/src/styles/haptics/controls.css index 784c8e9..aa5b8c8 100644 --- a/web/src/styles/haptics/controls.css +++ b/web/src/styles/haptics/controls.css @@ -7,30 +7,27 @@ padding-top: 4px; } -.dm-parameter-strip label, -.dm-profile-line label { +.dm-parameter-strip label { display: grid; gap: 7px; min-width: 0; } -.dm-parameter-strip select, -.dm-profile-line select { +.dm-parameter-strip select { width: 100%; min-width: 0; height: 34px; border: 0; border-radius: 5px; - color: var(--haptic); + color: var(--ink); background: rgba(10, 10, 12, 0.78); box-shadow: inset 0 0 0 1px rgba(113, 113, 122, 0.2); font-size: 12px; outline: none; } -.dm-parameter-strip select:focus, -.dm-profile-line select:focus { - box-shadow: inset 0 0 0 1px var(--actuation), 0 0 0 2px rgba(0, 112, 204, 0.16); +.dm-parameter-strip select:focus { + box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 2px rgba(0, 112, 204, 0.16); } .dm-led-controls { @@ -57,7 +54,7 @@ .dm-led-row code { width: 32px; - color: var(--haptic); + color: var(--ink); font-size: 10px; text-align: right; } @@ -84,9 +81,9 @@ position: absolute; inset: 4px; border-radius: 999px; - background: var(--lb-color, var(--actuation)); + background: var(--lb-color, var(--accent)); opacity: var(--lb-alpha, 1); - box-shadow: 0 0 16px color-mix(in srgb, var(--lb-color, var(--actuation)) 70%, transparent); + box-shadow: 0 0 16px color-mix(in srgb, var(--lb-color, var(--accent)) 70%, transparent); } .dm-mini-range { @@ -115,7 +112,7 @@ width: 10px; height: 10px; border-radius: 999px; - background: var(--tungsten); + background: var(--ink-muted); transition: transform 150ms ease-out, background 150ms ease-out; @@ -139,7 +136,7 @@ } .dm-effects-count code { - color: var(--actuation); + color: var(--accent); font-size: 12px; font-weight: 700; } @@ -175,7 +172,7 @@ } .dm-body-mode-title span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -185,7 +182,7 @@ .dm-body-mode-title code { overflow: hidden; max-width: 52%; - color: var(--actuation); + color: var(--accent); font-size: 11px; font-weight: 800; text-align: right; @@ -226,7 +223,7 @@ } .dm-body-mode-option span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -238,7 +235,7 @@ .dm-body-mode-option.active { border-color: rgba(0, 112, 204, 0.92); background: rgba(0, 112, 204, 0.13); - box-shadow: inset 3px 0 0 var(--actuation); + box-shadow: inset 3px 0 0 var(--accent); } .dm-channel-list { @@ -329,7 +326,7 @@ .dm-effect-advanced-grid label > span, .dm-channel-signal-strip strong { - color: var(--tungsten); + color: var(--ink-muted); font-size: 9px; font-weight: 800; line-height: 1; @@ -343,7 +340,7 @@ .dm-advanced-note { grid-column: 1 / -1; margin: 0; - color: var(--ash); + color: var(--ink-muted); font-size: 11px; font-weight: 650; line-height: 1.35; @@ -415,7 +412,7 @@ } .dm-fader-value:focus { - border-color: var(--actuation); + border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0, 112, 204, 0.14); } @@ -433,7 +430,7 @@ } .dm-route-select-wrap span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 9px; font-weight: 800; line-height: 1; @@ -446,7 +443,7 @@ height: 30px; border: 1px solid rgba(113, 113, 122, 0.42); border-radius: 6px; - color: var(--haptic); + color: var(--ink); background: linear-gradient(180deg, rgba(226, 232, 240, 0.05), rgba(226, 232, 240, 0.015)), rgba(10, 10, 12, 0.92); @@ -457,6 +454,42 @@ } .dm-route-select:focus { - border-color: var(--actuation); + border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0, 112, 204, 0.14); } + +/* RGB output console container (LightbarControls). Moved from the retired + haptics/profile-console.css; the canvas-grid context in tuning.css strips + the padding and divider and hides the title. */ +.dm-rgb-console { + display: grid; + gap: 9px; + padding-top: 12px; + box-shadow: inset 0 1px 0 var(--hairline-strong); +} + +.dm-console-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.dm-console-title span { + color: var(--ink-muted); + font-size: 10px; + font-weight: 800; + line-height: 1; + text-transform: uppercase; +} + +.dm-console-title strong { + overflow: hidden; + color: var(--ink); + font-size: 13px; + font-weight: 700; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/web/src/styles/haptics/curves.css b/web/src/styles/haptics/curves.css index b30cb9b..d446f0f 100644 --- a/web/src/styles/haptics/curves.css +++ b/web/src/styles/haptics/curves.css @@ -26,7 +26,7 @@ } .dm-module-title span { - color: var(--actuation); + color: var(--accent); font-family: "JetBrains Mono", monospace; font-size: 12px; font-weight: 700; @@ -41,7 +41,7 @@ } .dm-module-title code { - color: var(--haptic); + color: var(--ink); font-size: 18px; font-weight: 700; } @@ -55,7 +55,7 @@ touch-action: none; background: radial-gradient(circle at 50% 25%, rgba(0, 112, 204, 0.09), transparent 48%), - var(--carbon); + var(--bg-rail); } .dm-curve-frame:active { @@ -71,6 +71,7 @@ .dm-trigger-curve .curve-grid, .dm-trigger-curve .curve-linear, +.dm-trigger-curve .curve-saved-ghost, .dm-trigger-curve .curve-force, .dm-trigger-curve .curve-range-edge, .dm-trigger-curve .curve-live, @@ -100,9 +101,19 @@ stroke-width: 0.85; } +/* Saved-profile ghost (Task 7): dashed echo of the saved curve while the + working draft drifts; sits behind the live force curve. */ +.dm-trigger-curve .curve-saved-ghost { + stroke: rgba(214, 214, 220, 0.55); + stroke-dasharray: 5 4; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.4; +} + .dm-trigger-curve .curve-force { filter: url(#dm-blue-glow); - stroke: var(--actuation); + stroke: var(--accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.2; @@ -116,7 +127,7 @@ } .dm-trigger-curve .curve-live-dot { - fill: var(--actuation); + fill: var(--accent); stroke: rgba(226, 232, 240, 0.86); stroke-width: 0.85; vector-effect: non-scaling-stroke; @@ -174,7 +185,7 @@ .dm-curve-control-handle.active { background: - radial-gradient(circle at 36% 30%, #ffffff 0 16%, #b8edff 40%, var(--actuation) 100%); + radial-gradient(circle at 36% 30%, #ffffff 0 16%, #b8edff 40%, var(--accent) 100%); cursor: grabbing; transform: translate(-50%, -50%) scale(1.16); } @@ -226,7 +237,7 @@ } .dm-slider-row span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; @@ -235,7 +246,7 @@ .dm-slider-row code, .dm-fader-value { width: 48px; - color: var(--haptic); + color: var(--ink); font-size: 12px; text-align: right; } @@ -249,7 +260,7 @@ } .dm-curve-point-row > span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; @@ -265,7 +276,7 @@ } .dm-curve-point-actions code { - color: var(--haptic); + color: var(--ink); font-size: 12px; text-align: center; } @@ -274,9 +285,9 @@ align-items: center; aspect-ratio: 1; background: rgba(15, 23, 42, 0.72); - border: 1px solid var(--border-strong); + border: 1px solid var(--hairline-strong); border-radius: 4px; - color: var(--text); + color: var(--ink); cursor: pointer; display: inline-grid; height: 26px; @@ -302,7 +313,7 @@ height: 22px; margin: 0; padding: 0; - accent-color: var(--actuation); + accent-color: var(--accent); background: transparent; cursor: pointer; } @@ -312,7 +323,7 @@ height: 4px; border-radius: 999px; background: - linear-gradient(90deg, var(--actuation) 0 var(--value, 0%), rgba(78, 83, 95, 0.82) var(--value, 0%) 100%); + linear-gradient(90deg, var(--accent) 0 var(--value, 0%), rgba(78, 83, 95, 0.82) var(--value, 0%) 100%); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 1px 0 rgba(255, 255, 255, 0.04); @@ -332,7 +343,7 @@ .dm-mini-range::-moz-range-progress { height: 4px; border-radius: 999px; - background: var(--actuation); + background: var(--accent); } .dm-range::-webkit-slider-thumb, diff --git a/web/src/styles/haptics/profile-console.css b/web/src/styles/haptics/profile-console.css deleted file mode 100644 index 0f8cebc..0000000 --- a/web/src/styles/haptics/profile-console.css +++ /dev/null @@ -1,144 +0,0 @@ -.dm-rgb-console { - display: grid; - gap: 9px; - padding-top: 12px; - box-shadow: inset 0 1px 0 rgba(113, 113, 122, 0.18); -} - -.dm-console-title { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - min-width: 0; -} - -.dm-console-title span { - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - line-height: 1; - text-transform: uppercase; -} - -.dm-console-title strong { - overflow: hidden; - color: var(--haptic); - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 13px; - font-weight: 700; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-profile-console { - display: grid; - gap: 10px; - padding-top: 12px; - box-shadow: inset 0 1px 0 rgba(113, 113, 122, 0.18); -} - -.dm-profile-line { - display: grid; - grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); - align-items: end; - gap: 10px; -} - -.dm-action-row { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 8px; - min-width: 0; -} - -.dm-action-row > span { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - color: var(--actuation); - font-size: 12px; - font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-action-row > .dscc-tooltip { - flex: 0 0 auto; -} - -.dm-action-row .dm-mini-button, -.dm-action-row .dm-apply-button { - flex: 0 0 70px; - width: 70px; -} - -.dm-action-row .dm-mini-button.wide { - flex-basis: 94px; - width: 94px; -} - -.dm-mini-button { - width: 76px; - min-height: 30px; - padding-inline: 10px; - font-size: 12px; -} - -.dm-mini-button.primary { - border-color: rgba(0, 112, 204, 0.92); - background: var(--actuation); - color: #FFFFFF; -} - -.dm-apply-button { - width: 76px; - min-height: 30px; - padding-inline: 10px; - border: 1px solid rgba(113, 113, 122, 0.78); - color: rgba(226, 232, 240, 0.76); - background: rgba(10, 10, 12, 0.52); - font-size: 13px; - font-weight: 800; -} - -.dm-apply-button.dirty { - border-color: var(--actuation); - color: #FFFFFF; - background: var(--actuation); - box-shadow: 0 0 20px rgba(0, 112, 204, 0.22); -} - -.dm-apply-button.applied { - border-color: var(--ready); - background: var(--ready); - color: #06110A; -} - -.dm-profile-rename { - display: grid; - grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); - align-items: end; - gap: 10px; -} - -.dm-profile-rename label { - display: grid; - gap: 6px; - min-width: 0; -} - -.dm-profile-rename span { - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - line-height: 1; - text-transform: uppercase; -} - -.dm-profile-rename input { - min-width: 0; -} diff --git a/web/src/styles/haptics/routing.css b/web/src/styles/haptics/routing.css index 97d1900..1561d48 100644 --- a/web/src/styles/haptics/routing.css +++ b/web/src/styles/haptics/routing.css @@ -5,17 +5,6 @@ padding: clamp(14px, 1.55vw, 22px); } -.dm-routing { - display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto auto; - gap: 12px; - padding: clamp(16px, 1.6vw, 22px); -} - -.dm-routing.dm-global-feel { - grid-template-rows: auto minmax(0, 1fr) auto auto; -} - .dm-global-feel-panel { display: grid; align-content: start; @@ -41,7 +30,7 @@ .dm-global-feel-heading code { overflow: hidden; max-width: 50%; - color: var(--actuation); + color: var(--accent); font-size: 11px; font-weight: 800; text-align: right; @@ -57,7 +46,7 @@ } .dm-global-feel-panel article > span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 12px; line-height: 1.35; } @@ -87,7 +76,7 @@ } .dm-global-feel-controls label > span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -131,7 +120,7 @@ } .dm-pattern-option span { - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -142,7 +131,7 @@ .dm-pattern-option.active { border-color: rgba(0, 112, 204, 0.92); background: rgba(0, 112, 204, 0.13); - box-shadow: inset 3px 0 0 var(--actuation); + box-shadow: inset 3px 0 0 var(--accent); } .dm-section-head { @@ -159,10 +148,9 @@ .dm-section-head span, .dm-parameter-strip label > span, -.dm-led-row > span, -.dm-profile-line label > span { +.dm-led-row > span { display: block; - color: var(--tungsten); + color: var(--ink-muted); font-size: 10px; font-weight: 800; line-height: 1; @@ -192,8 +180,6 @@ .dm-test-button, .dm-mini-button, -.dm-action-button, -.dm-apply-button, .solid-action { display: inline-flex; align-items: center; @@ -210,8 +196,7 @@ } .dm-test-button, -.dm-mini-button, -.dm-action-button { +.dm-mini-button { border: 1px solid rgba(113, 113, 122, 0.78); color: #FFFFFF; background: transparent; @@ -221,19 +206,22 @@ .dm-test-button:hover, .dm-mini-button:hover, -.dm-action-button:hover, .dm-test-button.active { border-color: #FFFFFF; background: rgba(226, 232, 240, 0.055); } -.dm-action-button.primary { - border-color: rgba(0, 112, 204, 0.92); - background: var(--actuation); - color: #FFFFFF; +/* Compact sizing + primary variant (Save/Create in the profile menu). + Moved from the retired haptics/profile-console.css. */ +.dm-mini-button { + width: 76px; + min-height: 30px; + padding-inline: 10px; + font-size: 12px; } -.dm-action-button:disabled { - cursor: not-allowed; - opacity: 0.52; +.dm-mini-button.primary { + border-color: var(--accent); + background: var(--accent); + color: #FFFFFF; } diff --git a/web/src/styles/responsive.css b/web/src/styles/responsive.css index a6662da..8ff416d 100644 --- a/web/src/styles/responsive.css +++ b/web/src/styles/responsive.css @@ -1,104 +1,12 @@ -@media (max-width: 1180px) { - body { - overflow: auto; - } - - .ops-shell { - display: block; - height: auto; - min-height: 100vh; - overflow: visible; - } +/* Narrow-viewport adjustments for the tuning instruments. The document + scroll model lives in shell-v2.css and needs no overrides here. */ - .dm-hud, - .dm-tuning-ribbon, - .dm-games-page, - .dm-controllers-page, - .dm-controller-overview, - .dm-deck, - .dm-button-map-layout, - .dm-parameter-strip, - .dm-profile-line, - .dm-profile-rename { +@media (max-width: 1180px) { + .dm-parameter-strip { grid-template-columns: 1fr; height: auto; } - .dm-hud { - align-items: stretch; - } - - .dm-tuning-ribbon { - position: static; - top: auto; - } - - .dm-steam-identity { - grid-template-columns: 1fr; - gap: 4px; - } - - button.dm-ribbon-picker-trigger.dm-game-identity-cell { - grid-template-columns: 104px minmax(0, 1fr); - min-height: 64px; - } - - .dm-system-cluster { - justify-content: flex-start; - flex-wrap: wrap; - } - - .dm-system-readout { - justify-content: flex-start; - text-align: left; - } - - .dm-view-nav { - justify-self: stretch; - overflow-x: auto; - } - - .dm-deck, - .dm-games-page, - .dm-controllers-page, - .dm-button-map-page { - min-height: 0; - } - - .dm-controller-stage { - grid-template-columns: 1fr; - } - - .dm-map-callouts, - .dm-map-callouts.left, - .dm-map-callouts.right { - grid-template-columns: 1fr; - text-align: left; - } - - .dm-map-callouts.left .dm-callout-head, - .dm-map-callouts.right .dm-callout-head { - flex-direction: row; - } - - .dm-callout-cluster { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 10px; - } - - .dm-callout-cluster-title { - grid-column: 1 / -1; - } - - .dm-controller-canvas { - max-width: 720px; - margin: 0 auto; - } - - .dm-map-source-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .dm-physics { grid-template-rows: auto auto auto; } @@ -123,13 +31,6 @@ } @media (max-width: 900px) { - .dm-live-stick-grid, - .dm-trigger-meter-grid, - .dm-controller-metric-grid, - .dm-calibration-grid { - grid-template-columns: 1fr; - } - .dm-curve-stack { grid-template-columns: 1fr; } @@ -148,38 +49,10 @@ } @media (max-width: 720px) { - .ops-shell { - padding: 14px; - } - - .dm-physics, - .dm-routing, - .dm-button-map-panel { + .dm-physics { padding: 14px; } - .dm-view-nav { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 3px; - } - - .dm-view-nav button { - width: 100%; - padding: 0 8px; - font-size: 11px; - } - - .dm-app-tagline { - max-width: 100%; - white-space: normal; - overflow-wrap: anywhere; - } - - .dm-slider-bank { - grid-template-columns: 1fr; - } - .dm-curve-module { grid-template-rows: auto 170px auto; } @@ -191,23 +64,7 @@ .dm-slider-bank, .dm-channel-strip, .dm-led-row, - .dm-tuning-ribbon, - .dm-steam-identity, - .dm-system-toggles, .dm-support-panel, - .dm-action-row, - .dm-scope-strip button, - .dm-game-grid > button, - .dm-live-panel-head, - .dm-stick-head, - .dm-trigger-meter > div:first-child, - .dm-map-context-grid, - .dm-map-placeholder, - .dm-button-bind-list, - .dm-map-source-grid, - .dm-map-quick-grid > div, - .dm-steam-binding-row, - .dm-input-path-actions, .dm-global-feel-controls, .dm-pattern-grid, .dm-vibration-mode-grid, @@ -217,43 +74,7 @@ grid-template-columns: 1fr; } - button.dm-ribbon-picker-trigger.dm-game-identity-cell { - grid-template-columns: 86px minmax(0, 1fr); - } - - .dm-steam-identity .dm-ribbon-game-media { - width: 86px; - height: 46px; - } - - .dm-controller-stage { - min-height: 0; - } - - .dm-controller-canvas { - min-width: 0; - } - - .dm-map-source-row strong, - .dm-steam-binding-row em { - text-align: left; - } - .dm-channel-strip { align-items: stretch; } - - .dm-action-row { - display: grid; - } - - .dm-controller-card { - min-height: 220px; - } - - .dm-game-card-media img, - .dm-game-art-fallback { - width: 100%; - height: 148px; - } } diff --git a/web/src/styles/ribbon.css b/web/src/styles/ribbon.css deleted file mode 100644 index 8de39a0..0000000 --- a/web/src/styles/ribbon.css +++ /dev/null @@ -1,428 +0,0 @@ -.dm-tuning-ribbon { - position: sticky; - top: 74px; - z-index: 24; - display: grid; - grid-template-columns: minmax(650px, 1.4fr) minmax(360px, 0.72fr); - align-items: stretch; - gap: 10px; - flex: 0 0 62px; - width: min(1500px, 100%); - height: 62px; - margin: 0 auto 18px; -} - -.dm-steam-identity, -.dm-system-toggles { - min-width: 0; - background: rgba(18, 18, 20, 0.54); - box-shadow: - inset 0 1px 0 rgba(226, 232, 240, 0.045), - inset 0 0 0 1px rgba(113, 113, 122, 0.11); -} - -.dm-steam-identity { - display: grid; - grid-template-columns: minmax(210px, 0.82fr) minmax(380px, 1.45fr) minmax(220px, 0.88fr); - align-items: center; - gap: 8px; - overflow: hidden; -} - -.dm-steam-identity > span.dm-controller-glyph { - width: 58px; - height: 42px; - margin: 0 auto; - color: var(--haptic); - opacity: 0.82; -} - -.dm-steam-identity > div { - min-width: 0; - padding-right: 12px; -} - -.dm-steam-identity span, -.dm-switch-line span, -.dm-location-line span { - display: block; - color: var(--tungsten); - font-size: 10px; - font-weight: 800; - line-height: 1; - text-transform: uppercase; -} - -.dm-steam-identity strong, -.dm-switch-line strong { - display: block; - overflow: hidden; - color: #FFFFFF; - font-size: 13px; - font-weight: 700; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-steam-identity p, -.dm-switch-line small, -.dm-location-line small { - display: block; - margin: 2px 0 0; - overflow: hidden; - color: var(--tungsten); - font-size: 11px; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-active-profile-cell { - position: relative; - align-self: stretch; - display: grid; - align-content: center; - padding: 6px 12px 6px 12px; - border-left: 1px solid rgba(113, 113, 122, 0.18); -} - -.dm-active-profile-cell strong { - color: var(--actuation); - font-size: 15px; - font-weight: 700; - letter-spacing: 0.01em; -} - -.dm-active-profile-cell span { - color: var(--tungsten); - opacity: 1; -} - -.dm-ribbon-picker-host { - position: relative; - display: grid; - align-self: stretch; - min-width: 0; -} - -button.dm-ribbon-picker-trigger { - position: relative; - display: grid; - align-content: center; - width: 100%; - appearance: none; - border: 0; - background: transparent; - font: inherit; - text-align: inherit; - color: inherit; - cursor: pointer; - padding: 6px 28px 6px 12px; - border-radius: 6px; - transition: background 140ms ease-out, box-shadow 140ms ease-out; -} - -button.dm-ribbon-picker-trigger.dm-game-identity-cell { - grid-template-columns: 94px minmax(0, 1fr); - align-items: center; - align-content: stretch; - gap: 12px; - overflow: hidden; - padding-left: 8px; -} - -button.dm-ribbon-picker-trigger.dm-active-profile-cell { - padding-left: 12px; -} - -button.dm-ribbon-picker-trigger:hover:not(:disabled), -button.dm-ribbon-picker-trigger:focus-visible { - background: rgba(0, 112, 204, 0.08); - outline: none; - box-shadow: inset 0 0 0 1px rgba(0, 112, 204, 0.35); -} - -button.dm-ribbon-picker-trigger.open { - background: rgba(0, 112, 204, 0.12); - box-shadow: inset 0 0 0 1px rgba(0, 112, 204, 0.55); -} - -button.dm-ribbon-picker-trigger:disabled { - cursor: default; - padding-right: 12px; -} - -button.dm-ribbon-picker-trigger:disabled .dm-ribbon-picker-caret { - display: none; -} - -.dm-ribbon-picker-caret { - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--haptic); - font-size: 11px; - font-weight: 800; - opacity: 0.7; - pointer-events: none; - transition: transform 140ms ease-out, opacity 140ms ease-out; -} - -button.dm-ribbon-picker-trigger.open .dm-ribbon-picker-caret { - transform: translateY(-50%) rotate(180deg); - opacity: 1; -} - -.dm-steam-identity .dm-ribbon-game-backdrop { - position: absolute; - inset: 0; - z-index: 0; - display: block; - overflow: hidden; - opacity: 0.24; - color: inherit; - font-size: inherit; - font-weight: inherit; - line-height: normal; - text-transform: none; - pointer-events: none; -} - -.dm-steam-identity .dm-ribbon-game-backdrop::after { - content: ""; - position: absolute; - inset: 0; - background: - linear-gradient(90deg, rgba(18, 18, 20, 0.18), rgba(18, 18, 20, 0.76) 42%, rgba(18, 18, 20, 0.94)), - linear-gradient(180deg, rgba(18, 18, 20, 0.12), rgba(18, 18, 20, 0.78)); -} - -.dm-steam-identity .dm-ribbon-game-backdrop img { - width: 100%; - height: 100%; - object-fit: cover; - filter: saturate(1.08) contrast(1.05); -} - -.dm-steam-identity .dm-ribbon-game-media { - position: relative; - z-index: 1; - display: grid; - place-items: center; - width: 94px; - height: 48px; - overflow: hidden; - border-radius: 4px; - background: rgba(7, 7, 8, 0.52); - box-shadow: - inset 0 0 0 1px rgba(226, 232, 240, 0.12), - 0 8px 20px rgba(0, 0, 0, 0.24); - color: inherit; - font-size: inherit; - font-weight: inherit; - line-height: normal; - text-transform: none; -} - -.dm-steam-identity .dm-ribbon-game-media img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.dm-steam-identity .dm-ribbon-game-copy { - position: relative; - z-index: 1; - display: grid; - align-content: center; - min-width: 0; -} - -.dm-ribbon-game-copy span, -.dm-ribbon-game-copy strong, -.dm-ribbon-game-copy p { - min-width: 0; -} - -.dm-ribbon-picker-menu { - position: fixed; - z-index: 7500; - display: flex; - flex-direction: column; - gap: 2px; - min-width: 240px; - max-width: 360px; - max-height: min(420px, 70vh); - overflow-y: auto; - padding: 6px; - border: 1px solid rgba(0, 112, 204, 0.34); - border-radius: 8px; - background: - linear-gradient(180deg, rgba(26, 28, 34, 0.98), rgba(14, 14, 18, 0.99)); - box-shadow: - 0 24px 64px rgba(0, 0, 0, 0.62), - inset 0 1px 0 rgba(226, 232, 240, 0.06); -} - -.dm-ribbon-picker-menu.profile { - min-width: 260px; -} - -.dm-ribbon-picker-divider { - height: 1px; - margin: 4px 6px; - background: rgba(113, 113, 122, 0.22); -} - -button.dm-ribbon-picker-item { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - align-items: center; - gap: 10px; - padding: 7px 9px; - border: 0; - border-radius: 5px; - color: var(--haptic); - background: transparent; - font: inherit; - text-align: left; - cursor: pointer; - transition: background 120ms ease-out, color 120ms ease-out; -} - -button.dm-ribbon-picker-item:hover, -button.dm-ribbon-picker-item:focus-visible { - outline: none; - color: #FFFFFF; - background: rgba(0, 112, 204, 0.18); -} - -button.dm-ribbon-picker-item.active { - color: #FFFFFF; - background: rgba(0, 112, 204, 0.28); - box-shadow: inset 0 0 0 1px rgba(0, 112, 204, 0.55); -} - -button.dm-ribbon-picker-item:disabled { - cursor: not-allowed; - opacity: 0.52; -} - -.dm-ribbon-picker-thumb { - display: grid; - place-items: center; - width: 34px; - height: 34px; - overflow: hidden; - border-radius: 6px; - color: #FFFFFF; - background: transparent; - font-family: "JetBrains Mono", monospace; - font-size: 12px; - font-weight: 800; -} - -.dm-ribbon-picker-thumb img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 6px; -} - -.dm-ribbon-picker-thumb.art { - background: transparent; -} - -.dm-ribbon-picker-copy { - display: grid; - gap: 2px; - min-width: 0; -} - -.dm-ribbon-picker-copy strong { - display: block; - margin: 0; - overflow: hidden; - color: inherit; - font-size: 13px; - font-weight: 700; - line-height: 1.18; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-ribbon-picker-copy small { - display: block; - margin: 0; - color: var(--tungsten); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.01em; - line-height: 1.3; - text-transform: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-system-toggles { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - padding: 7px 8px; -} - -.dm-switch-line { - display: grid; - grid-template-columns: minmax(0, 1fr) max-content; - align-items: center; - gap: 10px; - min-width: 0; -} - -.dm-glyph-switch > div { - display: grid; - align-content: center; - gap: 3px; - min-width: 0; -} - -.dm-glyph-switch span, -.dm-glyph-switch strong, -.dm-glyph-switch small { - line-height: 1.15; -} - -.dm-glyph-switch small { - margin-top: 0; -} - -.dm-location-line, -.dm-location-line label { - display: grid; - min-width: 0; -} - -.dm-location-line label { - gap: 5px; -} - -.dm-location-line select { - width: 100%; - min-width: 0; - height: 26px; - border: 0; - border-radius: 5px; - color: #FFFFFF; - background: rgba(10, 10, 12, 0.78); - box-shadow: inset 0 0 0 1px rgba(113, 113, 122, 0.24); - font-size: 12px; - font-weight: 700; - outline: none; -} - -.dm-location-line select:focus { - box-shadow: inset 0 0 0 1px var(--actuation), 0 0 0 2px rgba(0, 112, 204, 0.16); -} diff --git a/web/src/styles/shell-v2.css b/web/src/styles/shell-v2.css new file mode 100644 index 0000000..42526eb --- /dev/null +++ b/web/src/styles/shell-v2.css @@ -0,0 +1,277 @@ +/* Shell v2 — slim sidebar + document-flow main column (calm console, flat fills). */ + +/* The document itself scrolls; the sidebar stays sticky beside it. base.css + and responsive.css defer to these rules for the scroll model. */ +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; +} + +/* The brand mark is the DualSense controller glyph (a currentColor mask over + /dualsense-controller.svg, shared with the controller cards). The artwork + sits in a central band of the square SVG canvas, so the mask is zoomed + past the box to render the controller itself at ~24px tall. */ +.dm-controller-glyph.sidebar-brand-glyph { + display: block; + width: 36px; + height: 24px; + mask-size: 40px 40px; + -webkit-mask-size: 40px 40px; +} + +.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 wide instrument panels so a stray + wide row never scrolls the whole document sideways. */ + overflow-x: hidden; + padding: 18px clamp(18px, 2.2vw, 34px) 22px; +} + +/* Utility row above the views — cross-view context (target controller, web UI + bind address, glyph override, system readout). */ +.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); +} + +/* Each field is exactly two rows — label, then control — so every toolbar + item shares one label line and one control line. The optional caption + (e.g. the bind address) sits inline beside the control instead of below + it, keeping the row's baseline tidy. */ +.app-toolbar-field { + display: grid; + grid-template-columns: auto minmax(0, auto); + align-items: center; + column-gap: 8px; + row-gap: 3px; + min-width: 0; +} + +.app-toolbar-field > span { + grid-column: 1 / -1; +} + +.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: 3px; + min-width: 0; +} + +/* Value + detail share one line, height-matched to the toolbar selects so the + readout label sits on the same line as the field labels. */ +.app-toolbar-readout > p { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 30px; + margin: 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; +} + +.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); +} + +/* 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; + } + + .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; + } +} diff --git a/web/src/styles/shell.css b/web/src/styles/shell.css deleted file mode 100644 index 4a99705..0000000 --- a/web/src/styles/shell.css +++ /dev/null @@ -1,235 +0,0 @@ -.ops-shell { - position: relative; - isolation: isolate; - display: flex; - flex-direction: column; - width: 100%; - min-height: 100vh; - height: 100vh; - padding: 18px clamp(18px, 2.2vw, 34px) 22px; - overflow-x: hidden; - overflow-y: auto; - /* Reserve the vertical scrollbar's track so its appearance never reflows - content. On platforms with classic (16px) scrollbars this prevents the - transient horizontal overflow that intermittently tripped the visual - smoke test; on overlay-scrollbar platforms it is a no-op. */ - scrollbar-gutter: stable; - color: var(--haptic); - background: - radial-gradient(circle at 50% -18%, rgba(0, 112, 204, 0.14), transparent 36%), - radial-gradient(circle at 82% 18%, rgba(226, 232, 240, 0.045), transparent 25%), - linear-gradient(180deg, #0C0C0F 0%, var(--obsidian) 42%, #070708 100%); -} - -.ops-shell::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - pointer-events: none; - opacity: 0.18; - background-image: - linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.014) 1px, transparent 1px); - background-size: 44px 44px; - mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.92), rgba(0, 0, 0, 0.18)); -} - -.dm-hud { - position: sticky; - top: 0; - z-index: 30; - display: grid; - grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr); - align-items: center; - gap: clamp(14px, 2vw, 26px); - flex: 0 0 64px; - height: 64px; - background: linear-gradient(180deg, rgba(12, 12, 15, 0.96), rgba(12, 12, 15, 0.82)); - backdrop-filter: blur(12px); -} - -.dm-hardware-state, -.dm-system-readout { - display: flex; - align-items: center; - gap: 14px; - min-width: 0; -} - -.dm-hardware-state > div { - min-width: 0; -} - -.dm-hardware-state h1 { - margin: 0; - overflow: hidden; - color: #FFFFFF; - font-family: "Space Grotesk", "Inter Tight", Inter, sans-serif; - font-size: 20px; - font-weight: 700; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-app-tagline { - display: inline-block; - color: var(--tungsten); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.dm-hardware-state p, -.dm-system-readout small, -.dm-system-readout span { - margin: 0; - overflow: hidden; - color: var(--tungsten); - font-size: 12px; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dm-hardware-state p { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.dm-controller-glyph { - width: 42px; - height: 42px; - flex: 0 0 auto; - color: var(--haptic); - 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(--tungsten); -} - -.dm-battery { - width: 28px; - height: 14px; - flex: 0 0 auto; - color: var(--tungsten); -} - -.dm-battery rect:first-child, -.dm-battery path { - fill: none; - stroke: currentColor; - stroke-width: 1.5; -} - -.dm-battery-fill { - fill: var(--haptic); -} - -.dm-system-cluster { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - width: 100%; - min-width: 0; -} - -.dm-system-readout { - justify-content: flex-end; - text-align: right; -} - -.dm-view-nav { - display: inline-flex; - align-items: center; - justify-self: center; - gap: 4px; - max-width: 100%; - padding: 4px; - overflow: hidden; - background: rgba(18, 18, 20, 0.72); - box-shadow: - inset 0 1px 0 rgba(226, 232, 240, 0.06), - inset 0 0 0 1px rgba(226, 232, 240, 0.06); -} - -.dm-view-nav .dscc-tooltip { - flex: 0 1 auto; - min-width: 0; -} - -.dm-view-nav .dscc-tooltip button { - width: 100%; -} - -.dm-view-nav button { - height: 34px; - min-width: 0; - padding: 0 14px; - border: 0; - border-radius: 5px; - color: var(--tungsten); - background: transparent; - font-size: 12px; - font-weight: 800; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.15s ease-out, background-color 0.15s ease-out, box-shadow 0.15s ease-out; -} - -.dm-view-nav button:hover, -.dm-view-nav button.active { - color: #FFFFFF; - background: rgba(0, 112, 204, 0.16); - box-shadow: inset 0 0 0 1px rgba(0, 112, 204, 0.34); -} - -.dm-system-readout strong { - color: var(--actuation); - font-size: 15px; - font-weight: 700; - white-space: nowrap; -} - -.dm-system-readout span, -.dm-system-readout small { - display: block; -} - -.dm-support-trigger { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 32px; - padding: 0 7px; - border: 1px solid rgba(113, 113, 122, 0.44); - border-radius: 6px; - color: var(--haptic); - background: rgba(18, 18, 20, 0.72); - font-size: 12px; - font-weight: 800; - white-space: nowrap; - transition: border-color 150ms ease-out, background-color 150ms ease-out, color 150ms ease-out; -} - -.dm-support-trigger:hover, -.dm-support-trigger.active { - border-color: rgba(0, 112, 204, 0.72); - color: #FFFFFF; - background: rgba(0, 112, 204, 0.14); -} 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); } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index e69f7de..ab5ceb3 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -1,16 +1,58 @@ :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; + + /* 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; font-synthesis: none; text-rendering: optimizeLegibility; } + +@media (prefers-reduced-motion: reduce) { + :root { --speed: 0ms; } +} diff --git a/web/src/styles/tuning.css b/web/src/styles/tuning.css new file mode 100644 index 0000000..4f07025 --- /dev/null +++ b/web/src/styles/tuning.css @@ -0,0 +1,1044 @@ +/* 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-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; + 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; +} + +/* --- tuning canvas: semantic columns (Task 6; layout truth tuning-canvas-v9) - + + Columns group by what is being tuned (Brake / Throttle / Road feel / + Lights). No wasted space: extra width grows the trigger-curve instruments + only; small columns cap at 320px and slider tracks at --slider-cap. Task 7 + wraps .canvas-grid in a .work-and-rail flex container. */ + +.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; +} + +@media (max-width: 760px) { + .col-trigger, + .col-small { + max-width: none; + flex-basis: 100%; + } +} + +.canvas-col-body { + margin-top: 6px; + display: grid; + gap: 12px; + align-content: start; +} + +.canvas-hint { + margin: 10px 0 0; + font-size: 10px; + color: var(--ink-muted); +} + +/* Pre-canvas panel chrome neutralized inside columns — the column label carries + the identity, the panels become bare instruments. */ +.canvas-grid .dm-physics { + display: block; + padding: 0; + background: none; + box-shadow: none; +} + +.canvas-grid .dm-curve-stack { + display: block; +} + +.canvas-grid .dm-curve-module { + grid-template-rows: auto auto auto; +} + +.canvas-grid .dm-curve-frame { + height: 170px; +} + +.canvas-grid .dm-slider-bank { + margin-top: 8px; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.canvas-grid .dm-channel-list { + overflow: visible; + padding: 0; +} + +/* Channel strips reflow to three rows inside the narrow semantic columns: + toggle | name on top, then the fader (slider + value field), then the + route. The fader's value field needs ~154px beside the slider, which the + 210–320px small columns cannot grant next to a route select, so each + control row gets the full strip width and nothing overlaps. */ +.canvas-grid .dm-channel-strip { + grid-template-columns: 42px minmax(0, 1fr); + row-gap: 6px; +} + +.canvas-grid .dm-channel-strip > .strip-name { + grid-column: 2 / -1; +} + +.canvas-grid .dm-channel-strip > .strip-fader { + grid-column: 1 / -1; + grid-row: 2; +} + +.canvas-grid .dm-channel-strip > .strip-route { + grid-column: 1 / -1; + grid-row: 3; + max-width: calc(var(--slider-cap) + 68px); /* match the fader's cap */ +} + +/* Sliders never stretch: cap the track, keep the value field beside it. */ +.canvas-grid .dm-range { + max-width: var(--slider-cap); +} + +.canvas-grid .dm-fader { + max-width: calc(var(--slider-cap) + 68px); /* track cap + value field + gap */ +} + +.dm-effects-embedded-head { + display: flex; + justify-content: flex-end; +} + +/* Lights column: the RGB console rows wrap instead of demanding the wide + six-track grid, and the console title yields to the column label. */ +.canvas-grid .dm-rgb-console { + padding-top: 0; + box-shadow: none; +} + +.canvas-grid .dm-console-title { + display: none; +} + +.canvas-grid .dm-led-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.canvas-grid .dm-led-row > span { + flex: 0 0 auto; +} + +.canvas-grid .dm-led-row .dm-mini-range { + flex: 1 1 96px; + max-width: var(--slider-cap); +} + +/* --- saved rail (Task 7; layout truth tuning-canvas-v9) --------------------- + + THE RULE: the rail is furniture. Fixed --saved-rail-w, sticky, docked right + of .canvas-grid, never part of the column wrap. Below 900px it hides and a + bottom-docked bar takes over (rendered only while changes are unsaved). */ + +.work-and-rail { + display: flex; + align-items: flex-start; +} + +.saved-rail { + flex: 0 0 var(--saved-rail-w); + position: sticky; + top: 14px; + margin: 14px 16px 18px 0; + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-m); +} + +.saved-rail-title { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.saved-rail-rows { + margin-top: 6px; + max-height: min(48vh, 420px); + overflow-y: auto; +} + +.saved-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 4px 0; + border-bottom: 1px solid var(--hairline); + font-size: 11px; +} + +.saved-row:last-child { + border-bottom: 0; +} + +.saved-row-label { + color: var(--ink); + min-width: 0; +} + +.saved-row-value { + flex: 0 1 auto; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.saved-row-saved, +.saved-row-was { + color: var(--ink-muted); +} + +.saved-row-now { + color: var(--accent-text); + font-weight: 600; +} + +.saved-rail-foot { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--hairline-strong); +} + +.saved-preview-row { + display: flex; + align-items: center; + gap: 6px; +} + +.saved-preview-button { + background: var(--accent-tint); + color: var(--accent-text); + border: 0; + border-radius: var(--radius-s); + padding: 5px 12px; + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.saved-preview-button.active { + box-shadow: inset 0 0 0 1px var(--accent-outline); +} + +.saved-preview-button:disabled { + opacity: 0.55; + cursor: default; +} + +.saved-preview-note { + font-size: 9px; + color: var(--ink-muted); + white-space: nowrap; +} + +.saved-rail-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.saved-save-button { + flex: 1; + background: var(--accent); + color: #fff; + border: 0; + border-radius: var(--radius-s); + padding: 5px 0; + font-family: inherit; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.saved-discard-button { + flex: 0 0 auto; + background: var(--surface-raised); + color: var(--ink-muted); + border: 0; + border-radius: var(--radius-s); + padding: 5px 12px; + font-family: inherit; + font-size: 11px; + cursor: pointer; +} + +.saved-save-button:disabled, +.saved-discard-button:disabled { + opacity: 0.55; + cursor: default; +} + +/* <900px: the rail becomes a bottom-docked bar with the same Save/Discard, + expanding to the full saved list on tap. Hidden entirely while clean + (the bar only renders when dirty). */ +.saved-mobile-bar { + display: none; + position: fixed; + right: 12px; + bottom: 12px; + z-index: var(--z-sticky); + /* 100% (the fixed-position containing block) rather than 100vw: the + viewport unit includes the document scrollbar and overhangs the edge. */ + width: min(420px, calc(100% - 24px)); + background: var(--surface); + border: 1px solid var(--accent-outline); + border-radius: var(--radius-m); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); +} + +.saved-mobile-rows { + padding: 8px 12px 0; + max-height: min(46vh, 360px); + overflow-y: auto; +} + +.saved-mobile-rows .saved-preview-row { + padding: 8px 0; +} + +.saved-mobile-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; +} + +.saved-mobile-toggle { + background: none; + border: 0; + padding: 0; + font-family: inherit; + font-size: 11px; + color: var(--ink-muted); + cursor: pointer; + min-width: 0; + text-align: left; +} + +.saved-mobile-toggle strong { + color: var(--accent-text); + font-weight: 600; +} + +.saved-mobile-actions { + display: flex; + gap: 8px; + flex: 0 0 auto; +} + +.saved-mobile-actions .saved-save-button { + flex: 0 0 auto; + padding: 4px 10px; +} + +.saved-mobile-actions .saved-discard-button { + padding: 4px 10px; +} + +@media (max-width: 900px) { + .saved-rail { + display: none; + } + + .saved-mobile-bar { + display: block; + } +} + +/* Content parked below the grid until Tasks 8-10 re-home it. */ +.canvas-below { + display: grid; + gap: 14px; + padding: 0 16px 18px; +} + +.canvas-parked { + display: grid; + gap: 12px; + padding: 14px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-m); +} + +/* The parked trigger-controls panel sits directly in .canvas-below; give it + the same bounded field the .canvas-parked wrappers provide (the HUD-era + panel background it used to lean on is gone). */ +.canvas-below > .dm-physics { + background: var(--surface); + border: 1px solid var(--hairline); + 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; + } + + .tuning-header-controller { + order: -1; + width: 100%; + justify-content: flex-end; + } + + .tuning-header-game strong { + font-size: 14px; + max-width: 60vw; + } +} diff --git a/web/src/styles/workspace.css b/web/src/styles/workspace.css deleted file mode 100644 index 6babacd..0000000 --- a/web/src/styles/workspace.css +++ /dev/null @@ -1,27 +0,0 @@ -.dm-deck { - display: grid; - grid-template-columns: minmax(0, 5.7fr) minmax(0, 6.3fr); - gap: clamp(14px, 1.5vw, 22px); - flex: 1 1 0; - width: min(1500px, 100%); - height: auto; - margin: 0 auto; - min-height: 0; -} - -.dm-view-hidden { - display: none !important; -} - -.dm-physics, -.dm-routing, -.dm-button-map-panel { - min-width: 0; - min-height: 0; - background: - linear-gradient(180deg, rgba(226, 232, 240, 0.035), rgba(226, 232, 240, 0.012)), - rgba(18, 18, 20, 0.82); - box-shadow: - inset 0 1px 0 rgba(226, 232, 240, 0.06), - inset 0 0 0 1px rgba(226, 232, 240, 0.035); -}