diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 7967e8b8..b579762f 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -15,7 +15,9 @@ const BUDGET_BYTES: Record = { // one block of component-level overrides — no UI framework was added. // Raised from 72 kB for the Mouse Check page CSS bundled alongside the // themes in the aggregate main + check stylesheet. - ".css": 78_000, + // Raised from 78 kB for the accessible theme-card grid and responsive live + // workspace preview. The existing theme tokens drive it; no dependency was added. + ".css": 79_000, // Raised from 280 kB for the production Logitech onboard-profile codec, // guarded flash editor, verification exporter, upstream Finalmouse driver, // the dedicated Viper Mini protocol driver, Viper V3 sleep/low-power plus diff --git a/src/control-events.ts b/src/control-events.ts index 56903197..658d7196 100644 --- a/src/control-events.ts +++ b/src/control-events.ts @@ -19,7 +19,6 @@ export interface ControlEventHandlers { selectAuthorizedDevice(index: number): Promise; openInterfaceSettings(): void; closeInterfaceSettings(): void; - setInterfaceDensity(value: string): void; setInterfaceTheme(value: string): void; setReducedMotion(enabled: boolean): void; setExpandSections(enabled: boolean): void; @@ -107,11 +106,8 @@ export function bindControlEvents(handlers: ControlEventHandlers): void { onClick("#interface-settings-button", handlers.openInterfaceSettings); onClick("#close-interface-settings", handlers.closeInterfaceSettings); - document.querySelector("#interface-density")?.addEventListener("change", (event) => { - handlers.setInterfaceDensity((event.target as HTMLSelectElement).value); - }); - document.querySelector("#interface-theme")?.addEventListener("change", (event) => { - handlers.setInterfaceTheme((event.target as HTMLSelectElement).value); + document.querySelectorAll('input[name="interface-theme"]').forEach((input) => { + input.addEventListener("change", () => handlers.setInterfaceTheme(input.value)); }); document.querySelector("#interface-reduced-motion")?.addEventListener("change", (event) => { handlers.setReducedMotion((event.target as HTMLInputElement).checked); diff --git a/src/control-template.ts b/src/control-template.ts index c39503ea..6bfea071 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -2,6 +2,15 @@ const TOGGLE_ROW = "switch-row"; const FIELD_LABEL = "field-label"; const CARD_HEADING = "setting-heading compact"; +const THEME_CHOICES = [ + ["Emerald", "#69d28d"], ["Violet", "#a78bfa"], ["Ice", "#67d8ff"], + ["Ember", "#ff9b62"], ["Mono", "#f1f1f3"], ["Miku", "#39c5bb"], + ["Catppuccin Mocha", "#cba6f7"], ["Catppuccin Macchiato", "#c6a0f6"], ["Catppuccin Frappé", "#ca9ee6"], +] as const; + +function themeChoices(): string { + return THEME_CHOICES.map(([name, swatch]) => ``).join(""); +} function superstrikeSteps(id: string, min: number, max: number): string { return `
${Array.from({ length: max - min + 1 }, (_, index) => { @@ -134,8 +143,7 @@ export function controlTemplate(buildLabel: string): string {

OPENMOUSE

Interface settings

-
LAYOUT

Interface density

Choose tighter controls or add more breathing room throughout the panel.

-
APPEARANCE

Accent theme

Customize active controls, status lights, switches, and focus highlights.

+
APPEARANCE

Accent theme

Choose a theme and preview it across a miniature OpenMouse workspace.

${themeChoices()}

Mono preview

MOTION

Animation

Disable interface transitions and animated state changes.

WRITES

Instant flash

Write each change to the mouse as soon as you make it, instead of staging it for the flash bar.

SECTIONS

Advanced editors

Choose whether CPI, button mapping, and experimental sections begin expanded.

diff --git a/src/control.css b/src/control.css index 9004dbb2..aae2692c 100644 --- a/src/control.css +++ b/src/control.css @@ -206,10 +206,9 @@ button:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid .control-shell[data-interface-theme="miku"] .pending-flash:hover:not(:disabled) { background: #159a96; border-color: #159a96; filter: none; } /* The pending-flash bar follows the accent in Miku rather than the mono white. */ .control-shell[data-interface-theme="miku"] .pending-flash { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-ink); } -/* Selected tab underline and the accent-preview tip read as soft cyan/pink. */ +/* Selected tab underline reads as soft cyan. */ .control-shell[data-interface-theme="miku"] .workspace-tabs button[aria-selected="true"]::after { background: var(--miku-soft-cyan); } -.control-shell[data-interface-theme="miku"] .theme-preview i:last-child { background: var(--miku-pink); } -.control-shell[data-interface-theme="miku"] .theme-preview i:nth-child(4) { background: var(--miku-soft-cyan); } +.control-shell[data-interface-theme="miku"] #miku-theme-preview-mascot { display: block; } /* ============================================================ Catppuccin theme family — Mocha, Macchiato and Frappé: three @@ -406,12 +405,6 @@ button:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid background: color-mix(in srgb, var(--destructive) 30%, var(--ctp-surface0)); color: var(--ctp-text); } -/* Theme preview: a surface ramp closing on blue and mauve. */ -.control-shell[data-interface-theme^="catppuccin-"] .theme-preview i:nth-child(1) { background: var(--ctp-surface0); opacity: 1; } -.control-shell[data-interface-theme^="catppuccin-"] .theme-preview i:nth-child(2) { background: var(--ctp-surface1); opacity: 1; } -.control-shell[data-interface-theme^="catppuccin-"] .theme-preview i:nth-child(3) { background: var(--ctp-surface2); opacity: 1; } -.control-shell[data-interface-theme^="catppuccin-"] .theme-preview i:nth-child(4) { background: var(--ctp-blue); opacity: 1; } -.control-shell[data-interface-theme^="catppuccin-"] .theme-preview i:nth-child(5) { background: var(--ctp-mauve); opacity: 1; } .control-shell button:focus-visible, .control-shell select:focus-visible, .control-shell input:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; } .control-shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: 100vh; } @@ -623,12 +616,28 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .interface-setting-card h3 { margin: .35rem 0 .3rem; font-size: 1rem; font-weight: 550; } .interface-setting-card p { margin: 0 0 .8rem; color: var(--muted); font-size: .68rem; line-height: 1.45; } .interface-setting-card select { width: 100%; padding: .52rem; border: 1px solid var(--border); border-radius: 7px; background: var(--input); color: var(--text-input); color-scheme: dark; } -.theme-preview { display: flex; gap: .32rem; margin-top: .65rem; } -.theme-preview i { width: 1.25rem; height: .28rem; border-radius: 999px; background: var(--ui-accent); opacity: .2; } -.theme-preview i:nth-child(2) { opacity: .35; } -.theme-preview i:nth-child(3) { opacity: .55; } -.theme-preview i:nth-child(4) { opacity: .75; } -.theme-preview i:nth-child(5) { opacity: 1; } +.interface-theme-card { grid-column: 1 / -1; } +.theme-studio { display: grid; grid-template-columns: minmax(0, .9fr) minmax(280px, 1.1fr); gap: 1rem; } +.theme-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .4rem; margin: 0; padding: 0; border: 0; } +.theme-choice { position: relative; display: flex; min-width: 0; align-items: center; gap: .5rem; padding: .52rem .6rem; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-control); color: var(--text-strong); font-size: .68rem; cursor: pointer; } +.theme-choice input { position: absolute; opacity: 0; } +.theme-choice i { width: .62rem; height: .62rem; flex: 0 0 auto; border-radius: 50%; background: var(--theme-swatch); } +.theme-choice:has(input:checked) { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--text-hover); } +.theme-choice:has(input:focus-visible) { outline: 2px solid var(--ui-accent); outline-offset: 2px; } +.theme-demo-window { overflow: hidden; border: 1px solid var(--border-hover); border-radius: 8px; background: var(--surface-bg); } +.theme-demo-window > header { padding: .45rem .6rem; border-bottom: 1px solid var(--border); background: var(--surface-sidebar); color: var(--text-faint); font-family: var(--font-mono); font-size: .48rem; letter-spacing: .08em; } +.theme-demo-body { position: relative; display: grid; min-height: 132px; grid-template-columns: 28% 1fr; } +.theme-demo-body > nav { display: grid; align-content: start; gap: .45rem; margin: 0; padding: .8rem .6rem; border-right: 1px solid var(--border); background: var(--surface-sidebar); } +.theme-demo-body > nav i { height: .28rem; border-radius: 9px; background: var(--surface-raised); } +.theme-demo-body > nav i:first-child { background: var(--ui-accent); } +.theme-demo-body > main { display: flex; min-width: 0; flex-direction: column; padding: .75rem; } +.theme-demo-body small { color: var(--text-faint); font-family: var(--font-mono); font-size: .45rem; } +.theme-demo-body strong { margin: .15rem 0 .55rem; color: var(--text-hover); font-size: .8rem; } +.theme-demo-body main div { display: flex; justify-content: space-between; gap: .5rem; padding: .45rem; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-panel); color: var(--text-soft); font-size: .52rem; } +.theme-demo-body main b { color: var(--text-strong); } +.theme-demo-body main em { align-self: flex-end; margin-top: auto; padding: .3rem .45rem; border-radius: 4px; background: var(--ui-accent); color: var(--ui-accent-ink); font-size: .48rem; font-style: normal; font-weight: 700; } +#miku-theme-preview-mascot { position: absolute; right: 5.3rem; bottom: .2rem; display: none; width: 2.8rem; pointer-events: none; } +.interface-setting-card .theme-demo > p { margin: .35rem 0 0; color: var(--text-faint); font-family: var(--font-mono); font-size: .5rem; text-align: right; } .interface-switch-row { display: flex; align-items: center; justify-content: space-between; gap: .8rem; margin-top: .7rem; color: var(--text-strong); font-size: .72rem; } .interface-switch-row input { appearance: none; @@ -648,12 +657,6 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .interface-switch-row input:focus-visible { box-shadow: 0 0 0 3px var(--ui-accent-soft); } .interface-reset { margin-top: .75rem; padding: .55rem .75rem; border: 1px solid #4a3436; border-radius: 7px; background: #1c1315; color: #e7a7aa; font-size: .68rem; font-weight: 650; } -.density-comfortable #pulsar-advanced.egg-advanced-layout { gap: .8rem !important; } -.density-comfortable #pulsar-advanced.egg-advanced-layout > .setting-card { padding: 1rem !important; } -.density-comfortable .settings-grid { gap: .9rem !important; } -.density-comfortable .settings-grid .setting-card { padding: 1.1rem !important; } -.density-comfortable .device-overview .summary-stat { padding: 1rem 1.2rem !important; } -.density-comfortable .egg-collapsible summary, .density-comfortable .egg-experimental summary { padding: .95rem 1rem; } .reduce-interface-motion *, .reduce-interface-motion *::before, .reduce-interface-motion *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } .small-screen-blocker { display: flex; min-height: 100vh; align-items: flex-start; justify-content: center; flex-direction: column; padding: clamp(2rem, 8vw, 7rem); } @@ -728,6 +731,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } @media (max-width: 720px) { .interface-settings-grid { grid-template-columns: 1fr; } + .theme-studio { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { diff --git a/src/control.ts b/src/control.ts index 4160be86..af2b2289 100644 --- a/src/control.ts +++ b/src/control.ts @@ -37,7 +37,6 @@ import { loadInterfacePreferences, saveInterfacePreferences as persistInterfacePreferences, interfaceThemeSlug, - type InterfaceDensity, type InterfaceTheme, } from "./interface-preferences"; import { @@ -346,17 +345,19 @@ function applyInterfacePreferences(): void { setPendingBarSuppressed(interfacePreferences.instantFlash); const shell = document.querySelector(".control-shell"); if (!shell) return; - shell.classList.toggle("density-comfortable", interfacePreferences.density === "Comfortable"); shell.classList.toggle("reduce-interface-motion", interfacePreferences.reducedMotion); shell.classList.toggle("sidebar-hidden", sidebarHidden); const menuToggle = document.querySelector("#sidebar-menu-toggle"); if (menuToggle) menuToggle.setAttribute("aria-pressed", String(!sidebarHidden)); shell.dataset.interfaceTheme = interfaceThemeSlug(interfacePreferences.theme); - const mascot = document.querySelector("#miku-mascot"); - const mascotSource = mascot?.dataset.src; - if (interfacePreferences.theme === "Miku" && mascot && mascotSource && !mascot.getAttribute("src")) { - mascot.src = mascotSource; - } + document.querySelectorAll('input[name="interface-theme"]').forEach((input) => { + input.checked = input.value === interfacePreferences.theme; + }); + setText("#theme-preview-name", `${interfacePreferences.theme} preview`); + if (interfacePreferences.theme === "Miku") document.querySelectorAll("#miku-mascot, #miku-theme-preview-mascot").forEach((mascot) => { + const source = mascot.dataset.src; + if (source && !mascot.getAttribute("src")) mascot.src = source; + }); document.querySelectorAll(".egg-collapsible, .egg-experimental").forEach((details) => { details.open = interfacePreferences.expandSections; }); @@ -482,10 +483,6 @@ function renderControl(): void { selectAuthorizedDevice, openInterfaceSettings, closeInterfaceSettings, - setInterfaceDensity: (value) => { - interfacePreferences.density = value as InterfaceDensity; - saveInterfacePreferences(); - }, setInterfaceTheme: (value) => { interfacePreferences.theme = value as InterfaceTheme; saveInterfacePreferences(); @@ -779,8 +776,6 @@ async function populatePreviewLauncher(): Promise { function populateInterfaceSettings(): void { void populatePreviewLauncher(); - setControlValue("#interface-density", interfacePreferences.density); - setControlValue("#interface-theme", interfacePreferences.theme); const reducedMotion = document.querySelector("#interface-reduced-motion"); const expandSections = document.querySelector("#interface-expand-sections"); const instantFlash = document.querySelector("#interface-instant-flash"); diff --git a/src/interface-preferences.test.ts b/src/interface-preferences.test.ts index fe2d3537..c70c6139 100644 --- a/src/interface-preferences.test.ts +++ b/src/interface-preferences.test.ts @@ -22,7 +22,6 @@ class MemoryStorage implements Storage { test("interface preferences restore only supported values", () => { const storage = new MemoryStorage(); saveInterfacePreferences(storage, { - density: "Comfortable", theme: "Violet", reducedMotion: true, expandSections: true, @@ -31,7 +30,6 @@ test("interface preferences restore only supported values", () => { }); assert.deepEqual(loadInterfacePreferences(storage), { - density: "Comfortable", theme: "Violet", reducedMotion: true, expandSections: true, diff --git a/src/interface-preferences.ts b/src/interface-preferences.ts index cda71560..c072fbc4 100644 --- a/src/interface-preferences.ts +++ b/src/interface-preferences.ts @@ -1,4 +1,3 @@ -export type InterfaceDensity = "Compact" | "Comfortable"; export type InterfaceTheme = | "Emerald" | "Violet" @@ -11,7 +10,6 @@ export type InterfaceTheme = | "Catppuccin Frappé"; export interface InterfacePreferences { - density: InterfaceDensity; theme: InterfaceTheme; reducedMotion: boolean; expandSections: boolean; @@ -33,7 +31,6 @@ const THEMES: readonly InterfaceTheme[] = [ ]; export const DEFAULT_INTERFACE_PREFERENCES: InterfacePreferences = { - density: "Compact", theme: "Mono", reducedMotion: false, expandSections: false, @@ -45,7 +42,6 @@ export function loadInterfacePreferences(storage: Storage): InterfacePreferences try { const saved = JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as Partial; return { - density: saved.density === "Comfortable" ? "Comfortable" : "Compact", theme: THEMES.includes(saved.theme as InterfaceTheme) ? saved.theme as InterfaceTheme : "Mono", reducedMotion: saved.reducedMotion === true, expandSections: saved.expandSections === true,