diff --git a/.github/labeler.yml b/.github/labeler.yml index 1914176c..c5e25ddc 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,12 +1,12 @@ "area: ui": - changed-files: - - any-glob-to-any-file: ['src/app/**', 'src/**/*.css', 'index.html'] + - any-glob-to-any-file: ['src/app/**', 'src/features/**/*.tsx', 'src/shared/**', 'src/styles/**', 'index.html'] "area: devices": - changed-files: - - any-glob-to-any-file: ['src/device/**', 'src/*hid*', 'src/*device*', 'src/logitech-*'] + - any-glob-to-any-file: ['src/device/**', 'src/features/devices/**', 'src/features/buttons/**', 'src/features/bridge/**'] "area: translations": - changed-files: - - any-glob-to-any-file: ['src/i18n*.ts'] + - any-glob-to-any-file: ['src/i18n/**'] "area: artwork": - changed-files: - any-glob-to-any-file: ['public/devices/**/*.{png,jpg,jpeg,webp,svg,avif}'] diff --git a/AGENTS.md b/AGENTS.md index a38a6be5..d16f18b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,38 +4,90 @@ Guidance for human contributors and AI agents working in this repository. ## Design -Read [`DESIGN.md`](./DESIGN.md) before touching any UI. The project follows -Material Design 3 (M3): colored filled icon glyphs, floating rounded surfaces, -elevation instead of borders, and theme-token-only colors. All the specific -conventions — sidebar layout, device cards, artwork handling, and the icon -accent palette — are defined there. +Read [`DESIGN.md`](./DESIGN.md) before touching any UI. It is the source of truth for how +OpenMouse looks: a calm, dark, instrument-like control panel where the device is the hero and +settings read as numbers. -Key rules that must never be broken: +Rules that must never be broken: -- Use design tokens from `src/control.css`; never hardcode colors in TSX. +- Colours come from the tokens in `src/styles/tokens.css`. No hex, `rgb()` literals or theme-name + checks in TSX or area CSS; alpha mixes use the `-rgb` triples (`rgb(var(--accent-rgb) / 0.12)`). +- Every theme (12 of them, Emerald default) is a full token set. A change must look right in + Emerald, Light and NieR, and mirror correctly in Arabic (RTL). +- Sentence case everywhere. No uppercase micro labels, no category overlines, no em dashes in copy. +- Real data only: every value comes from the device or the browser; anything unknown is left out + or shown as "–". Never ship placeholder numbers. - Device artwork is always `object-fit: contain`, never cropped. -- Connected-device cards are square (1:1), not 9:16. -- Never invert or work around a styling bug with `overflow: hidden`; pin the - element box instead (see `DESIGN.md` "Commandments"). +- Never hide a layout bug with `overflow: hidden`; pin the element box instead. +- Never remove a setting, control, link or string as a side effect of a UI change. Restyle, don't + delete. If something has to go, say so in the PR. + +## Strings and translations + +- Every user-facing string is a key in `src/i18n/index.ts` (English, the source of truth) and is + rendered with `t(locale, key)` or `tp(locale, key, vars)`. +- `src/i18n/tr-coverage.test.ts` fails on static English text in JSX under `src/app`, + `src/features` and `src/shared`. Units and brand names go through code: `{"DPI"}`. +- Add the key to every file in `src/i18n/locales/` in the same change. `src/i18n/i18n.test.ts` + checks that every locale has every key. +- Locales load lazily; switch through `ensureLocale()` so a switch never flashes English. ## Commands ```bash -npm run dev # start dev server +npm run dev # dev server npm run build # tsc --noEmit && vite build -npm test # node --test -npx tsc --noEmit # typecheck only -npm run check # full local check (dev readiness) +npm test # node --test (src, test, build) +npm run size # bundle budget, after a build +npm run check # build + tests +node scripts/sync-supported-devices.mjs # refresh the supported-devices mirror ``` -Run `npm run build` (or `npx tsc --noEmit`) after any change before finishing. +Run `npm run build`, `npm test` and `npm run size` before finishing; CI runs all three plus +`npm audit`. If the size check fails, find what grew; raise `BUDGET_BYTES` in +`build/check-bundle-size.ts` only with a note that says what was added and the measured size. + +Previews: `?preview=list` opens the device shelf with fixtures, `?preview=` opens one +device (keys in `src/device/preview/preview-modes.ts`, e.g. `egg-op1-v2`, `steelseries-gen2`). +The app needs a window of at least 900 × 600. + +## Repo layout + +Organized by feature; tests sit next to the code they cover. + +| Path | What lives there | +| --- | --- | +| `src/main.tsx` | Entry: browser guards, service worker, mounts `App` | +| `src/app/` | Shell: `App.tsx`, `AppSidebar.tsx`, pending bar, toasts, help panel | +| `src/features/devices/` | Devices home, device page (`OverviewPage.tsx`, `DeviceHero.tsx`), setting `cards/` | +| `src/features/buttons/` | Button mapping, setup codes, Logitech button commands | +| `src/features/profiles/` | Onboard profiles and profile sharing | +| `src/features/superstrike/` | Superstrike hit test and presets | +| `src/features/bridge/` | OpenMouse Bridge client, status store, sidebar status card, update checks | +| `src/features/games/` | Games page and per-game profiles (profiles need Bridge) | +| `src/features/hardware-test/` | Hardware test page, report, supported-devices mirror and cross-check | +| `src/features/capture/` | Report capture dialog and export formats | +| `src/features/news/` | News banner and "What's new" | +| `src/features/settings/` | Interface settings page and stored preferences | +| `src/device/` | Device controller (`controller.ts`), snapshot types, per-vendor glue, pending changes, `preview/` fixtures | +| `src/shared/` | UI primitives (`ui.tsx`: `SwitchRow`, `Segmented`, `OptionMenu`, `StepperSlider`, `RateSlider`, `BatteryIcon`), `CardSlot`, icons, colour picker, formatting helpers | +| `src/i18n/` | `index.ts` (English + API), `locales/.ts` | +| `src/platform/` | Service worker, offline banner, Trusted Types, XSS guard, browser support, sounds | +| `src/styles/` | `tokens.css`, base, controls, shell, page styles, `instrument.css`, `areas/` per tab | +| `src/types/` | Global declarations (Vite, WebHID) | +| `test/` | Tests for `functions/`, security headers and the PWA build | +| `functions/` | Cloudflare Pages Functions (`/api/*`, security middleware) | +| `build/` | Vite plugins and the bundle-size check | + +Where new code goes: a new screen or capability gets its own folder under `src/features/`; a new +setting card for a device goes in `src/features/devices/cards/`; anything two features share goes +in `src/shared/`. Don't add files to the top of `src/`. + +## Devices and protocols -## Repo orientation +Wire protocols and HID drivers live in the external `@openmouse/protocol` and +`@openmouse/keyboard-protocol` packages; don't add driver code here. This repo maps a driver's +capabilities onto the UI (`src/device/controller.ts`, `src/device/traits.ts`) and renders them. -- `src/control.css` — all theme tokens and UI styles (single stylesheet). -- `src/app/` — UI: `AppSidebar.tsx` (nav rail), `InterfaceSettings.tsx` - (settings page), `OverviewPage.tsx` (connect + device list pages). -- `src/device/` — controller, types (`ControlSnapshot`), and protocol glue. -- `src/i18n.ts` — all user-facing strings, organized by key. -- Actual wire protocol/drivers live in the external `@openmouse/protocol` - library; do not expect to add HID driver code here. \ No newline at end of file +`src/features/hardware-test/supported-devices-data.ts` is generated from the website's table +(`openmouse-landing-page/src/data/supported-mice.ts`). Don't edit it by hand; run the sync script. diff --git a/DESIGN.md b/DESIGN.md index 923b4fdb..f52cf2e9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,97 +1,127 @@ -# OpenMouse — Design Style Guide - -This project follows **Material Design 3 (M3)** conventions. Read this guide -before touching any UI. It is the single source of truth for visual style. - -All design tokens live in `src/control.css` (CSS custom properties on the -`.control-shell` / `:root` theme blocks). **Never hardcode colors in -components** — use the theme tokens. Art/SVG/glyph fills may only use -`currentColor` or `color-mix()` against tokens; never hardcode hue values -except for the per-icon accent palette defined below. - -## Core principles - -- **Filled, colored glyphs**: navigation and inline icons are Material - Symbol-style filled paths (`fill="currentColor"`) rendered at 20–24px. - Thin stroke-only icons are out of style for navigation. -- **Floating rounded surfaces**: elevated panels are inset from the page, - rounded (`border-radius: 16px`), with layered `box-shadow` depth — not flat, - border-only rectangles. -- **Elevation over borders**: distinguish surfaces with elevation (gradients, - shadows, `--surface-*` tokens) before reaching for borders. When a divider - is needed it must be clearly visible (use `--line-strong`, never - `--border-rule`/`--line` — those are too faint). -- **State layers**: hovers are subtle overlays (`--hover`, `--surface-hover`) - — a change of background/color, never a sudden border or outline. -- **Theme-awareness**: every style must hold up in **all themes** (dark, - light, ocean, Nier, catppuccin). Check contrast between adjacent surfaces in - both dark and light before committing. -- **Bold labels**: navigation labels use high weight (`font-weight: 600–700`) - and high-contrast text tokens so they read as interactive targets. - -## Layout - -### Sidebar (`.app-sidebar`) - -The left rail is the app's navigation drawer: - -- Floating rounded panel: inset from the shell edge (`margin: 0.55rem 0 - 0.55rem 0.55rem`), `border-radius: 16px`, full outline in `--edge`, - gradient background `linear-gradient(180deg, var(--surface-strong), - var(--surface-dialog))`, and layered drop shadows for lift. -- Nav items (`.app-sidebar-nav-item`): 42px tall, rounded 8px, flex row of - `[icon] [label] [>]`. - - `>` chevron (`.app-sidebar-nav-arrow`) sits at the right via - `margin-left: auto`; it nudges right on hover. - - Adjacent items are separated by a Material divider - (`border-top: 1px solid var(--line-strong)`). -- Per-item icon accent palette (set via inline `style={{ color }}` on the - icon svg — the label itself keeps theme text tokens): - - Home / Connect: green `#5dde89` - - Dashboard (mouse config): blue `#67d8ff` — **use the mouse glyph** - (`M13 1.07V9h7c0-4.08-3.05-7.44-7-7.93zM4 15c0 4.42 3.58 8 8 8s8-3.58 - 8-8v-4H4v4zm7-13.93C7.05 1.56 4 4.92 4 9h7V1.07z`) - - Docs: amber `#e8b267` - - Supported: violet `#a78bfa` - - What's New: gold `#fbbf24` - - Help & Feedback: blurple `#5865f2` — **brand mark** (`DiscordIcon`), sized - by height (`16px`) rather than the square 21px nav box, since the glyph is - wider than it is tall - - Settings: slate `#94a3b8` -- Section order: top nav = Home, Dashboard, Docs. Bottom nav = What's New, - Help & Feedback, Settings. Supported devices lives **inside the Settings - page**, not the sidebar. -- **Help & Feedback is the one featured nav row** - (`.app-sidebar-nav-item-featured`): taller (`min-height: 52px`) with a second - line (`.app-sidebar-nav-sub`) under the label, because the community Discord - is where support and feedback now live. It links straight out to - `DISCORD_URL` rather than opening an in-app dialog, and collapses back to a - plain 42px icon row with the rest when the sidebar is collapsed. - -### Device cards - -- Connected-device cards and the add-device cards are **square (1:1)**, equal - size, on a centered grid. -- Card background is `--surface-strong` so cards separate clearly from the - page background in every theme. -- No border at rest — border + subtle shadow appear only on hover. -- Device artwork: - - Rendered through `.device-tile-image` as `position: absolute; inset: 0; - object-fit: contain;` (+ `transform: scale(1.1)`), never cropped. - - The card clips with `overflow: hidden`. -- The mouse add card and keyboard add card are identical size and shape. The - keyboard add card in the device list must not carry the flex `max-width` - that the empty-state add grid uses (`max-width: none` override). -- State: per-card "Connecting…" text from local state, not global labels. -- Cog/gear buttons are borderless icons (no square outline), `overflow: - visible` on the icon svg so strokes are never clipped. - -## Commandments - -1. Never crop a product rendering; always `object-fit: contain`. -2. Never reintroduce the old 9:16 card aspect ratio for the connected list. -3. Never use `overflow: hidden` to "fix" artwork — pin the image box with - absolute positioning instead. -4. Never use `--line` / `--border-rule` for dividers you want visible. -5. Never introduce a new UI concept (page, card, dialog) without reading this - file and matching the M3 conventions above. \ No newline at end of file +# OpenMouse design + +The single source of truth for how OpenMouse looks. Read it before touching any UI. +Values live in code: `src/styles/tokens.css`. When this file and the code disagree, the code +wins; fix this file. + +Reading: a hardware control panel for people who tune their mouse by the number. Calm, dark, +precise, instrument-like. Dial ENERGY 1 / RHYTHM 2 / MOTION 1. + +## Principles + +- **The device is the hero.** Product renders sit on a lit stage (a dark well with one soft + accent light behind the mouse). Everything else is quiet chrome. +- **Numbers first.** Settings read as figures: label above, value, unit after, meta under. All + numbers are tabular. +- **Colour means something.** The accent (from the user's theme) marks the active place, the + primary action, focus, slider fills and the active stage. `--ok` / `--bad` / `--warn` mark real + states only. Everything else is ink. +- **Real data only.** Every value comes from the device or this browser. A value the device does + not report is left out or shown as "–". Empty states say why and what fills them. +- **One idea per block.** Short titles, sentence case, no category overlines, no uppercase micro + labels, no decorative dots, stripes or glows. + +## Structure + +| Layer | Recipe | +|---|---| +| Ground | `--ground`: page ink plus one soft accent light from the top right. Painted once on the shell. | +| Chrome | Sidebar on `--ink-0` with a hairline edge. | +| Panel | `.om-panel` (or the legacy `.setting-card`): `--sheen` over `--surface-1`, `--lit` edge, `--r-md`. Featured panel: `.om-panel.is-lit` (one or two per page). | +| Row | Inside a panel: hairline `--line-1` separators. Don't nest panels. | +| Well | Inset areas (segmented controls, inputs, render stages): `--well` + `--inset-shadow`. | +| Raised | Buttons, the selected segment key, switch thumb: `--btn` / `--key`. | +| Floating | Menus, dialogs, toasts, the pending bar, help: opaque `--solid-2` + `--shadow-float`. | + +## App layout + +- **Sidebar** (`AppSidebar.tsx`, 248px, collapsible to a 68px icon rail, remembered): + brand + build; Devices, Hardware test, Games (without Bridge it lists the supported games under a download-Bridge panel); "Your devices" switcher + (thumbnail, name, battery icon + percent or "Not connected"); footer: Docs ↗, What's new, + Help & feedback (Discord), Settings; Bridge status line, which opens a small card with Bridge's + status, update notice and download link (`features/bridge/BridgeStatus.tsx`). +- **Main column**: news strip on top, then the scrolling page (`.om-content` > `.om-page`, max + 1160px). The pending-changes bar floats at the bottom of the main column. A help button floats + bottom right and opens a small panel of common questions with instant answers, plus links to + the site FAQ and Discord (`app/HelpPanel.tsx`). +- **Devices home** (`features/devices/DevicesHome.tsx`): a swipeable shelf of device blocks, each + one surface (render on a stage, name, connection badge, live stats with icons, Configure/Open, + and a ⋯ menu to remove the device) ending in an Add device block with the supported mice + crossfading. Drag, swipe, arrow buttons, dots and arrow keys all move it. The shelf sits in the + middle of the page and centres when it doesn't fill the width. No devices: the same shelf with + only the Add device block, larger, in the middle of the page; its button becomes the primary + action. +- **Device page** (`features/devices/DeviceHero.tsx`): hero (render stage, breadcrumb, name, connection line, stats + with icons) with the section tabs bolted under it as one unit. Overview: Current setup figures + + DPI stage chips, Battery over time chart (recorded samples), Firmware. +- **Setting cards** flow in a masonry column layout (`CardSlot`, `styles/instrument.css`) so there + are no holes; every card heading carries a glyph, and feature switches are tiles + (`SwitchRow` in `shared/ui.tsx`). +- **Settings** (`features/settings/InterfaceSettings.tsx`): grouped panels of setting rows (title + description start, control end): Appearance + (theme gallery, each tile painted in its own theme; animations), Language (picker with native + names), Behaviour, Profile key, Driver previews (dev builds), About. + +## Tokens + +Use tokens only; no hex in components or area CSS. Alpha mixes use the `-rgb` triples: +`rgb(var(--accent-rgb) / 0.12)`. + +| Token | Use | +|---|---| +| `--ink-0`, `--ink-1`, `--ground` | Chrome, page | +| `--surface-1..4` | Panel, hover/selected, pressed/strong, rare | +| `--solid-1`, `--solid-2` | Opaque surfaces (menus, dialogs, sticky heads) | +| `--well`, `--glint` | Inset areas, hover wash | +| `--line-1..3` | Hairlines: divider, input edge, emphasis | +| `--text-1..4` | Values/titles, body, labels/meta, disabled/placeholder only | +| `--accent`, `--accent-rgb`, `--on-accent`, `--accent-soft`, `--accent-line` | Active place, primary action, focus, fills | +| `--ok`, `--bad`, `--warn`, `--info` | Real states only | +| `--stage-light` | The light behind a device render | + +Themes: all 12 (Emerald default, Matt, Violet, Ice, Ember, Mono, Miku, Catppuccin Mocha / +Macchiato / Frappé, NieR: Automata, Light) are full token sets in `tokens.css`, selected by +`data-interface-theme` on the shell (and mirrored on ``). Derived tokens are declared on every +themed element, so any element carrying `data-interface-theme` paints itself in that theme. +No component may fork on a theme name. + +## Type + +- DM Sans for everything; JetBrains Mono only for raw identifiers, hex and HID logs. +- Scale: page title 22, panel title 15, body 13, meta 12, small 11. Figures 28–30 at 650. +- Sentence case everywhere. No uppercase tracked labels. Weights 400–650. + +## Shape and space + +- Radii: 3 (chips), 6 (controls, rows), 10 (panels, wells), 14 (hero, device blocks, dialogs). +- Spacing on a 4px grid. Panel padding 16–18px; gaps between panels 16px; rows ≥ 40px. +- Device renders: always `object-fit: contain`, absolutely positioned inside their stage, never + cropped. + +## Controls + +`.om-btn` (default raised), `.is-primary` (one per view), `.is-ghost`, `.is-danger`, `.is-sm`, +`.is-lg`, `.is-icon`; `.om-icon-btn`; `.om-badge` (`.is-ok/.is-bad/.is-warn/.is-accent`); +`.segmented` and `.rate-slider-buttons` (well + raised key); `SwitchButton`; `OptionMenu`; +`StepperSlider`; range inputs with an accent fill. Focus is the accent gap ring (`--focus`), never +removed. + +## Icons + +lucide-react at stroke 1.8, `currentColor`, `--text-3` at rest, accent when active. Icons sit next +to text that says the same thing (battery icon + percent, connection icon + "Wireless"). The battery +is the custom `BatteryIcon` (fill tracks the level; low and charging change its colour). + +## Motion + +120–260ms washes and fades. The device page body fades in; panels and bars rise 8px once. No +loops except the add-device crossfade, which stops with reduced motion. The Animations preference +and `prefers-reduced-motion` both stop motion. + +## Checklist before shipping a page + +- [ ] Tokens only; no new radii, shadows or hex. +- [ ] Sentence case, no overlines, no em dashes in copy. +- [ ] One primary button per view. +- [ ] Every number tabular; every value real or "–". +- [ ] Works in Emerald, Light and NieR; RTL (Arabic) mirrors correctly. +- [ ] Keyboard: every control reachable, visible focus, Escape closes dialogs. diff --git a/README.md b/README.md index b7412982..052910fb 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,29 @@ device inventory changes are written to the browser console with the downloadable diagnostics from a connected device's **Advanced** panel; HID report payloads are not written to the console. -The control panel is organized by responsibility: `control.ts` coordinates the -application, while the template, events, DOM helpers, persisted preferences, -battery history, device selection, and rendering live in focused modules under -`src/`. +The control panel is organized by feature. Tests sit next to the code they cover. + +| Folder | What lives there | +| --- | --- | +| `src/main.tsx` | Entry point: guards, service worker, mounts the app | +| `src/app/` | The shell: `App`, sidebar, pending-changes bar, toasts, AI overlay | +| `src/features/devices/` | Device home, device page, hero, stats, and the setting `cards/` | +| `src/features/buttons/` | Button mapping, setup codes, Logitech button commands | +| `src/features/profiles/` | Onboard profiles and profile sharing | +| `src/features/superstrike/` | Superstrike hit test and presets | +| `src/features/bridge/` | OpenMouse Bridge client, status line, update checks | +| `src/features/games/` | Games page and per-game profiles (Bridge only) | +| `src/features/hardware-test/` | Hardware test page, report builder, supported-devices cross-check | +| `src/features/capture/` | Report capture dialog and export formats | +| `src/features/news/` | News banner and "What's new" | +| `src/features/settings/` | Interface settings and stored preferences | +| `src/device/` | Device controller, per-vendor glue, pending changes, preview fixtures | +| `src/shared/` | UI primitives, icons, colour picker, formatting helpers | +| `src/i18n/` | Strings: `index.ts` is English and the API, `locales/` has one file per language | +| `src/platform/` | Service worker, offline banner, Trusted Types, browser support, sounds | +| `src/styles/` | Design tokens, base styles, and one stylesheet per area | +| `src/types/` | Global type declarations (Vite, WebHID) | +| `test/` | Tests for the Cloudflare functions, security headers and the PWA build | Packet codecs and WebHID drivers live in the standalone [`@openmouse/protocol`](https://github.com/OpenMouse-Project/mouse-protocol) diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 135241ee..ff234cf3 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -2,241 +2,8 @@ import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; const BUDGET_BYTES: Record = { - // Raised from 103 kB for the interface themes: NieR: Automata and Liquid - // Glass each ship their own token block, and the liquid-glass material - // layer (SVG displacement filters plus their component rules) added the - // largest share. Liquid Glass was removed in the theme sync (main CSS is - // ~104 kB again). The measured bundle is 153.7 kB; 175 kB adds headroom for - // the Developer Hall of Fame page (~15 kB of animated card and hero - // styles that load only on /donate.html). Raised to 180 kB in the - // same pass as the 175 kB target, then to 195 kB for the Minecraft Hall of - // Fame overhaul: the blocky token block (plank textures, bevels, item-frame - // avatars), the animated day/night scene (sun/moon/star/cloud/bonfire - // keyframes) and the credits-style quote widget push the measured CSS - // aggregate to 183.7 kB. - // Raised to 210 kB after the protocol 0.26.0 device batch left the measured - // CSS aggregate at 194.2 kB — 843 bytes under the old 195 kB — so the next - // card or theme change would have tripped the check for no reviewable reason. - // Raised to 225 kB for the Logitech Superstrike Buttons tab: the button map - // card (callouts, command list, scrollbar), the HITS presets/test/share dialogs - // and the Bunny Hop card add ~15 kB of scoped styles; the measured CSS is - // 210.6 kB, which was 631 bytes over the old 210 kB budget. - ".css": 225_000, - // Raised from 510 kB for Bridge discovery, profile editing, automatic - // reconnection, and recent device support, which have since grown further - // with the supported-device page and MX Master remap controls. Preview - // fixtures retain their separate allowance below; the measured aggregate - // is 573.4 kB with them, plus the ~11 kB Hall of Fame chunk. Raised again - // from 590 kB for the Razer button-mapping card and its codec: the measured - // aggregate is 588.2 kB, which left under 2 kB of headroom. Raised again to - // 610 kB for the Pulsar XS-1 feature-report driver and 4K receiver support - // (mouse-protocol 3c3a445): the X3 family codec plus the 4K DPI/polling work - // adds ~1.3 kB to the measured aggregate. Raised to 632 kB for the Attack - // Shark GearHub (0x25a7) protocol routed to 0x1d57 VID devices (+1.6 kB). - // Raised to 700 kB for four new drivers landing together: Keychron M6, - // Keychron Nape Pro (layer/keymap/orientation controls), Glorious Model O - // 2/I 2 lighting, and SteelSeries Rival 3 Gen 1. Measured aggregate is - // 689.0 kB, which leaves about 11 kB of headroom. Raised to 730 kB for the - // device artwork pass: ~20 new product models mapped to transparent top-view - // renders in device-images.ts (PID keys plus name fallbacks) add ~22 kB of - // mapping code to the measured aggregate (720.5 kB). Raised to 765 kB for - // the SteelSeries/device-support and Cloudflare R2 artwork-serve work merged - // on dev: those land with the measured aggregate already at ~750 kB. The - // donate page rebuild (Hall of Fame -> Support) does not drive this; its - // rebuilt donate chunk is lighter than the old Minecraft-themed hof chunk it - // replaced. 765 kB leaves ~15 kB of headroom over the measured aggregate. - // Raised to 790 kB for the MCHOSE A7 V2 mouse and MagDock driver support: - // the measured aggregate is 779.1 kB, leaving ~11 kB of headroom. - // Raised to 800 kB for the ATK ZERO driver (AtkCards.tsx, device/atk.ts) - // and the mouse-reported lift-off range plumbing: the measured aggregate is - // 790.6 kB, leaving ~9 kB of headroom. - // Raised to 895 kB for the Portuguese (pt) localization: the full - // en+pt UI dictionary adds ~85 kB of strings to the measured aggregate - // (883.2 kB, on top of the 800 kB budget's own ~790.8 kB baseline). The pt - // table ships as its own lazy chunk (i18n-pt-*.js, loaded only when a - // non-English locale is selected), so the initial load is unaffected — the - // aggregate counts it because the check sums every emitted chunk. - // Raised to 1,065 kB for Spanish, French, German, and Simplified Chinese: - // each ships as its own lazy chunk (i18n-es/fr/de/zh-*.js) at ~40-55 kB, - // loaded only when that locale is selected — none of them touch the - // initial (English) bundle. Measured aggregate is 1,061.8 kB. - // Raised to 1,165 kB for Japanese and Korean, same lazy-chunk pattern. - // Measured aggregate is 1,160.9 kB. - // Raised to 1,185 kB for Attack Shark R2 support (GearHub-V5 driver, - // traits.ts entry, device artwork name-fallback): the measured aggregate - // is 1,171.4 kB, leaving ~14 kB of headroom. - // Raised to 1,250 kB for the Russian (ru) interface locale: the new - // translation strings push the measured aggregate to 1,232.4 kB. - // Raised to 1,290 kB for the control-app support/UI work that landed - // together: the OpenMouse AI chat overlay (AiOverlay.tsx with its topic - // flow and Discord fallback), the Discord-embed FeedbackDialog, the - // What's New Desktop-app dialog, the desktop-style AppSidebar / - // OverviewPage shell, and the ai.* + wn.* keys added across all nine - // locale tables. Those add ~21 kB of strings and UI to the measured - // aggregate (1,271.9 kB), leaving ~18 kB of headroom. - // Raised to 1,300 kB for swapping hand-rolled icons for lucide-react, - // tree-shaken per icon: the profile-row icons in icons.tsx - // (enable/disable, link/unlink, rename, running/activate, refresh, trash), - // the AppSidebar nav icons (home, mouse config, docs, what's new, - // feedback, settings, chevron), the AiOverlay launcher/close/header - // icons, and the OverviewPage icons (workspace tabs, device-tile stats, - // back/share/plus controls, and the add-device mouse/keyboard glyphs, - // which replace their old hand-drawn illustrations). The measured - // aggregate is 1,290.4 kB, leaving ~10 kB of headroom. - // Raised to 1,310 kB for the crowd-sourced device artwork system: - // ArtworkUploadDialog.tsx (drag-drop upload UI with verification - // states), artwork-verification.ts (canvas-based heuristic analysis), - // artwork-storage.ts (R2 API fetch + localStorage cache), Cloudflare - // Functions for R2 list/upload endpoints, and artwork.* i18n keys - // across all nine locale tables. Measured aggregate is ~1,305.5 kB. - // That system was fully removed later (unmoderated anonymous uploads - // were abused to overwrite device art); the budget stays, and the - // aggregate only shrank. - // Raised to 1,345 kB for the in-app Mouse Test page: MouseTestPage.tsx - // (live polling-rate sampler, rolling chart canvas, DPI/battery device - // card, button tester), its Activity nav icon, and the test.* i18n keys - // across all nine locale tables. Measured aggregate is ~1,329.5 kB. - // Raised to 1,370 kB for the HyperX and Incott drivers: both register in - // registry.ts/vendors.ts, and the Incott one brings a full vendor codec - // plus applyPulsarValue/traits/device-images wiring. Measured aggregate - // is 1,351.0 kB, leaving ~19 kB of headroom. - // Raised to 1,410 kB for the @openmouse/protocol lockfile bump that picks - // up the VXE R1 Pro Max driver on the protocol side: bridge-hid.ts pulls - // in every registered vendor's HID filters via SUPPORTED_HID_FILTERS, so a - // new protocol-side driver grows this bundle even before any app code - // references it directly. Measured aggregate is 1,386.8 kB, leaving ~23 kB - // of headroom for the VXE app-side integration PR. - // Raised to 1,465 kB for the Vietnamese (vi) interface locale landing on - // top of that: same lazy-chunk pattern as the other non-English tables - // (i18n-vi-*.js, loaded only when selected), adding ~47 kB. - // Raised to 1,500 kB for the MCHOSE A5 Pro Max Gen 1 driver: it registers - // in controller.ts/traits.ts and pulls in its own protocol-side codec via - // bridge-hid's SUPPORTED_HID_FILTERS, same growth pattern as prior drivers. - // Measured aggregate is 1,482.3 kB, leaving ~18 kB of headroom. - // Raised to 1,530 kB for Incott's receiver-LED and rapid-fire card: a new - // AdvancedCards section plus its availability/control wiring. Measured - // aggregate is 1,508.0 kB, leaving ~22 kB of headroom. - // Raised to 1,570 kB for the @openmouse/protocol lockfile bump to ec90f61: - // attackshark X11 polling/DPI/battery, MCHOSE A7 V3 settings writes + - // product-string naming, and Windows read-restriction fixes all route - // through bridge-hid's SUPPORTED_HID_FILTERS, so this grows the bundle - // even before the A7 V3 traits work (PR #267) references the new codec - // directly. Measured aggregate is 1,545.7 kB, leaving ~24 kB of headroom. - // Raised to 1,600 kB for the strict artwork-submission flow: the expanded - // artreq.* dictionary (new rejection reasons, background/photo guidance, the - // failure lockout and Discord-ticket strings) ships in all ten locale - // tables, and ArtworkRequestDialog.tsx gained the per-IP failure-lockout - // logic. Measured aggregate is 1,578.2 kB, leaving ~22 kB of headroom. - // Raised to 1,650 kB for the Bridge game-profile feature landing across - // three commits: BridgeCard.tsx (Home connection/games card), - // GameProfilePanel.tsx (per-game target-device/DPI/auto-apply editor), - // games-catalog.ts (CDN games.json fetch for cover art), bridge-status- - // store.ts (shared status poll), and useBridgeProfileApplier.ts (in-tab - // fallback apply + prior-settings restore for brands Bridge has no native - // driver for), plus the bridge.* i18n keys across all ten locale tables. - // Measured aggregate is 1,614.2 kB, leaving ~36 kB of headroom. - // Raised to 1,700 kB for the Games-page request flow shipping on top - // ("Let people request a game or app from the Games page"): the - // request-form dialog, game availability filtering, and submit wiring - // push the measured aggregate to 1,673.2 kB, already past the 1,650 kB - // budget. The read-only DPI stage editor (DpiCard disabled stage - // controls, DeviceCapabilities stage-write flags, and the RAZER preview - // fixture stage table) then adds ~1.7 kB more. Measured aggregate is - // 1,674.9 kB, leaving ~25 kB of headroom. - // Raised to 1,780 kB for the in-app Hardware Test certification suite: - // HardwareTestPage.tsx (verification runner with live polling-rate - // sampling, terminal, device card, and the share flow), the report model - // (automatic checks, verdict, Discord embed), hardware-brand-checks.ts - // (brand-depth checks), the supported-devices crosscheck, and the - // 238-row mirrored supported-devices table (src/supported-devices-data.ts, - // generated from the landing page's supported-mice.ts via - // scripts/sync-supported-devices.mjs), plus the hw.* and - // nav.hardwareTest strings in all ten locale tables. Measured aggregate - // is 1,748.7 kB, leaving ~31 kB of headroom. - // Raised to 1,845 kB for the Arabic (ar) interface locale: i18n-ar.ts is a - // full native translation of all ~1,050 keys and ships as its own lazy - // chunk (i18n-ar-*.js, ~69 kB, loaded only when Arabic is selected) — the - // initial (English) bundle is untouched. This is the same lazy-chunk pattern - // as the pt/es/fr/de/zh/ja/ko/ru/vi locales above, and the aggregate counts - // it only because the check sums every emitted chunk. Measured aggregate is - // 1,817.7 kB, leaving ~27 kB of headroom. - // The in-app Mouse Test page (MouseTestPage.tsx + MouseTestMouse.tsx, the - // test.* keys, and its chart/button-tester CSS) and the strict artwork- - // submission flow (ArtworkRequestDialog.tsx, /api/artwork + images.js, the - // artreq.* dictionary) were both removed; artwork requests now open the - // documented GitHub issue form instead. The budget stays — like the earlier - // crowd-sourced removal, the aggregate only shrank. - // Raised to 1,880 kB for a batch of feature PRs merged together: the unified - // mouse-option widgets (OptionMenu, StepperSlider, and the M3 controls in - // #288), the EGG 4K v2 sensor-glass-mode / polling-divider card (#352), the - // WLmouse Beast X 4K driver wiring (#287, which also pulls its protocol codec - // in through SUPPORTED_HID_FILTERS), and the Razer Bluetooth-mode notice - // (#357). Measured aggregate is 1,850.7 kB, leaving ~29 kB of headroom. - // Raised to 2,085 kB for protocol 0.22.0 and the four device PRs that land - // with it: the Noir Gear M2-NEX integration (#402, local profile slots and - // the macro editor), the Keychron G3 Air / Launcher 8k-1k panel wiring - // (#408, #426), and the Rapoo VT9 Pro client (#422). Their protocol codecs - // (Keychron Launcher mouse catalog, Rapoo register codec, K-snake macro - // store) are pulled in through SUPPORTED_HID_FILTERS and the controllers, - // and the shared UI gained the macro-card and profile-overview CSS. Measured - // aggregate is 1,995.2 kB, leaving ~90 kB of headroom. - // Raised to 2,150 kB for the protocol 0.26.0 device batch: AJAZZ AJ179 PRO - // (#169), Redragon M690 PRO (#163, wired into the panel by #617), Lamzu Paro - // Aurora (#172), plus the X11-on-Bridge (#174), PRO X Wireless format 4 - // (#175) and Logitech onboard-profile (#166/#589) work. Their protocol codecs - // arrive through bridge-hid's SUPPORTED_HID_FILTERS as well as the - // controllers. The measured aggregate is 2,084.2 kB, which left only 801 - // bytes of headroom under the old 2,085 kB. - // Raised to 2.2 MB for the Superstrike Buttons tab (button map, HITS presets, - // speed test, setup codes, bounce check) merged with protocol 0.28.0: the - // measured aggregate is 2,150.4 kB, 350 bytes over the old 2.15 MB budget. - // Raised to 2,350 kB for protocol 0.31.0 and the work that landed on 0.29-0.31: - // the Starlight X (Finalmouse) panel, the Wallhack M-001 scanning, DPI curve - // and macro cards, and the ATK per-model capability cards. The measured - // aggregate is 2,288.4 kB, 88 kB over the old 2.2 MB budget, leaving ~60 kB - // of headroom. - // Raised to 2,450 kB for keyboard protocol 1.0.0 and its control panel: - // Wuque codecs/client wiring, shared workspace cards, per-key trigger UI, - // and keyboard strings in all ten locale chunks. The measured aggregate - // is 2,419.9 kB, leaving ~30 kB of headroom. - // Raised to 2,470 kB for the @openmouse/protocol 0.38.0 bump: the AJAZZ - // NJ07/NJ08 key-map codec, the 49-entry vendor button-action catalog, and - // the marker/default client handling arrive through bridge-hid's - // SUPPORTED_HID_FILTERS. The measured aggregate is 2,454.4 kB, 4.4 kB over - // the old 2,450 kB budget, leaving ~15 kB of headroom. - // Raised to 2,472 kB for the @openmouse/protocol 0.40.0 bump: the G-Wolves - // XVI-new driver (profile-addressed sensor commands plus clicks-only - // remap), 16 more cataloged G-Wolves PIDs and their picker filters, and - // the Bridge empty-enumeration diagnostic. The measured aggregate is - // 2,470.9 kB, 0.9 kB over the old 2,470 kB budget, leaving ~1 kB of - // headroom — the next driver will need a real diet or a bigger bump. - // Raised to 2,474 kB for the @openmouse/protocol 0.40.1 bump: XVI-new - // button-slot fix plus the real DPI stage table and per-stage/active - // setters. The measured aggregate is 2,472.0 kB, under a kilobyte over - // the old 2,472 kB budget. - // Raised to 2,478 kB for the @openmouse/protocol 0.42.1 bump: LunaFury - // onboard, remapping, and power controls, Wallhack debounce, Attack Shark - // X11 SE battery, Superlight 2 Lightspeed polling above 1000 Hz, and the - // DeathAdder V3 polling commit. The measured aggregate is 2,476.0 kB, - // 2.0 kB over the old 2,474 kB budget, leaving ~2.0 kB of headroom. - // Raised to 2,630 kB for Turkish, including legacy driver labels, dynamic - // messages and dialog copy. The two Turkish dictionaries remain lazy - // chunks. The measured aggregate is about 2,620 kB, including render-boundary - // localization; no additional CSS framework or runtime dependency is added. - // Raised to 2,640 kB for the Endgame Gear OP1-8K control panel: the section - // the missing family tag had kept dark adds the sensor-controls, handedness - // and lift-off cards plus two strings across twelve locales, taking the - // measured aggregate from 2,629.5 kB to 2,632.2 kB — over the old budget by - // 2.2 kB, which itself had only 545 bytes of headroom, so any new localized - // string would have tripped it. - // Raised to 2,720 kB for the @openmouse/protocol 0.43.0 bump: eleven - // SteelSeries drivers arrive through the shared registry (the Aerox 3 - // Wireless Gen 2, Rival 3 Gen 2, and Rival 3 Wireless Gen 2 preset families - // plus the Rival 100/110/300/5/600, Sensei 310/Raw/Rival Pro, and Prime Mini - // codecs). The per-stage X/Y column adds ~750 bytes of JS and ~460 bytes of - // CSS of its own. The measured aggregate is 2,715.0 kB, 75.0 kB over the old - // 2,640 kB budget, leaving ~5.0 kB of headroom. - ".js": 2_720_000, + ".css": 165_000, + ".js": 2_800_000, }; const ASSETS = join("dist", "assets"); diff --git a/build/dev-feedback-plugin.ts b/build/dev-feedback-plugin.ts index 8f2e2285..1bd9e18c 100644 --- a/build/dev-feedback-plugin.ts +++ b/build/dev-feedback-plugin.ts @@ -1,30 +1,9 @@ -// Dev-only relay for `/api/feedback`. -// -// The production endpoint is a Cloudflare Pages Function -// (`functions/api/feedback.js`) that relays the POST body to the Discord -// webhook, so the bare vite dev server 404s the exact request the Feedback -// dialog and the hardware-test "Share report" button issue. -// -// This middleware serves the same JSON contract in local development and is -// honest about delivery: -// -// - If `DISCORD_FEEDBACK_WEBHOOK` is present (put it in the gitignored -// `.env.local`, e.g. `DISCORD_FEEDBACK_WEBHOOK=https://discord.com/api/ -// webhooks//`), the payload is forwarded to Discord exactly like -// the production function and `{ ok: true }` is only returned when Discord -// accepts it. -// - Without a configured webhook the request is refused with 503 and an -// actionable message — it never pretends a report was delivered. -// -// The embeds are always printed to the terminal so they can be inspected, with -// or without a webhook. import type { Plugin } from "vite"; import { loadEnv } from "vite"; import type { IncomingMessage, ServerResponse } from "node:http"; export interface FeedbackValidation { valid: boolean; - /** Status to answer with when invalid; 200 on the success path. */ status: number; body: string; embeds: unknown[] | null; @@ -36,12 +15,6 @@ export interface DiscordForwardResult { status: number; } -/** - * Boot-time hint printed by the dev server so the state of the relay is - * visible before anyone clicks Share: configured and delivering, or about to - * refuse every share with 503 until `DISCORD_FEEDBACK_WEBHOOK` exists in - * `.env.local` (and the server is restarted, since the env is read at boot). - */ export function feedbackRelayHint(webhook: string | undefined): string { if (!webhook || !webhook.startsWith("https://discord")) { return "[dev-feedback] relay up, but DISCORD_FEEDBACK_WEBHOOK is not set in .env.local — Share report will answer 503 until it is; add it and restart the dev server"; @@ -51,11 +24,6 @@ export function feedbackRelayHint(webhook: string | undefined): string { const MAX_DEV_BODY_BYTES = 1024 * 1024; -/** - * Validates a single `/api/feedback` request exactly like the production relay - * contract: POST only, a JSON body carrying an `embeds` array. Pure so it can - * be unit-tested; delivery is a separate step (`sendToDiscord`). - */ export function validateFeedbackRequest(method: string | undefined, rawBody: string): FeedbackValidation { if (method !== "POST") { return { valid: false, status: 405, body: JSON.stringify({ message: "Method not allowed." }), embeds: null, rawBody }; @@ -76,11 +44,6 @@ export function validateFeedbackRequest(method: string | undefined, rawBody: str return { valid: true, status: 200, body: JSON.stringify({ ok: true }), embeds, rawBody }; } -/** - * Forwards a validated payload to the Discord webhook, mirroring the - * production relay. Returns 502 for network failures; any other non-2xx - * status is the one Discord answered with. - */ export async function sendToDiscord( webhook: string, rawBody: string, @@ -105,14 +68,11 @@ function endJson(res: ServerResponse, status: number, body: string): void { res.end(body); } -/** Vite plugin that serves a local `/api/feedback` in dev. */ export function devFeedback(): Plugin { return { name: "openmouse:dev-feedback", apply: "serve", configureServer(server) { - // Optional local webhook so dev shares can really land in the channel: - // loadEnv reads .env / .env.local etc. (both are gitignored). const env = loadEnv(server.config.mode, server.config.root, ""); const webhook = env.DISCORD_FEEDBACK_WEBHOOK; server.config.logger.info(feedbackRelayHint(webhook)); diff --git a/build/pwa-vite-plugin.ts b/build/pwa-vite-plugin.ts index c4f3737c..654207b2 100644 --- a/build/pwa-vite-plugin.ts +++ b/build/pwa-vite-plugin.ts @@ -21,7 +21,6 @@ export const PRECACHE_PAGES = ["index.html"]; export const ROOT_PAGE = "index.html"; -/** The root page is served from "/", every other page from its own filename. */ export function pageUrl(file: string): string { return file === ROOT_PAGE ? "/" : `/${file}`; } diff --git a/build/sites-vite-plugin.ts b/build/sites-vite-plugin.ts index 96e14a2d..93c18598 100644 --- a/build/sites-vite-plugin.ts +++ b/build/sites-vite-plugin.ts @@ -2,7 +2,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import type { Plugin } from "vite"; -/** Adds the Cloudflare Worker entry point required by the Sites host. */ export function sites(): Plugin { let root = process.cwd(); let outputDirectory = "dist"; diff --git a/docs/UI_REDESIGN.md b/docs/UI_REDESIGN.md new file mode 100644 index 00000000..e7b9b5c0 --- /dev/null +++ b/docs/UI_REDESIGN.md @@ -0,0 +1,248 @@ +# OpenMouse UI redesign plan + +Status: **implemented on branch `ui-redesign`.** `DESIGN.md` is now the live contract; this file +keeps the original audit and plan for reference. Changes from the plan after owner feedback: + +- Navigation stays a **left sidebar** (collapsible to an icon rail, remembered), not a top bar. + It also carries a "Your devices" switcher with battery icons. +- Devices home is a **swipeable shelf of device blocks** (drag, swipe, arrows, dots, arrow keys), + ending in an Add device block; first run shows a hero with the connect action. +- The device page uses a **hero header with the section tabs bolted under it** instead of a left + device column, so settings get the full width. +- Icons with every live value: connection, battery (custom fill icon), polling, DPI, profile. +- "OpenMouse AI" became **Quick help**, a plain FAQ panel opened from the sidebar. +- Language moved into Appearance; support links moved to Settings > About. +- The dead launch countdown was removed. + +- Mock: `../ui-research/openmouse-redesign/mock.html` (serve the folder, open `mock.html`; bottom-left bar switches screens and themes). Renders in `../ui-research/openmouse-redesign/shots/`. +- Current-app captures for comparison: `../ui-research/openmouse-current/`. +- Reference: `../ui-research/` (invade.lol captures, `invade-ui/` design system clone at `261eba8`). + +Decisions already made by the owner: + +| Question | Answer | +|---|---| +| antislop | During (rules apply while planning and building) | +| Themes | Keep all 12, each rebuilt as a full token override | +| Shell | Top bar first; later changed by the owner to keep the left sidebar | +| Accent | The user's selected theme supplies the accent; no new brand colour | + +Reading this as: a hardware control panel for mouse enthusiasts who tune numbers, in a calm dark instrument style, dial ENERGY 1 / RHYTHM 2 / MOTION 1. + +--- + +## 1. Every screen in OpenMouse today + +The app is a single page with state-based navigation (`App.tsx` `resolvedPage`), no URL routes. Everything below was traced from source. + +### 1.1 Gates (render instead of the app) + +| Screen | Source | Trigger | +|---|---|---| +| Launch countdown | `LaunchCountdown.tsx`, `launch.ts`, `launch.css` | Prod build before 2026-09-04 17:00 UTC. That date has passed, so this is dead code. | +| Unsupported notice, 4 variants | `UnsupportedNotice.tsx`, `platform/browser-support.ts` | Handheld, no WebHID and no Bridge, insecure context, old browser | +| Window too small | `main.tsx` + `useViewportTooSmall.ts` | Viewport under 900 × 600 | +| Offline banner | `offline-banner.ts/.css` | Mounted outside React; PWA offline | + +### 1.2 Pages + +| Page | Source | Notes | +|---|---|---| +| **Home: empty** | `OverviewPage.tsx` `OverviewEmpty` | "Connect your device" hero, animated aurora, one square add card | +| **Home: device list** | `DeviceListView` | Square 1:1 tiles of authorized devices, connected one moved to the middle, add tile last | +| **Device page** (mouse) | `OverviewPage` + `Workspace` | Pill tab bar + Back. Tabs filtered per device by `availableWorkspaceTabs()` | +| ↳ Overview | `OverviewContent` | Giant centred render, status pill (DPI, battery, Hz, profile), Firmware card, optional power cards, M2-NEX profile overview | +| ↳ Performance | `performance` array | DPI, Polling, Sensor, Ninjutso, Egg filter/polling/CPI, Processing, Power mode, Wallhack scanning/curves, ATK sensor; side column with the render | +| ↳ Buttons | same section | Superstrike (HITS), ButtonMap, Magnetic buttons, Lightforce, TMR, Debounce, SPDT, Razer/Egg/MX/ATK/LunaFury mappings, Ksnake scroll | +| ↳ Macro | `macro` array | Ksnake macros, Wallhack macros | +| ↳ Lighting | `lighting` array | LightingCard (zones, ColorPicker), DPI indicator lighting | +| ↳ Profiles | `Profiles.tsx`, `KeychronNapeLayers`, Teevolution card, ATK/onboard/Pulsar Pro cards | Rendered as the last tab in its own nav slot | +| ↳ Advanced | `advanced` array + `Diagnostics` + Logitech details + MX Master cards | Signal, sleep, low power, dongle LED, receivers, Finalmouse, Incott; dev-testing note; Diagnostics with capture | +| ↳ Empty tab | `workspace-tab-empty` | "This device has no X settings" | +| **Device page** (keyboard) | `KeyboardPanel`, `KochiKeyboardPanel`, `KeyboardWorkspaceCards` | Same frame; Kochi only gets Overview | +| **Hardware test** | `HardwareTestPage.tsx` | Render column, stage runner with verdict, device facts column, long footer paragraph | +| **Games** (Bridge only) | `GamesPage.tsx`, `GameProfilePanel.tsx` | Game tile grid; per-game profile reuses `Workspace` with `gameProfile` | +| **Settings** | `InterfaceSettings.tsx` | Profile key, theme (12), language (12), sound, animations, instant flash, advanced editors, experimental, dev previews, support links, reset | +| Docs, Help & Feedback | `AppSidebar.tsx` | External links (docs.openmouse.app, Discord) | + +### 1.3 Overlays + +`WhatsNewDialog`, `CaptureDialog`, `ShareProfileDialog`, `SetupCodeDialog`, `BounceCheckDialog`, `HitsTestDialog`, `SaveHitsPresetDialog`, `DeleteHitsPresetDialog`, `GameRequestDialog`, the dialogs inside `Profiles.tsx`, `ColorPicker`, `OptionMenu`, `ToastHost`, `PendingBar`, `NewsBanner`, `HelpPanel`. + +### 1.4 Not in scope + +- `src/styles/admin.css` has no consumer in `src/` (only `functions/api/admin/unban.js` exists). Leave it. +- `dist/check.html` and `dist/supported.html` are stale build output with no source entry. +- The marketing site and openmouse.app/supported live in other repos. + +### 1.5 Size of the job + +51 card components, ~6,100 lines of CSS across 6 files, 1,326 top-level selectors, 86 `box-shadow`, 44 `font-mono`, 15 `uppercase`, 45 hex literals in TSX (mostly the nav icon palette and stage colours). DOM coupling from logic is minimal: only `#workspace-tab-*` focus and `.battery-fill`. No test asserts class names. That makes a full restyle safe to do without touching `device/controller.ts`. + +--- + +## 2. What we take from the reference, and what we don't + +### Take (ideas) + +| invade.lol idea | OpenMouse use | +|---|---| +| Slim top bar, one content column (~1200px), footer | App shell for every page | +| Profile card on the left with section nav under it | **Device card**: render, name, connection, actions; section nav under it; "Other devices" list below (the "Played with" idea) | +| Ranked/Performance panel: big figures, label above, unit after, meta under | **Current setup** panel: DPI, polling, battery, profile, plus the DPI stage strip | +| Activity/LP chart: one line, soft fade, hairline guides | **Battery over time** from `features/devices/battery-history.ts` (real samples, 5-min checkpoints, 30-day window) | +| Match rows: one row per game, result tint, expand inline | Hardware-test stages, game list, diagnostics entries | +| Champion table: name pinned, figures right-aligned, tabular | Button mapping, DPI stages, device facts, a supported-devices table if we add one | +| Segmented control as an inset well with a raised key | Polling rate, DPI mode, language, every enum setting | +| Translucent sheets lit from the top, one soft light on the ground | Surface system for all 12 themes | +| "Last answer stays until the next arrives" | Device reads: keep the old value dimmed while a read is in flight, no skeleton flashes | +| Empty states that say why and what happens next ("Checked every 30 seconds") | Every empty state (no device, no history, tab unavailable, Bridge absent) | +| ⌘K search | Settings search over the cards the connected device actually has (only if built; see 5.2) | + +### Do not copy + +- Invade's violet, its token values, Archivo, rank/tier colours, PerfDial glow, LoL components, copy or icons. +- `invade-ui` is **PolyForm Noncommercial**: no vendoring of CSS, components or fonts. OpenMouse is AGPL and takes donations. We reimplement the ideas in our own tokens. The mock's token file is our own sketch; re-derive values during implementation rather than diffing against `invade-ui/src/styles/tokens.css`. +- The reference's coloured 3px result edge. We mark pass/fail with icon + word + a faint tint instead (no decorative stripes). + +--- + +## 3. What changes, aspect by aspect + +| Aspect | Today | New | +|---|---|---| +| Navigation | Floating rounded left rail, rainbow icon per item, collapses on device pages | 52px top bar: brand + build, Devices, Hardware test, Games (Bridge only), Docs ↗; right side: Bridge state, search, What's new, Help, Settings | +| Device page frame | Back button + centred pill tabs with glowing active pill; giant centred render | 300px left column (device card + section nav, sticky) and a main column of panels | +| Home | Square tiles centred on a page, aurora animation | Device rows (render 64px, name, connection, polling, battery, state) + an OpenMouse Bridge panel with real status | +| Cards | 51 bespoke chromes; mono uppercase micro labels ("STAGES IN USE", "POLLING RATE") | One `Panel` (title, meta, end slot); rows split by hairlines; sentence-case labels | +| Settings rows | Mixed per card | One `SettingRow`: label + description left, control right, 44px min | +| Controls | Several button styles, filled green toggles and pills | Button (default, ghost, one primary per view), Segmented, Switch, Select, Slider, NumberField, Badge | +| Colour | Per-item icon hues, green glows, green Back button | Neutral chrome; accent only for primary action, active place, focus, slider fill, active stage. `ok/bad/warn` only for real states | +| Type | DM Sans + JetBrains Mono | DM Sans only, tabular figures everywhere; mono only for raw hex IDs and HID logs in Diagnostics. Scale: page 20, panel 14, body 13, meta 12 | +| Radii | 16px floating panels, pills | 3 / 5 / 8 / 12 only; no pills | +| Shadows | 86 shadows, glows | Lit top edge on sheets; real shadow only on menus, dialogs, the pending bar | +| Unsaved changes | `PendingBar` at the bottom of the shell | Sticky bar at the bottom of the main column: "N unsaved changes", the diff in words, Discard, **Save to mouse** | +| Motion | Aurora, glows, scroll smoothing | 120–180ms washes; values swap in place; existing "Animations" preference and `prefers-reduced-motion` both honoured | +| Help bot | Floating "OpenMouse AI" bubble with a bot icon that plays canned answers | It is a scripted FAQ, so it must not be called AI. Rename to "Quick help" and move it into the Help menu, or drop it (owner decides) | +| Launch countdown | Live code, date passed | Delete with `launch.ts`/`launch.css` instead of restyling | +| Docs | `DESIGN.md` mandates M3, rainbow icons, 16px floating surfaces, square cards | Replace `DESIGN.md` and the design rules in `AGENTS.md` before code lands | + +--- + +## 4. Page by page + +Widths: content 1200px; device page = 300px side + 24px gap + main. Below 1100px the device column becomes a horizontal header (small render, name, actions) with section nav as a row of tabs. The 900 × 600 gate stays. + +### 4.1 Shell (`App.tsx`, new `TopBar.tsx`, `Footer.tsx`; delete `AppSidebar.tsx`) + +- Top bar on `--ink-0` with a 1px hairline. Active item = filled `--surface-2`, no underline glow. +- News banner becomes a 34px strip under the bar, same column width, dismiss on the right (existing `features/news/news.ts` logic). +- Footer: brand, Supported devices, GitHub, Discord, X, Donate, AGPL + not-affiliated line. Moves the support links out of Settings. +- Live region and toasts stay where they are in the tree. + +### 4.2 Devices (home) + +- Page head: "Devices", meta "N authorized in this browser", primary **Connect a device** (the only primary on the page). +- Rows: 64px render (contain, never cropped), name + brand · connection, polling, battery, state badge ("Connected" / "Plug in to open"), chevron. Whole row opens the device; gear button removed (it did the same thing). +- Empty: no aurora. Text: what OpenMouse needs (Chromium or Bridge, the device plugged in) and the Connect button. +- Bridge panel (lit sheet): what Bridge does, real status from `features/bridge/bridge-status-store.ts`, latest release from `features/bridge/updates.ts`, Download link. + +### 4.3 Device page + +**Left column** +- Device card (`--r-lg`): render in an inset well with a soft accent light behind it; name 20px; "Connected · Brand"; connection detail; actions: Share profile, Setup code, overflow (Disconnect, Copy profile key, Diagnostics). +- Section nav: Overview, Performance, Buttons, Macros, Lighting, Profiles, Advanced, filtered by the existing `availableWorkspaceTabs()`. Arrow-key behaviour from today's tablist is kept. +- Other devices (overview only): other authorized devices as 44px rows. + +**Overview** +- Current setup (lit sheet): DPI, polling, battery, onboard profile as figures; DPI stage strip with the active stage marked. Every value from `status`; missing values show "–". +- Battery over time: chart from `features/devices/battery-history.ts` with the time-left estimate it already computes; empty state explains the 10-minute minimum. +- Firmware: installed / latest / connection rows + Check for update (`firmware-updates.ts`). +- Device-specific overview blocks (M2-NEX profiles, power overview) slot in as panels. + +**Performance**: Sensitivity panel (stage rows: enable, index, number field, slider, "Active"), Polling rate (segmented well, current value as a figure), Sensor/LOD, then vendor panels. One column; the render no longer repeats here because it sits in the left card. + +**Buttons**: ButtonMap gets the main column with the mouse diagram; mapping tables become two-column rows (button name | select). HITS keeps its dial-like controls but inside a standard panel; its presets become a Select + Save/Delete in the panel header. + +**Macros / Lighting / Profiles**: standard panels. Lighting zone list on the left of the panel, ColorPicker on the right. Profiles as rows (slot, name, active badge, actions) with Share/Import in the header. + +**Advanced**: settings rows grouped into panels (Wireless, Power, Receiver, Vendor). The dev-testing note becomes one quiet line at the top. Diagnostics becomes a panel with an inline expandable log (mono allowed here) and Capture. + +**Unsaved changes bar**: described in section 3. + +### 4.4 Keyboard page + +Same frame. Device card shows the keyboard render; sections from `availableKeyboardWorkspaceTabs()`. + +### 4.5 Hardware test + +- Page head: "Hardware test", one-line meta, actions: Start test (primary), Download log. +- Left: device card (shared component). Right: stage list, one 44px row per stage: state icon + label + detail + right-aligned result; running stage shows its progress bar and live Hz. Verdict summary panel on top when finished: Passed / Failed / Stopped, counts, duration. +- Device facts panel (key/value rows) below the stages instead of a third column. +- The 3-line footer paragraph shrinks to one sentence with the Discord link. + +### 4.6 Games (Bridge only) + +- Page head with Bridge state. Grid of game tiles (art 16:9, name, "Auto-apply" badge when set) plus a "Request a game" tile that opens `GameRequestDialog`. +- Game profile: the device page frame with the game's art in place of the render and only settings sections (as `gameProfile` already filters). + +### 4.7 Settings + +- Two columns: anchor list on the left (Appearance, Language, Behaviour, Profile key, Developer previews when enabled), panels on the right. +- Theme picker: 12 swatches that each render a tiny preview in that theme's own tokens (sheet, text, accent), not just a dot. +- Language: Select with native names (12 options is too many for a segmented control). +- Behaviour: sound, animations, instant flash, advanced editors, experimental as SettingRows with switches. +- Reset as a ghost button at the bottom. Support links move to the footer. + +### 4.8 Gates and notices + +One `Notice` layout for Unsupported (4 variants) and Window too small: brand mark, headline, one paragraph, one action where an action exists (Download Bridge, Open over HTTPS). Offline banner reuses the news strip style. + +### 4.9 Dialogs, menus, toasts + +- `Dialog` primitive: opaque `--solid-2`, `--r-lg`, menu shadow, title + close, footer actions with one primary, Escape and focus trap. All 11 dialogs move onto it. +- Menus/popovers (`OptionMenu`, ColorPicker): `--solid-2`, `--r-sm`. +- Toasts: bottom-right, solid, icon + text, no glow. + +--- + +## 5. Implementation + +### 5.1 Structure + +- `src/styles/tokens.css`: shape/type/motion + **12 theme blocks**, each defining the full colour set (`--ink-0/1`, `--surface-*`, `--solid-*`, `--line-*`, `--text-1..4`, `--accent`, `--accent-rgb`, `--on-accent`, `--ok/--bad/--warn`, lighting and shadow recipes). Default stays whatever `DEFAULT_INTERFACE_PREFERENCES.theme` says (currently **Emerald**; the decision text said Matt, so confirm). +- `src/styles/base.css`, `primitives.css`, `shell.css`, then one file per area (`device.css`, `performance.css`, `buttons.css`, `lighting.css`, `profiles.css`, `advanced.css`, `hardware-test.css`, `games.css`, `settings.css`, `dialogs.css`). Replaces `control.css`, `control-devices.css`, `control-profiles.css`, `profile-card.css`. +- Primitives in `src/app/ui/`: `Panel`, `SettingRow`, `Figure`, `KeyValue`, `Button`, `Badge`, `EmptyState`, `Dialog`, `DeviceCard`, `SectionNav`. Existing `ui.tsx` controls (`Segmented`, `SwitchButton`, `OptionMenu`, `StepperSlider`, `RateSlider`, `Collapsible`, `BatteryIcon`) keep their props and get restyled, so the 51 cards mostly change only their outer chrome. +- Icons: keep `lucide-react` for now, one stroke width, `currentColor`, no per-icon hues. Swap the set later if the owner wants a different look. +- All new strings through `t()`; add keys to all 12 locales (the `i18n-tr-coverage` test fails otherwise). Logical CSS properties throughout for Arabic RTL. + +### 5.2 Order + +| # | Step | Done when | +|---|---|---| +| 0 | Rewrite `DESIGN.md`, update `AGENTS.md` rules, delete launch gate | Docs merged | +| 1 | Tokens for all 12 themes + base + primitives | Primitive gallery renders in 12 themes, AA contrast measured for `--text-1..3` on `--ink-1` and `--surface-1` per theme | +| 2 | Shell: TopBar, news strip, footer, layout; remove sidebar | Every existing page reachable; Games appears only with Bridge | +| 3 | Devices home + empty + Bridge panel + notices | Preview `list` and a no-device run render clean | +| 4 | Device page frame: DeviceCard, SectionNav, Overview, pending bar | `?preview=razer` overview matches the mock | +| 5 | Section areas in parallel (one worker each): Performance; Buttons/HITS/Magnetic/ButtonMap; Macros + Lighting + Profiles; Advanced + Diagnostics + Firmware + keyboard | Every preview key × every tab renders without overflow | +| 6 | Hardware test, Games, Settings | Click-through recorded | +| 7 | Dialogs, menus, toasts on the shared primitives | Each dialog opens, closes with Escape, traps focus | +| 8 | Optional: URL routes (`/`, `/device/:i/:section`, `/test`, `/games`, `/settings`) with an allowlisted parser like `device/preview/preview-modes.ts` | Back button works; PWA precache test still passes | +| 9 | Optional: settings search (Ctrl K) indexing only cards from `cardAvailability()` | Results jump to and focus the card; not rendered until built | + +### 5.3 Verification + +- Harness: the 36 `?preview=` fixtures already cover most vendors. A Node Playwright script shoots every preview key × every available tab at 1920 × 1080 and 1100 × 800 in Emerald, Light and NieR, plus Arabic once, and logs page overflow and console errors. +- `npm run check` (tsc + vite build + node tests) and `npm run size` (CSS budget should drop well below 225 kB). +- antislop Delivery Gate report with the click-through before anything is called done. + +--- + +## 6. Open questions + +1. Default theme: code default is **Emerald**; the decision text said Matt. Which one? +2. "OpenMouse AI": rename to Quick help in the Help menu, or remove? +3. Add an in-app **Supported devices** table page (data is already in `features/hardware-test/supported-devices-data.ts`), or keep linking to openmouse.app/supported? +4. URL routing (step 8) now, or later? +5. Keep the per-mouse DPI stage colours (M2-NEX `--stage-color-*`, these mirror the mouse's LED colours) as the only multi-hue element? diff --git a/functions/_lib/admin.js b/functions/_lib/admin.js index 3e4db455..7eb8a315 100644 --- a/functions/_lib/admin.js +++ b/functions/_lib/admin.js @@ -22,7 +22,6 @@ export function tokenFrom(request) { return request.headers.get("x-admin-token") ?? ""; } -/** True only when ADMIN_TOKEN is configured and the request presents it. */ export function isAdminRequest(request, env) { const expected = env.ADMIN_TOKEN; if (!expected) return false; diff --git a/functions/_lib/discord-card.js b/functions/_lib/discord-card.js index 48d5c4f7..f94588b1 100644 --- a/functions/_lib/discord-card.js +++ b/functions/_lib/discord-card.js @@ -1,11 +1,3 @@ -// Renders the README's "OpenMouse on Discord" card as an SVG, with live member -// and online counts when Discord answers. -// -// Counts come from Discord's public invite lookup (no bot or token needed): -// `approximate_member_count` and `approximate_presence_count`. When the lookup -// fails, the card falls back to the static tagline so the README never shows a -// broken image. Layout and colors mirror `.github/assets/discord-card.svg`. - export const DISCORD_INVITE_CODE = "yxC9jzMdw6"; const INVITE_API = `https://discord.com/api/v10/invites/${DISCORD_INVITE_CODE}?with_counts=true`; diff --git a/functions/_lib/security-headers.js b/functions/_lib/security-headers.js index 67cc4496..2e9a6af1 100644 --- a/functions/_lib/security-headers.js +++ b/functions/_lib/security-headers.js @@ -3,7 +3,7 @@ // `public/_headers` declares the exact same set for the static assets Pages // serves directly; this module applies it to the Pages Functions responses // (the IP-ban screen, blocked/rate-limited replies, and all /api/* payloads), -// which `_headers` does not cover. `src/security-headers.test.ts` keeps the two +// which `_headers` does not cover. `test/security-headers.test.ts` keeps the two // copies in lockstep so they cannot drift. // // `upgrade-insecure-requests` is deliberately absent. It would rewrite the diff --git a/index.html b/index.html index 94b621e8..9445cabb 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,6 @@
- + diff --git a/package.json b/package.json index 14969cfb..f3a43fc8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "node --test \"src/**/*.test.ts\" \"build/**/*.test.ts\"", + "test": "node --test \"src/**/*.test.ts\" \"test/**/*.test.ts\" \"build/**/*.test.ts\"", "size": "node build/check-bundle-size.ts", "check": "npm run build && npm test", "preview": "vite preview" diff --git a/public/news.json b/public/news.json index 0947b060..e5edd02b 100644 --- a/public/news.json +++ b/public/news.json @@ -2,5 +2,5 @@ "id": "2026-09-openmouse-bridge", "level": "info", "message": "OpenMouse Bridge is here: Razer mice work on Windows again, plus game profiles, low-battery alerts and Firefox support. Read the announcement.", - "url": "https://openmouse.app/blog-openmouse-bridge" + "url": "https://openmouse.app/blog/openmouse-bridge" } diff --git a/public/sounds/click.wav b/public/sounds/click.wav deleted file mode 100644 index 23e25b55..00000000 Binary files a/public/sounds/click.wav and /dev/null differ diff --git a/public/theme-init.js b/public/theme-init.js index 81bc65e1..4dda34b1 100644 --- a/public/theme-init.js +++ b/public/theme-init.js @@ -11,6 +11,5 @@ document.documentElement.dataset.colorScheme = dark ? "dark" : "light"; document.documentElement.style.colorScheme = dark ? "dark" : "light"; } catch { - /* storage disabled — fall back to the stylesheet default */ } })(); diff --git a/scripts/generate-firmware-manifest.mjs b/scripts/generate-firmware-manifest.mjs index 99acfd74..aaa73d07 100644 --- a/scripts/generate-firmware-manifest.mjs +++ b/scripts/generate-firmware-manifest.mjs @@ -89,7 +89,6 @@ function entryId(depotName, version, key) { return `${depotName}@${version}${key && key !== "dfu_binary" ? `:${key}` : ""}`; } -/** Plain-text English changelog from the depot, if shipped. */ function depotReleaseNotes(files) { const raw = files.get('release_notes/dfu_release_notes.html'); if (!raw) return null; diff --git a/scripts/sync-supported-devices.mjs b/scripts/sync-supported-devices.mjs index 940e4443..3bfb84b1 100644 --- a/scripts/sync-supported-devices.mjs +++ b/scripts/sync-supported-devices.mjs @@ -1,4 +1,4 @@ -// Mirrors the supported-devices table into src/supported-devices-data.ts. +// Mirrors the supported-devices table into src/features/hardware-test/supported-devices-data.ts. // // The source of truth is the landing page's `supported-mice.ts` (the table the // openmouse.app/supported page renders; itself re-checked against the @@ -19,7 +19,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const root = join(here, ".."); -const source = join(root, "..", "openmouse-landing-page", "src", "supported-mice.ts"); +const source = join(root, "..", "openmouse-landing-page", "src", "data", "supported-mice.ts"); const { MICE } = await import(pathToFileURL(source).href); @@ -31,7 +31,7 @@ const rows = MICE.map((entry) => { const out = `// Supported-devices table, mirrored from the landing page. // // AUTO-GENERATED — do not edit by hand. Source of truth: -// openmouse-landing-page/src/supported-mice.ts +// openmouse-landing-page/src/data/supported-mice.ts // (the table behind openmouse.app/supported, re-checked against the // @openmouse/protocol driver registry every time the landing page builds). // @@ -61,5 +61,5 @@ ${rows.join("\n")} ]; `; -writeFileSync(join(root, "src", "supported-devices-data.ts"), out); -console.log(`Wrote src/supported-devices-data.ts with ${MICE.length} entries from ${source}`); \ No newline at end of file +writeFileSync(join(root, "src", "features", "hardware-test", "supported-devices-data.ts"), out); +console.log(`Wrote src/features/hardware-test/supported-devices-data.ts with ${MICE.length} entries from ${source}`); \ No newline at end of file diff --git a/src/app/AiOverlay.tsx b/src/app/AiOverlay.tsx deleted file mode 100644 index f1d112d4..00000000 --- a/src/app/AiOverlay.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { displayText } from "../i18n"; -import { Bot, BotMessageSquare, X } from "lucide-react"; -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { t } from "../i18n"; -import type { InterfaceLocale } from "../interface-preferences"; -import { DISCORD_URL } from "./social-links"; - -type TopicId = "connect" | "dpi" | "browser" | "saving" | "other"; - -const TOPIC_ORDER: TopicId[] = ["connect", "dpi", "browser", "saving", "other"]; - -interface Message { - from: "bot" | "user"; - text: string; -} - -export function AiOverlay({ locale = "en" }: { locale?: InterfaceLocale }): ReactNode { - const [open, setOpen] = useState(false); - const [messages, setMessages] = useState([]); - const [typed, setTyped] = useState(true); - const [greeted, setGreeted] = useState(false); - const scrollRef = useRef(null); - const timerRef = useRef(null); - - useEffect(() => { - return () => { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - }; - }, []); - - useEffect(() => { - if (!open) return; - if (!greeted) { - setGreeted(true); - scheduleAnswer(t(locale, "ai.greeting"), 700); - } - }, [open, greeted, locale]); - - useEffect(() => { - const node = scrollRef.current; - if (node) node.scrollTop = node.scrollHeight; - }, [messages, typed, open]); - - function scheduleAnswer(answer: string, delay: number): void { - setTyped(false); - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = window.setTimeout(() => { - timerRef.current = null; - setMessages((current) => [...current, { from: "bot", text: answer }]); - setTyped(true); - }, delay); - } - - const topicLabel = (id: TopicId): string => { - switch (id) { - case "connect": return t(locale, "ai.topic1"); - case "dpi": return t(locale, "ai.topic2"); - case "browser": return t(locale, "ai.topic3"); - case "saving": return t(locale, "ai.topic4"); - case "other": return t(locale, "ai.other"); - } - }; - - const topicAnswer = (id: TopicId): string => { - switch (id) { - case "connect": return t(locale, "ai.topic1Ans"); - case "dpi": return t(locale, "ai.topic2Ans"); - case "browser": return t(locale, "ai.topic3Ans"); - case "saving": return t(locale, "ai.topic4Ans"); - case "other": return t(locale, "ai.otherAns"); - } - }; - - function pick(id: TopicId): void { - if (!typed) return; - setMessages((current) => [...current, { from: "user", text: topicLabel(id) }]); - const answer = topicAnswer(id); - scheduleAnswer(answer, 450 + Math.min(answer.length * 6, 2200)); - } - - return ( - <> - - - {open ? ( -
-
-
- - -
- {t(locale, "ai.title")} - {t(locale, "ai.subtitle")} -
-
- -
- -
- {messages.map((message, index) => ( -
- {displayText(locale, message.text)} -
- ))} - {!typed ? ( - - ) : null} -
- -
- {TOPIC_ORDER.map((id) => ( - - ))} -
- - -
- ) : null} - - ); -} \ No newline at end of file diff --git a/src/app/App.tsx b/src/app/App.tsx index bb3ec894..abfacc4d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,25 +1,35 @@ -import { displayText } from "../i18n"; +import { displayText } from "../i18n/index"; import { useEffect, useRef, useState, type ReactNode } from "react"; import * as control from "../device/controller"; -import { ensureDisplayLocale } from "../i18n"; -import { interfaceThemeSlug } from "../interface-preferences"; +import { ensureDisplayLocale } from "../i18n/index"; +import { interfaceThemeSlug } from "../features/settings/interface-preferences"; import { AppSidebar, type DesktopPage } from "./AppSidebar"; -import { OverviewPage } from "./OverviewPage"; -import { CaptureDialog } from "./CaptureDialog"; -import { GamesPage, useBridgeActive } from "./GamesPage"; -import { HardwareTestPage } from "./HardwareTestPage"; -import { InterfaceSettings } from "./InterfaceSettings"; -import { NewsBanner } from "./NewsBanner"; +import { OverviewPage } from "../features/devices/OverviewPage"; +import { CaptureDialog } from "../features/capture/CaptureDialog"; +import { GamesPage } from "../features/games/GamesPage"; +import { HardwareTestPage } from "../features/hardware-test/HardwareTestPage"; +import { InterfaceSettings } from "../features/settings/InterfaceSettings"; +import { NewsBanner } from "../features/news/NewsBanner"; import { PendingBar } from "./PendingBar"; -import { ShareProfileDialog } from "./ShareProfileDialog"; -import { WhatsNewDialog } from "./WhatsNewDialog"; -import { AiOverlay } from "./AiOverlay"; +import { HelpPanel } from "./HelpPanel"; +import { ShareProfileDialog } from "../features/profiles/ShareProfileDialog"; +import { WhatsNewDialog } from "../features/news/WhatsNewDialog"; import { ToastHost } from "./Toasts"; -import { useBridgeBatteryReporter } from "./useBridgeBatteryReporter"; -import { useBridgeProfileApplier } from "./useBridgeProfileApplier"; +import { useBridgeBatteryReporter } from "../features/bridge/useBridgeBatteryReporter"; +import { useBridgeProfileApplier } from "../features/bridge/useBridgeProfileApplier"; import { useControl } from "./useControl"; -import { setSoundsEnabled } from "../sound-manager"; -import { UiLocaleContext } from "./ui-locale"; +import { setSoundsEnabled } from "../platform/sound-manager"; +import { UiLocaleContext } from "../shared/ui-locale"; + +const SIDEBAR_KEY = "openmouse-sidebar-collapsed-v1"; + +function initialSidebarCollapsed(): boolean { + try { + return localStorage.getItem(SIDEBAR_KEY) === "1"; + } catch { + return false; + } +} export function App(): ReactNode { const snapshot = useControl(); @@ -30,12 +40,10 @@ export function App(): ReactNode { const [shareProfileOpen, setShareProfileOpen] = useState(false); const [whatsNewOpen, setWhatsNewOpen] = useState(false); const [page, setPage] = useState("home"); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(initialSidebarCollapsed); const { preferences, status } = snapshot; const locale = preferences.locale; - const bridgeActive = useBridgeActive(); - // Games only exists while Bridge does — if it goes away, fall back to Home. - const showingGames = page === "games" && bridgeActive; + const showingGames = page === "games"; const showingSettings = page === "settings" || snapshot.interfaceSettingsOpen; @@ -43,9 +51,9 @@ export function App(): ReactNode { ? "settings" : page === "hardware-test" ? "hardware-test" - : showingGames - ? "games" - : (status !== null || snapshot.keyboard !== null) && snapshot.deviceView === "device" + : showingGames + ? "games" + : (status !== null || snapshot.keyboard !== null) && snapshot.deviceView === "device" ? "dashboard" : "home"; @@ -69,13 +77,11 @@ export function App(): ReactNode { if (document.documentElement.lang !== nextLang) { document.documentElement.lang = nextLang; } - // Arabic reads right-to-left; every other locale is left-to-right. const nextDir = locale === "ar" ? "rtl" : "ltr"; if (document.documentElement.dir !== nextDir) { document.documentElement.dir = nextDir; } } catch { - /* non-DOM environment (tests) */ } // A stored non-English locale resolves its table after first paint; // re-render once it arrives instead of sticking on fallback strings. @@ -85,19 +91,32 @@ export function App(): ReactNode { }); }, [locale]); + useEffect(() => { + try { + document.documentElement.dataset.interfaceTheme = interfaceThemeSlug(preferences.theme); + } catch { + } + }, [preferences.theme]); useEffect(() => { panel.current?.scrollTo({ top: 0, behavior: preferences.reducedMotion ? "auto" : "smooth" }); }, [page, snapshot.workspaceTab, preferences.reducedMotion]); - useEffect(() => { - setSidebarCollapsed(resolvedPage === "dashboard" || resolvedPage === "hardware-test"); - }, [resolvedPage]); - useEffect(() => { setSoundsEnabled(preferences.enabledSounds); }, [preferences.enabledSounds]); + const toggleSidebar = (): void => { + setSidebarCollapsed((current) => { + const next = !current; + try { + localStorage.setItem(SIDEBAR_KEY, next ? "1" : "0"); + } catch { + } + return next; + }); + }; + function navigate(next: DesktopPage): void { if (next === "settings") { control.openInterfaceSettings(); @@ -120,12 +139,19 @@ export function App(): ReactNode { } } + const openDevice = (index: number): void => { + control.closeInterfaceSettings(); + setPage("home"); + void control.openDeviceOverview(index); + }; + + return (
- - setSidebarCollapsed(!sidebarCollapsed)} onNavigate={navigate} onOpenWhatsNew={() => setWhatsNewOpen(true)} /> + setWhatsNewOpen(true)} + onOpenDevice={openDevice} + /> -
-
+
+ +
{showingSettings ? ( ) : page === "hardware-test" ? ( @@ -161,7 +195,7 @@ export function App(): ReactNode { setCaptureOpen(false)} locale={locale} /> setWhatsNewOpen(false)} locale={locale} /> setShareProfileOpen(false)} snapshot={snapshot} /> - +
diff --git a/src/app/AppSidebar.tsx b/src/app/AppSidebar.tsx index e345a24f..186b1d57 100644 --- a/src/app/AppSidebar.tsx +++ b/src/app/AppSidebar.tsx @@ -1,21 +1,120 @@ -import { displayText } from "../i18n"; -import { ChevronLeft, ChevronRight, FileText, FlaskConical, Gamepad2, House, Mouse, Settings as SettingsIcon, Star, type LucideIcon } from "lucide-react"; +import { DISCORD_URL, DiscordIcon } from "../shared/social-links"; +import { displayText, t } from "../i18n/index"; +import { + Activity, + BookOpen, + ChevronLeft, + ChevronRight, + Gamepad2, + LayoutGrid, + Megaphone, + Settings as SettingsIcon, + type LucideIcon, +} from "lucide-react"; import { type ReactNode } from "react"; import type { ControlSnapshot } from "../device/types"; -import { t } from "../i18n"; -import { useBridgeActive } from "./GamesPage"; -import { DISCORD_URL, DiscordIcon } from "./social-links"; +import { deviceImage } from "../shared/device-images"; +import { BridgeStatusButton } from "../features/bridge/BridgeStatus"; +import { BatteryIcon } from "../shared/ui"; export const OPENMOUSE_URL = "https://openmouse.app/"; export type DesktopPage = "home" | "dashboard" | "hardware-test" | "games" | "settings"; -function NavIcon({ icon: Icon, color }: { icon: LucideIcon; color: string }): ReactNode { - return