mirror of
https://github.com/rommapp/romm.git
synced 2026-06-27 14:25:52 +00:00
docs: split v2 constitution into skills, make CLAUDE.md holistic
Reorient the repo for agentic development by outside contributors.
- Rewrite CLAUDE.md from a frontend-v2-only constitution into a lean,
holistic guide covering both stacks, the v1/v2 split, repo-wide rules
(English, AI disclosure, branch/PR flow, Trunk, verification), a skills
index, and a quick command reference.
- Extract the v2 constitution and add backend guidance as seven focused
skills under .claude/skills/:
- frontend-v2-components (tiers, file/SFC conventions, anti-patterns)
- frontend-v2-theming (tokens, dual theme, zero-hex-literal policy)
- frontend-v2-input (universal input + responsive viewport)
- frontend-v2-patterns (errors, loading, sockets, persistence, forms,
permissions, confirmations)
- frontend-i18n (locale parity rule + check script)
- backend-development (FastAPI/SQLAlchemy layering, scopes, migrations,
OpenAPI -> frontend type pipeline, uv/pytest/trunk workflow)
- pre-pr-verification (per-stack pre-PR gate mirroring CI)
- Track shared skills in git while keeping personal/local Claude config
ignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EigwEL43km8mskHjT6sjr
This commit is contained in:
93
.claude/skills/backend-development/SKILL.md
Normal file
93
.claude/skills/backend-development/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: backend-development
|
||||
description: Working on the RomM Python backend (backend/) — a FastAPI app with SQLAlchemy 2.0, Alembic, RQ/Redis, and Socket.IO. Use when adding or changing API endpoints, handlers, ORM models, response schemas, metadata-provider adapters, background tasks, database migrations, or backend tests. Covers the layered architecture, conventions, auth/scopes, the OpenAPI→frontend type pipeline, and the uv/pytest/alembic/trunk workflow. Trigger on any work under backend/.
|
||||
---
|
||||
|
||||
# RomM Backend — FastAPI / SQLAlchemy
|
||||
|
||||
Python 3.13+, FastAPI, SQLAlchemy 2.0 (MariaDB default; MySQL/PostgreSQL supported), Alembic, Redis + RQ for jobs/cache/sessions, Socket.IO for real-time. Managed with **uv**.
|
||||
|
||||
Full reference: **`docs/BACKEND_ARCHITECTURE.md`** (directory map, ER diagram, every endpoint, auth flows). Read it before non-trivial changes.
|
||||
|
||||
---
|
||||
|
||||
## Layered architecture — where code goes
|
||||
|
||||
```
|
||||
endpoints/ FastAPI routers: request validation, response schemas, @protected_route scopes
|
||||
endpoints/responses/ Pydantic response schemas (these shape the OpenAPI → frontend types)
|
||||
endpoints/sockets/ Socket.IO event handlers
|
||||
handler/ Business logic, decoupled from HTTP
|
||||
├ auth/ HybridAuthBackend (session/basic/bearer/OIDC/client-token), scopes, CSRF/session middleware
|
||||
├ database/ Per-entity CRUD handlers (db_rom_handler, db_user_handler, …), engine/session factory
|
||||
├ metadata/ One handler per provider; normalizes + ranks by priority
|
||||
└ filesystem/ ROM/asset/firmware file I/O, hashing, archive extraction
|
||||
adapters/services/ Typed external API clients (igdb.py + igdb_types.py, screenscraper.py, …)
|
||||
models/ SQLAlchemy ORM models (BaseModel adds created_at/updated_at)
|
||||
tasks/ RQ jobs — scheduled/ (cron) and manual/ (on-demand); base classes in tasks.py
|
||||
config/ Env-var loading (__init__.py) + YAML config manager (singleton)
|
||||
decorators/ @begin_session (DB session), @protected_route (auth + scopes)
|
||||
exceptions/ Custom exception hierarchy
|
||||
utils/ logger/ Shared helpers, structured logging
|
||||
alembic/ Migrations (env.py + versions/)
|
||||
```
|
||||
|
||||
**Endpoint → handler → (database | metadata | filesystem) → models/adapters.** Endpoints stay thin: validate, enforce scopes, call handlers, serialize via a response schema. Don't put business logic or raw queries in endpoints.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Naming:** Classes `PascalCase`; functions/vars `snake_case`; constants `UPPER_SNAKE_CASE`; private `_prefixed`.
|
||||
- **DB sessions:** decorate handler methods with `@begin_session`; it injects and manages the SQLAlchemy session/transaction. Don't open sessions ad hoc.
|
||||
- **Async:** I/O-bound endpoints and tasks use `async/await`. Per-request `httpx`/`aiohttp` clients come from context vars (`utils/context.py`), not new clients per call.
|
||||
- **Imports:** stdlib → third-party → local; explicit (no wildcards); `TYPE_CHECKING` blocks to break circular imports.
|
||||
- **Errors:** raise the custom exceptions in `exceptions/` (e.g. `RomNotFoundInDatabaseException`), not bare `HTTPException`, where a typed one exists.
|
||||
- **Validation/SSRF:** sanitize filenames/paths before filesystem use (`utils/`); paths are rooted at `LIBRARY_BASE_PATH`/`RESOURCES_BASE_PATH`/`ASSETS_BASE_PATH` from config.
|
||||
|
||||
## Auth & scopes
|
||||
|
||||
- Roles: `VIEWER` (read), `EDITOR` (+write roms/platforms/assets), `ADMIN` (+users/tasks/logs). Defined on `models/user.py`; scope tiers in `handler/auth/constants.py`.
|
||||
- Granular scopes: `me.read/write`, `roms.read/write`, `platforms.*`, `assets.*`, `devices.*`, `firmware.*`, `collections.*`, `users.*`, `tasks.run`, `logs.read`.
|
||||
- Protect routes with `@protected_route(router.<method>, "<path>", [Scope.X])`. The frontend mirrors these scopes — keep them aligned.
|
||||
|
||||
## Adding things
|
||||
|
||||
- **Endpoint:** add the route in the right `endpoints/*` router, a response schema in `endpoints/responses/`, enforce scopes, delegate to a handler. If the response shape changes, the frontend must regenerate types (below).
|
||||
- **Model / schema change:** edit `models/`, then create a migration (below). Update the matching response schema so OpenAPI stays accurate.
|
||||
- **Metadata provider:** add a typed client in `adapters/services/<name>.py` (+ `<name>_types.py`) and a `handler/metadata/<name>_handler.py` that normalizes into the common shape and slots into the priority order.
|
||||
- **Background job:** subclass `Task`/`PeriodicTask` in `tasks/scheduled/` or `tasks/manual/`; register scheduled jobs in `startup.py`.
|
||||
|
||||
## Database migrations (Alembic)
|
||||
|
||||
Migrations must work on **MariaDB, MySQL, and PostgreSQL** (CI runs `alembic upgrade head` on Postgres and MariaDB — `.github/workflows/migrations.yml`). Use batch mode / DB-specific SQL where needed; mirror existing migrations in `alembic/versions/`.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run alembic revision --autogenerate -m "short description" # generate, then HAND-REVIEW the file
|
||||
uv run alembic upgrade head # apply
|
||||
uv run alembic downgrade -1 # verify the downgrade works
|
||||
```
|
||||
|
||||
Always review autogenerated migrations — they miss server-default/enum/index nuances and cross-dialect differences. The `virtual_collections` DB view is excluded from migrations.
|
||||
|
||||
## OpenAPI → frontend types
|
||||
|
||||
FastAPI serves the schema at `GET /openapi.json`. The frontend regenerates its TypeScript types from it:
|
||||
|
||||
```bash
|
||||
# backend running on :3000, then in frontend/
|
||||
npm run generate # writes src/__generated__/ via openapi-typescript-codegen
|
||||
```
|
||||
|
||||
**Any change to a response schema or route signature should be followed by `npm run generate` + a frontend typecheck.**
|
||||
|
||||
## Run, test, lint
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run python3 main.py # run (migrations auto-apply on startup)
|
||||
uv run pytest [path/file] # tests (subset by path); uv run pytest -vv for all
|
||||
```
|
||||
|
||||
- Tests: pytest + pytest-asyncio, isolated per `pytest-xdist` worker (per-worker DBs); `fakeredis`; `pytest-recording` VCR cassettes mock external APIs; Hypothesis for property tests. Mirror the `backend/<area>/` layout under `backend/tests/`. First-time test DB setup: `docker exec -i romm-db-dev mariadb -uroot -p<pw> < backend/romm_test/setup.sql`.
|
||||
- **Lint / format / type-check run through Trunk** (ruff, black, isort, mypy, bandit): `trunk fmt && trunk check`. CI enforces Trunk on every PR. Never bypass with `--no-verify`.
|
||||
- New/changed logic needs a test; new endpoints need endpoint tests.
|
||||
32
.claude/skills/frontend-i18n/SKILL.md
Normal file
32
.claude/skills/frontend-i18n/SKILL.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: frontend-i18n
|
||||
description: Internationalization for the RomM frontend (both v1 and v2). Use whenever adding, renaming, or removing any user-visible string / translation key under frontend/src/locales/. Covers the en_US-is-source rule, the requirement to add every new key to ALL locale directories in the same change, namespace layout, and the check_i18n_locales.py validator enforced in CI. Trigger on any change to frontend/src/locales/**.
|
||||
---
|
||||
|
||||
# RomM Frontend — i18n / Localization
|
||||
|
||||
User-visible strings are **never hard-coded** in components — they come from locale files via `vue-i18n` (`$t(...)` in templates/composites; utils may call `i18n.global.t(...)`; **v2 lib primitives must not call `$t` at all** — text passes via props/slots).
|
||||
|
||||
## Structure
|
||||
|
||||
- Locales live in `frontend/src/locales/<locale>/<namespace>.json`, loaded by dynamic glob import in `src/locales/index.ts`.
|
||||
- **18 locales**: `en_US` (default + fallback), `en_GB`, `bg_BG`, `cs_CZ`, `de_DE`, `es_ES`, `fr_FR`, `hu_HU`, `it_IT`, `ja_JP`, `ko_KR`, `pl_PL`, `pt_BR`, `ro_RO`, `ru_RU`, `zh_CN`, `zh_TW`.
|
||||
- Namespaces are per-feature files (e.g. `collection`, `common`, `console`, `detail`, `emulator`, `gallery`, `home`, `library`, `login`, `navigation`, `patcher`, `platform`, `scan`, `settings`, `task`).
|
||||
|
||||
## The rule (enforced in CI)
|
||||
|
||||
- **`en_US` is the source of truth**, but **every key added to `en_US` must be added to all other locale directories in the same change.** Never leave a key English-only.
|
||||
- Translate where you can; otherwise copy the English value as a placeholder so the key exists.
|
||||
- Removing or renaming a key means doing it across **every** locale.
|
||||
|
||||
## Verify before handoff
|
||||
|
||||
```bash
|
||||
python3 frontend/src/locales/check_i18n_locales.py
|
||||
```
|
||||
|
||||
It compares every non-English locale against `en_US` and **fails on any missing file, missing key, or extra key**. CI runs the same script (`.github/workflows/i18n.yml`) on any change under `frontend/src/locales/**`. It must pass with zero missing/extra keys.
|
||||
|
||||
## Adding a new language
|
||||
|
||||
Create a new folder under `frontend/src/locales/` mirroring `en_US/`'s files, then translate. Open the PR against `master` (see the docs/Contributing flow). This is the one i18n change where a new locale directory is expected.
|
||||
124
.claude/skills/frontend-v2-components/SKILL.md
Normal file
124
.claude/skills/frontend-v2-components/SKILL.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: frontend-v2-components
|
||||
description: Building or modifying components in the RomM v2 frontend (frontend/src/v2/). Use when creating/editing v2 primitives (R* components in src/v2/lib/), shared composites, or feature composites — covers the three-tier model, file/folder conventions, SFC structure, import order, barrels, Storybook requirements, and v2 anti-patterns. Trigger on any work under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM Frontend v2 — Component Constitution
|
||||
|
||||
This governs work inside `frontend/src/v2/`. **v1 is frozen** (`src/views/`, `src/components/`, `src/console/`, `src/layouts/`) — never refactor it; it will be deleted wholesale in a final wave. v2 is gated by `user.ui_settings.uiVersion`.
|
||||
|
||||
> Official language for all code, comments, identifiers, `.md`, and commit/PR messages: **English**.
|
||||
|
||||
Related skills: `frontend-v2-theming` (tokens/colors), `frontend-v2-input` (focus/gamepad/responsive), `frontend-v2-patterns` (errors/loading/forms/permissions/confirmations), `frontend-i18n`, `pre-pr-verification`.
|
||||
|
||||
---
|
||||
|
||||
## Premises (stable)
|
||||
|
||||
1. **v1 is frozen.** Don't touch `src/views/`, `src/components/`, `src/console/`, `src/layouts/`. When coexistence forces a v2 fork of a store/composable/util, annotate the v1 export with `@deprecated` pointing at the v2 replacement.
|
||||
2. **Three component tiers** (below).
|
||||
3. **Shared resources are canonical.** Pinia stores, API services, OpenAPI types (`src/__generated__/`), locales, utils — v2 *imports* them, never forks them. Additive changes to shared resources are allowed; changing a shared store API to work around a v2 call-site issue is not.
|
||||
4. **TypeScript strict.** Zero `any` (justify with a comment if unavoidable). No `as unknown as ...`; fix the source or define an intermediate type.
|
||||
5. **Universal substitution.** When an `R*` primitive exists, use it. If it doesn't, create or extend it. Never drop to raw HTML or raw Vuetify when a primitive applies.
|
||||
6. **Wrapper contract.** Wrappers around Vuetify use `defineOptions({ inheritAttrs: false })` + `v-bind="$attrs"` + slot passthrough, and accept every prop/slot of the wrapped component.
|
||||
7. **Layout via Vuetify utility classes + our wrappers, not plain CSS.** Use `d-flex`, `pa-4`, `align-center` directly. Vuetify layout components (`v-row`, `v-col`, `v-container`, `v-spacer`, `v-app`, `v-main`) get wrapped as `R*` on first use (lazy).
|
||||
8. **Accessibility & performance** are requirements: semantic HTML, focus management, contrast, ARIA on icon-only controls; lazy-load heavy views, virtualize large lists, stable `:key` on every `v-for`.
|
||||
|
||||
---
|
||||
|
||||
## The three tiers
|
||||
|
||||
| Tier | Path | Prefix | Stores/services/router/emitter/i18n | Story | Domain knowledge |
|
||||
| --------------------- | ------------------------------ | -------------- | ----------------------------------- | --------- | --------------------------------- |
|
||||
| **Primitive** | `src/v2/lib/` | `R*` mandatory | **No** | Mandatory | None |
|
||||
| **Shared composite** | `src/v2/components/shared/` | no prefix | Yes | Optional | Cross-feature, no specific domain |
|
||||
| **Feature composite** | `src/v2/components/<feature>/` | no prefix | Yes | Optional | Feature-specific |
|
||||
|
||||
### A component is a primitive only if all three hold
|
||||
|
||||
1. Does not depend on stores, services, router, or emitter.
|
||||
2. No knowledge of a product domain (ROM, Platform, Collection, User…). `RAvatar` yes, `UserAvatar` no.
|
||||
3. Its API can be described without naming features — generic props/slots/events.
|
||||
|
||||
If any fails: **shared composite** if generic across features, **feature composite** if owned by one feature. Edge cases get raised to the user, not auto-decided. Consumer count never demotes a primitive.
|
||||
|
||||
### Primitive boundaries
|
||||
|
||||
- **Can use**: tokens, other primitives, Vue/Vuetify, generic composables (`useInput*`, `useFocus*`).
|
||||
- **Cannot use**: Pinia stores, API services, `emitter`, `router` (a `RouterLink` may be accepted as a prop), `i18n` directly. **No `$t()` in primitives** — text comes via props or slots.
|
||||
|
||||
---
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Primitive**: one per folder — `RFoo/RFoo.vue`, `RFoo/RFoo.stories.ts`, `RFoo/index.ts`, optional `RFoo/types.ts`.
|
||||
- **Composite**: flat `.vue` if one file suffices; a folder with the same internal structure (no story required) if it has sub-pieces.
|
||||
- **Barrel**: `src/v2/lib/index.ts` re-exports every primitive — update it when a new primitive ships. Composites are imported directly by path; no barrel. No single-file `index.ts` that just re-exports to shorten a path.
|
||||
|
||||
### SFC structure
|
||||
|
||||
- `<script setup lang="ts">` always.
|
||||
- `defineOptions({ inheritAttrs: false })` on every wrapper, paired with `v-bind="$attrs"` and slot passthrough (without the bind, attrs vanish silently).
|
||||
- Props via `defineProps<Props>()` (interface), never runtime declarations. Emits via `defineEmits<{...}>()`. Slots with payload via `defineSlots<{}>()`.
|
||||
- Order: `<script setup>` → `<template>` → `<style scoped>`. Unscoped `<style>` (teleport overrides only) goes after the scoped block.
|
||||
|
||||
### Import order & aliases
|
||||
|
||||
```ts
|
||||
// 1. External
|
||||
import { computed, ref } from "vue";
|
||||
// 2. v2 primitives
|
||||
import { RBtn, RDialog } from "@v2/lib";
|
||||
// 3. v2 composables / shared
|
||||
import { useCan } from "@/v2/composables/useCan";
|
||||
// 4. v2 feature siblings
|
||||
import GameCard from "@/v2/components/GameCard.vue";
|
||||
// 5. Canonical shared resources
|
||||
import storeAuth from "@/stores/auth";
|
||||
import type { SimpleRom } from "@/__generated__";
|
||||
```
|
||||
|
||||
- `@v2/lib` — primitives barrel. `@/v2/...` — anything else under v2. `@/...` — canonical shared resources. Never relative paths (`../../foo`) when an alias exists.
|
||||
- Shared v2 types live in `src/v2/types/`; backend types come from `src/__generated__/`; not `src/types/` (legacy).
|
||||
|
||||
### Composables
|
||||
|
||||
- `use` prefix; single named export from `composables/useFoo/index.ts`; fully typed args/return; no side effects on module load (init on first call). Creating a v2-only composable when a v1 equivalent exists is allowed.
|
||||
|
||||
### Console logging
|
||||
|
||||
- `console.error` allowed for production-visible errors. `console.log`/`console.warn` must not ship. `console.debug` is dev-only — remove before PR.
|
||||
|
||||
---
|
||||
|
||||
## Storybook (mandatory for `/lib`)
|
||||
|
||||
- Every primitive ships at least one story with controls and at least one variant per theme.
|
||||
- A new interactive primitive that warrants gamepad navigation ships a `play()` interaction.
|
||||
- Modified primitive: existing story must still render and its interactions still pass.
|
||||
- `npm run test` runs Vitest **and** every `/lib` story's `play()` via `composeStories`. Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (beyond what the premises already say)
|
||||
|
||||
1. Don't change shared store APIs to work around a v2 call-site issue. (Fix the call site; the Gallery lesson was calling `romsStore.reset()` from the view, not adding `_fetchSeq` to the store.)
|
||||
2. Don't drop to inline role checks — always go through `useCan` (see `frontend-v2-patterns`).
|
||||
3. Don't reinvent a surface — dialog/menu/popover/card all go through their primitive; special cases become a new prop, not a parallel surface.
|
||||
4. Don't use `v-form` directly — use `RForm`.
|
||||
5. Don't add backwards-compat shims inside v2: delete removed code; no `// removed`, no renamed-but-unused exports, no deprecated wrappers that just call the new function.
|
||||
6. Don't write redundant tests; don't touch v1; never `--no-verify` on commits.
|
||||
|
||||
**Allowed (often misread):** modifying shared stores/services/utils *additively*; creating v2-only composables; importing from `src/__generated__/`.
|
||||
|
||||
---
|
||||
|
||||
## Known debt (focused follow-ups)
|
||||
|
||||
- **Virtualisation migration** — `RVirtualScroller` (`src/v2/lib/structural/`) needs to absorb `GameGrid`/`LetterGroupedGrid` (structural refactor of `Platform.vue`/`Search.vue`/`Collection.vue`); `useLetterGroups` must become index-based for AlphaStrip scroll-spy.
|
||||
- **`useGalleryFilterUrl`** — sync `galleryFilter` store fields to URL query params for bookmarkable links; mark v1 store usage `@deprecated`.
|
||||
- **Vue Router scroll restoration** — galleries scroll custom containers (`.r-v2-plat__scroll`), not window; add a Pinia `routeFullPath → offsetTop` map with per-view hooks. Bundle with the virtualisation migration.
|
||||
- **`useSocketEvent` composable** — typed socket subscriptions with mount/unmount cleanup (consumers currently wire `socket.on/off` by hand).
|
||||
- **When v1 dies**: move `uiVersion` into `UI_SETTINGS_KEYS`; drop `.r-v2-*` scope classes (tokens move to `:root`); simplify `useUISettings` sync; delete `useGameAnimation`; drop the color-string→tone collapser in `NotificationHost`; remove the Vuetify rule arrays in `stores/users.ts`.
|
||||
|
||||
Full reference: `docs/FRONTEND_ARCHITECTURE.md`.
|
||||
60
.claude/skills/frontend-v2-input/SKILL.md
Normal file
60
.claude/skills/frontend-v2-input/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: frontend-v2-input
|
||||
description: Universal input (mouse, touch, keyboard, gamepad) and responsive/universal-viewport layout in the RomM v2 frontend. Use when adding interactive v2 components, focus management, spatial navigation, gamepad/keyboard handling, modality-gated focus rings, breakpoints, or responsive layout. Covers useInput, focus geometry primitives, the overlay scope stack, useBreakpoint, and the data-bp/data-input attributes. Trigger on interactive or responsive work under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM v2 — Universal Input & Universal Viewport
|
||||
|
||||
**Premise:** all v2 UI works with mouse, touch, keyboard, **and** gamepad; and every surface reads cleanly from a 320px phone to a 4K display. Both mechanisms are fixed — don't invent a parallel one.
|
||||
|
||||
The input system lives in `src/v2/composables/useInput/` (bus, keyboard, gamepad, actions, scope). It generalises the original gamepad-only `src/console/` system. There are **no `/console/*` routes in v2**.
|
||||
|
||||
---
|
||||
|
||||
## Modality
|
||||
|
||||
`useInputModality` sets `data-input="mouse|touch|key|pad"` on `<html>` from the most recent input.
|
||||
|
||||
- **Focus rings appear only with `key` and `pad`** (CSS in `global.css`). **Never use bare `:focus` in styles** — use the modality-gated selectors, or focus rings flash on mouse click.
|
||||
- Hit targets may scale up for `touch` and `pad`.
|
||||
|
||||
## Coverage — every interactive primitive participates
|
||||
|
||||
Buttons, list items, tabs, menu items, focusable cards, toggleable chips — all participate in spatial navigation (not optional). A new interactive primitive must:
|
||||
|
||||
- be focusable (proper `tabindex`, or already so via a wrapped Vuetify component);
|
||||
- react to logical actions (confirm/cancel) from `useInput`, in addition to native click;
|
||||
- show a modality-gated focus state.
|
||||
|
||||
Storybook `play()` covering gamepad input is required **only when applicable** (the primitive is interactive enough that gamepad navigation matters).
|
||||
|
||||
## Focus geometry
|
||||
|
||||
Each view declares its layout with focus primitives: `RFocusZone`, `RFocusGrid`, `RFocusRow`, `RFocusColumn`. Multiple regions = multiple zones. Predictable up/down/left/right movement is the view's responsibility.
|
||||
|
||||
## Element-level global shortcuts
|
||||
|
||||
Above per-view geometry, some elements bind globally regardless of focus location:
|
||||
|
||||
- `UserMenu` opens on **Start**.
|
||||
- Navbar tabs cycle with **LB/RB** (plus D-pad).
|
||||
- Context menu opens on **X or Y**.
|
||||
|
||||
## Scope (overlay stack)
|
||||
|
||||
When a dialog opens, push a scope; when it closes, pop. This stops Escape from closing two things at once and stops `confirm` leaking to controls beneath an overlay. `RDialog` and `RMenu` manage their scope automatically — custom overlays are an anti-pattern; go through the primitives.
|
||||
|
||||
---
|
||||
|
||||
## Responsive layout (universal viewport)
|
||||
|
||||
- **Single breakpoint source: `useBreakpoint`** (`src/v2/composables/useBreakpoint/`). Material thresholds — `xs <600`, `sm 600–959`, `md 960–1279`, `lg 1280–1919`, `xl ≥1920`. `installBreakpointAttribute()` (mounted once in `AppLayout`) mirrors the active set onto `<html data-bp="…">`.
|
||||
- **Layout switches live in CSS** via the attribute selector: `html[data-bp~="xs"] .foo { … }`, `html[data-bp~="sm-and-down"] .foo { … }`. **No raw `@media` for layout** — the only allowed `@media` are `prefers-reduced-motion` and print. The attribute is on `<html>` so it reaches teleported overlays.
|
||||
- **Conditional rendering** (mount/unmount a different component per tier) uses the `useBreakpoint()` refs in `<script>` — e.g. `v-if="xs"`. Prefer mount-gating over `display:none` for focusable chrome so hidden controls never sit in the tab/spatial-nav order.
|
||||
- **`--r-row-pad` is the global horizontal gutter**, already re-scoped responsive in `global.css` (36 → 20 → 14px). Consume `var(--r-row-pad)`; don't hard-code a smaller `xs` padding per component.
|
||||
- **Touch targets** ≥ `--r-touch-target` (44px) on `xs`/touch. Gate the bump to touch/pad where desktop sizing would bloat.
|
||||
- **Overlays go full-bleed on `xs`.** `RDialog` renders full-screen / bottom-sheet on phones (`fullscreenOnMobile`, default on); `RMenu` bottom-sheets large menus. Never float a 600px dialog on a 360px screen.
|
||||
- **Label→icon collapse** (the AppNav precedent) is the canonical way to compress chrome; the four primary destinations relocate to `BottomNav` on `sm-and-down`.
|
||||
- **Grids** size via `useResponsiveColumns` (ResizeObserver), never a fixed column count.
|
||||
|
||||
Verification adds a breakpoint sweep — see `pre-pr-verification`.
|
||||
89
.claude/skills/frontend-v2-patterns/SKILL.md
Normal file
89
.claude/skills/frontend-v2-patterns/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: frontend-v2-patterns
|
||||
description: Cross-cutting feature patterns for the RomM v2 frontend — error/snackbar handling, loading & skeleton states, real-time Socket.IO updates, UI state persistence (URL vs localStorage vs ephemeral), pagination/infinite scroll, forms & validation, permissions (useCan), and destructive confirmations. Use when wiring up a v2 feature's behavior (not just its markup). Trigger when implementing data flows, dialogs, forms, toggles, or permission gating under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM v2 — Architecture Patterns
|
||||
|
||||
How v2 features behave. Each pattern has one canonical mechanism — don't invent a parallel one.
|
||||
|
||||
---
|
||||
|
||||
## A. Errors & snackbars
|
||||
|
||||
- Single channel: `useSnackbar()` (`src/v2/composables/useSnackbar/`) with `success | error | warning | info` methods. It emits `snackbarShow`; `NotificationHost` stacks toasts.
|
||||
- The **call site** decides what's significant — no global "wrap-every-promise" magic.
|
||||
- Field validation errors render **in-place**, never as a snackbar.
|
||||
- Auth (401/403) is handled by the axios interceptor; no per-call-site checks.
|
||||
- Successful critical actions → `success` snackbar. Routine optimistic toggles → silent on success, `error` on failure.
|
||||
- Don't snackbar every rejected promise.
|
||||
|
||||
## B. Loading states
|
||||
|
||||
- **Skeleton** (`RSkeletonBlock`) for first load of a view with known layout — mimic the real shape so the layout doesn't jump.
|
||||
- **Inline `:loading` on the control itself** for in-flight actions (`RBtn`, `RTextField`, `RSelect`). Never put an external `RSpinner` next to a button that has its own `loading`.
|
||||
- **`RSpinner` inline** when what's loading isn't a control with native `loading`.
|
||||
- **Determinate progress (%)**: use `RProgressLinear` — no raw `v-progress-linear`.
|
||||
- **Empty state ≠ loading state.** Zero items is its own UX (message, illustration, optional CTA).
|
||||
- **Optimistic toggles show no spinner**: flip immediately; on failure, revert + snackbar.
|
||||
- `RBtn` ships `loadingDebounce={200}` — actions resolving under 200ms never paint a spinner; loading→not-loading is immediate.
|
||||
|
||||
## C. Real-time updates (Socket.IO)
|
||||
|
||||
- One instance: `src/services/socket.ts`. Never `new io()`.
|
||||
- New consumers go through (or build) a `useSocketEvent(event, handler)` composable for typed subscriptions with automatic mount/unmount cleanup (this composable is still debt — today consumers wire `socket.on/off` by hand).
|
||||
- **Ownership rule:** state living only while a view is open → subscribe in the view; state that must outlive a view (e.g. scan badge in navbar) → a Pinia store subscribes globally and views just read.
|
||||
- Reconnection is socket.io's job — don't roll your own.
|
||||
|
||||
## D. UI state persistence — three layers
|
||||
|
||||
1. **Persistent preferences** (theme, language, gallery defaults like `groupRoms`/`boxartStyle`, Home panels) → `useUISettings` (localStorage + backend `user.ui_settings` two-way sync). Add a key to `UI_SETTINGS_KEYS`.
|
||||
2. **Bookmarkable session state** (active filters, search query, sort, current tab in detail views) → **URL query params**. Anyone copying the link reproduces what they see. **Active gallery filter must be in URL.**
|
||||
3. **Ephemeral session state** (open dialog, hover, expansion) → `ref` if local, Pinia store if cross-component within the session.
|
||||
|
||||
Don't push state into `useUISettings` "so it persists" — follow the rule above.
|
||||
|
||||
## E. Pagination & infinite scroll
|
||||
|
||||
- `LoadMore` (`RBtn` + `RSpinner` + IntersectionObserver) is the canonical fallback when virtualization stalls.
|
||||
- `RVirtualScroller` (`src/v2/lib/structural/`, wrapping `v-virtual-scroll`) is the substrate for large lists/grids.
|
||||
- Page size lives in the store (`fetchLimit`); not user-configurable for now.
|
||||
- **Scroll restoration** on back-nav: Vue Router `scrollBehavior` + Pinia in-session offset. URL holds filters/sort/search but **not** scroll offset.
|
||||
|
||||
## F. Forms & validation
|
||||
|
||||
- Use the **`RForm` primitive** (wraps `v-form`: Enter-to-submit when valid, scroll-to-first-error after a failed `validate()`). **Never use `v-form` directly.**
|
||||
- **Native Vuetify rules** — no Zod/Yup. Rules are arrays of `(v) => true | string`.
|
||||
- **Reusable rules** in `src/v2/utils/validation.ts` (`required(msg?)`, `email`, `asciiOnly`, `lengthBetween`, `usernameLength/Chars`, `passwordLength`). Utility code *may* call `i18n.global.t(...)` (the no-i18n rule covers lib primitives, not utils).
|
||||
- **Submit pattern:** `await formRef.value?.validate()` before the API call; submit button uses `:loading="submitting"`; errors → snackbar; field errors stay in-place via `:error-messages`.
|
||||
|
||||
## G. Permissions
|
||||
|
||||
- Action vocabulary `domain.action` (`rom.upload`, `rom.delete`, `library.scan`, `user.create`, `app.admin`) in `src/v2/composables/useCan/actions.ts`.
|
||||
- Scope vocabulary:
|
||||
```ts
|
||||
type PermissionScope =
|
||||
| { kind: "global" }
|
||||
| { kind: "platform"; id: number }
|
||||
| { kind: "collection"; id: number }
|
||||
| { kind: "rom"; id: number };
|
||||
```
|
||||
- **`useCan(action, scope?)`** returns `ComputedRef<boolean>`, reactive to `permissionsStore.grants`. Without scope: "can do this anywhere."
|
||||
- `stores/permissions.ts` holds normalised grants, hydrated from `authStore.user.role` via the role-map (`installPermissionsHydration()` in `AppLayout`); a future `/permissions/me` will replace it.
|
||||
- **`v-if`** to hide options a user shouldn't see; **`:disabled`** with tooltip when the option must be visible but blocked.
|
||||
- **Backend is source of truth** — frontend is a UX hint. Never bypass with inline `user.role === "..."`. All grants are pre-loaded (no `useCanAsync`).
|
||||
|
||||
## H. Destructive confirmations
|
||||
|
||||
Three friction levels:
|
||||
|
||||
- **Low / High** → shared composite `ConfirmDialog` (`components/shared/`) opened via `useConfirm({ title, body, confirmText, tone, requireTyped }) => Promise<boolean>` (mounted once in `GlobalDialogs`).
|
||||
- **Medium** → a feature composite when the flow needs extra options (e.g. `DeleteRomDialog` with per-item filesystem checkboxes).
|
||||
|
||||
Common rules:
|
||||
|
||||
- All destruction goes through a dialog — no silent destructive action.
|
||||
- Confirm button is danger-toned; **focus starts on Cancel**; Enter cancels.
|
||||
- Success → success snackbar or navigate away, dialog closes. Error → error snackbar, dialog stays open. During action → confirm shows `:loading`, cancel disabled.
|
||||
- The destructive control respects `useCan(action, scope)`.
|
||||
- **No "don't ask again."** **Type-to-confirm (`requireTyped`) is required when the action affects the filesystem.**
|
||||
79
.claude/skills/frontend-v2-theming/SKILL.md
Normal file
79
.claude/skills/frontend-v2-theming/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: frontend-v2-theming
|
||||
description: Theming, design tokens, colors, and visual language in the RomM v2 frontend. Use when styling v2 components, picking colors, adding/using CSS variables, working with light/dark themes, or whenever you'd reach for a hex/rgba literal. Covers the token pipeline (src/v2/tokens/index.ts → build:tokens → tokens.css), the .r-v2 scope classes, the zero-hex-literal policy, and shared state semantics. Trigger on any color/theme/token work under frontend/src/v2/.
|
||||
---
|
||||
|
||||
# RomM v2 — Tokens, Theming & Visual Language
|
||||
|
||||
**Tokens are the only source of truth for theming. Zero hex/`rgba()` literals in v2 components.** If a value is missing, add a token. Every component must work in both `v2-dark` and `v2-light`.
|
||||
|
||||
---
|
||||
|
||||
## Token pipeline
|
||||
|
||||
`src/v2/tokens/index.ts` is the source. It feeds two consumers:
|
||||
|
||||
- **`src/v2/styles/tokens.css`** — *generated* by `scripts/build-tokens.ts` (`npm run build:tokens`, hooked into `predev`/`prebuild`). **Do not hand-edit.** This is how the vast majority of tokens are consumed: `var(--r-color-...)` in CSS.
|
||||
- **Direct JS/TS imports** of named exports (`colorCanvas`, `colorCoverArt`, `layout`, …) for the few cases needing a token value in JavaScript — baking colors into an SVG string (`utils/covers`), canvas/QR backgrounds (`Player/Ruffle.vue`, `ShowQRCodeDialog`), and the virtualiser's pixel math (`Gallery/listColumns` reading `layout`).
|
||||
|
||||
v2 has **no Vuetify theme of its own.** `tokens.css` emits a palette block per theme under `.r-v2.r-v2-dark` / `.r-v2.r-v2-light`; `RomM.vue` toggles those classes on `<html>`. v2 surfaces never read Vuetify's runtime theme.
|
||||
|
||||
> Caveat: a wrapped Vuetify component still resolves `color="primary"` against Vuetify's *own* registered themes (`src/plugins/vuetify.ts`, sourced from v1's `@/styles/themes`). They mirror the brand tokens by hand (both `#8B74E8`), so they line up, but it's a parallel source. **Prefer `var(--r-...)` over the `color` prop.**
|
||||
|
||||
### Adding a new token
|
||||
|
||||
1. Add it to `src/v2/tokens/index.ts` with a **semantic, role-based** name (`--r-color-danger`, not `--r-color-red`).
|
||||
2. Provide **both** dark and light values.
|
||||
3. Consume via `var(--r-...)` (CSS) or the named export (JS).
|
||||
4. Run `npm run build:tokens` to regenerate `tokens.css`.
|
||||
5. If the JS→CSS variable name needs an exception (e.g. `--r-nav-h`), add an entry to `NAME_OVERRIDES` in the generator.
|
||||
|
||||
### Where the scope classes live — and why `<html>`
|
||||
|
||||
`.r-v2`, `.r-v2-dark`, `.r-v2-light` go on `<html>` (`RomM.vue` toggles them whenever `uiVersion` or the active theme changes). Vuetify teleports overlays (`VDialog`, `VMenu`, `VTooltip`) to `<body> > .v-overlay-container`, **outside** `<v-app>`. Only `<html>` covers both the regular tree and the teleports — without it, overlays lose their tokens.
|
||||
|
||||
### Diagnostics — when `var(--r-color-...)` resolves to nothing on an overlay
|
||||
|
||||
1. Check `RomM.vue`'s watch on `documentElement.classList` (load-bearing).
|
||||
2. Check that the teleported component carries a `content-class` tying it back to scope (e.g. `RDialog` uses `content-class="r-dialog"`).
|
||||
3. **Never** "fix" it by swapping the token for a hex literal — that hides the bug and breaks the dual theme.
|
||||
|
||||
---
|
||||
|
||||
## Visual language
|
||||
|
||||
1. **Single visual vocabulary.** Every surface (dialog, menu, popover, card, toolbar) reads as a sibling — same blur, curvature, depth. No standalone "dialog look" vs "menu look".
|
||||
2. **Canonical references when designing**: ask the user before consulting `https://mockup.thebirdcage.tv/`. They decide whether the mockup or existing primitives take priority.
|
||||
3. **State semantics are shared across primitives** (don't reinvent per component):
|
||||
- hover (neutral, or brand-tinted on selected rows)
|
||||
- selected/checked (`--r-color-brand-primary`)
|
||||
- active/favorite (`--r-color-fav`)
|
||||
- focus (modality-gated; visible only on `key`/`pad` — see `frontend-v2-input`)
|
||||
- busy/pending · disabled
|
||||
4. **Implementation gotchas:**
|
||||
- `RDialog` ships unscoped `<style>` at the bottom that strips Vuetify defaults from `.v-overlay__content`. Load-bearing — without it the `--r-radius-card` corners disappear.
|
||||
- Every dialog goes through `RDialog`; every menu through `RMenu`.
|
||||
|
||||
---
|
||||
|
||||
## Color-literal policy: zero exceptions
|
||||
|
||||
Outside `src/v2/tokens/index.ts` (the source-of-truth TS module) and the generated `src/v2/styles/tokens.css`, **no hex or `rgba()` literals exist anywhere in v2.** Everything previously "excepted" is now a token or a `color-mix`:
|
||||
|
||||
- Cover-overlay glass → `--r-color-overlay-*` (fixed dark glass; never theme-flips).
|
||||
- Cover artwork placeholder & shimmer → `--r-color-cover-placeholder`, `--r-color-cover-placeholder-bright`.
|
||||
- Panel / tooltip / shimmer-sweep → `--r-color-panel`, `--r-color-panel-border`, `--r-color-tooltip-bg`, `--r-color-shimmer-sweep`.
|
||||
- Backdrop scrims (`global.css`) → `color-mix(in srgb, var(--r-color-bg) X%, transparent)`.
|
||||
- Status tints → `color-mix(in srgb, var(--r-color-status-base-{success,warning,danger,info}) X%, transparent)`.
|
||||
- Brand-tinted backgrounds (selected rows, focus rings) → `color-mix(in srgb, var(--r-color-brand-primary) X%, transparent)`.
|
||||
- Black/white shadows → `color-mix(in srgb, black X%, transparent)` (CSS named color, not a hex literal).
|
||||
- Metadata-provider chips → `--r-color-provider-*`. Player canvas → `--r-color-canvas-bg`, `--r-color-canvas-bg-deep`.
|
||||
- Emphasis pill (always-white-on-dark "Play" CTA over cover art) → `--r-color-overlay-emphasis-bg/-fg/-bg-hover`.
|
||||
|
||||
If a literal would otherwise be needed, the answer is: **add a token** (steps above), then consume via `var(--r-color-...)` or the named export.
|
||||
|
||||
## Style conventions
|
||||
|
||||
- Scoped `<style>` by default; unscoped only for teleport overrides.
|
||||
- BEM-ish class names: `.feature__element--modifier`. Prefixes: `.r-v2-...` for app-shell surfaces outside components; `.r-...` for globally shared utilities/tokens.
|
||||
- No plain CSS where a Vuetify utility class covers the case (`d-flex`, `pa-4`, `align-center`).
|
||||
65
.claude/skills/pre-pr-verification/SKILL.md
Normal file
65
.claude/skills/pre-pr-verification/SKILL.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: pre-pr-verification
|
||||
description: The before-handoff / before-PR verification gate for RomM, covering both stacks. Use right before committing, opening a PR, or telling the user a change is done — to run the right static checks, tests, and (for UI) manual browser/theme/input/Storybook checks so CI stays green. Covers frontend (typecheck/lint/test/build/i18n/tokens), backend (pytest/alembic/trunk), and the OpenAPI regen step. Trigger when wrapping up any change.
|
||||
---
|
||||
|
||||
# RomM — Verification Before Handoff
|
||||
|
||||
Run the checks that match what you touched. **Static checks don't prove a feature works** — when UI changed, also test it in the browser. Mirror the CI gates so review isn't the first place a failure shows up. **Never `--no-verify`.**
|
||||
|
||||
---
|
||||
|
||||
## Frontend (`frontend/`)
|
||||
|
||||
Run from `frontend/`:
|
||||
|
||||
1. `npm run typecheck` — zero errors (`vue-tsc --noEmit`).
|
||||
2. `npm run lint` *(if present)* / ESLint clean. Trunk also runs ESLint + Prettier in CI.
|
||||
3. `npm run test` — zero failures (Vitest + happy-dom; runs unit tests **and** every `/lib` story's `play()` via `composeStories`).
|
||||
4. `npm run build` — zero failures (CI sanity check).
|
||||
|
||||
**If you touched the backend API:** start the backend, run `npm run generate`, then re-`typecheck`.
|
||||
|
||||
**If you touched tokens** (`src/v2/tokens/index.ts`): `npm run build:tokens` (also auto-runs on `predev`/`prebuild`) and confirm `tokens.css` regenerated.
|
||||
|
||||
**If you touched locales** (`src/locales/**`): `python3 frontend/src/locales/check_i18n_locales.py` must pass with zero missing/extra keys. See the `frontend-i18n` skill.
|
||||
|
||||
### UI manual pass (when changes are visible) — v2
|
||||
|
||||
With `uiVersion = "v2"`:
|
||||
|
||||
- **Golden path + edge cases:** empty, error, loading, no-permission, extreme data; plus nearby regressions.
|
||||
- **Both themes:** `v2-dark` and `v2-light`.
|
||||
- **All four input modalities:** mouse, touch, keyboard, gamepad — focus ring only on `key`/`pad`.
|
||||
- **Responsive sweep:** 320px → 4K across the `useBreakpoint` tiers; overlays full-bleed on `xs`.
|
||||
- **Accessibility:** contrast, keyboard reachability with no traps, aria-labels on icon-only controls.
|
||||
- **Performance:** lists/grids of 1000+ items stay smooth; every `v-for` has a stable `:key`.
|
||||
|
||||
### Storybook (for `/lib`)
|
||||
|
||||
- New primitive → mandatory story with controls + at least one variant per theme; interactive ones get a `play()`.
|
||||
- Modified primitive → existing story still renders and interactions still pass.
|
||||
- Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
|
||||
|
||||
---
|
||||
|
||||
## Backend (`backend/`)
|
||||
|
||||
Run from `backend/`:
|
||||
|
||||
1. `uv run pytest [path/file]` — zero failures (run the affected subset, or all with `-vv`).
|
||||
2. `trunk fmt && trunk check` — ruff/black/isort/mypy/bandit clean (CI enforces Trunk).
|
||||
3. **If you added a migration:** `uv run alembic upgrade head` then `uv run alembic downgrade -1` to prove both directions; it must work on MariaDB **and** PostgreSQL (CI runs both).
|
||||
4. **If a response schema or route signature changed:** regenerate frontend types (`npm run generate`) and typecheck the frontend.
|
||||
|
||||
---
|
||||
|
||||
## CI gates this mirrors
|
||||
|
||||
`typecheck.yml` (vue-tsc + lockfile lint), `frontend.yml` (vitest + build), `i18n.yml` (locale check), `pytest.yml` (pytest on MariaDB + PostgreSQL), `migrations.yml` (alembic on both DBs), `trunk-check.yml` (Trunk across the repo). Green locally → green in CI.
|
||||
|
||||
## Don't
|
||||
|
||||
- Open a PR without manually testing the UI when UI was touched.
|
||||
- `--no-verify` on commits.
|
||||
- Leave a locale key English-only, a token un-generated, or a migration one-directional.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -71,4 +71,6 @@ switch_titledb.json
|
||||
switch_product_ids.json
|
||||
|
||||
# AI tools
|
||||
.claude
|
||||
# Keep shared agent skills tracked; ignore personal/local Claude config.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
|
||||
518
CLAUDE.md
518
CLAUDE.md
@@ -1,481 +1,93 @@
|
||||
# RomM Frontend v2 — Constitution
|
||||
# RomM — Repository Guide for Contributors & Agents
|
||||
|
||||
This file governs work inside `frontend/src/v2/`. It is the canonical source of v2 working rules. Read it before editing v2 code.
|
||||
RomM is a self-hosted ROM manager and player: scan a game library off disk, enrich it with metadata from 10+ providers, browse it in a web UI, and play in the browser. This file orients anyone working in the repo — humans and AI agents alike. **Many features here are written by outside contributors using coding agents**, so this guide is written to be actioned without prior context.
|
||||
|
||||
> **Official language**: code, comments, identifiers, .md files, commit/PR messages — English. Always.
|
||||
|
||||
Full migration plan: `/home/ymir/.claude/plans/i-want-to-create-majestic-kazoo.md`.
|
||||
> **Official language:** all code, comments, identifiers, `.md` files, and commit/PR messages are in **English**. Always.
|
||||
|
||||
---
|
||||
|
||||
## I. Premises
|
||||
## The stack at a glance
|
||||
|
||||
These are stable. They change only through explicit redesign.
|
||||
| | Backend | Frontend |
|
||||
| --- | --- | --- |
|
||||
| Path | `backend/` | `frontend/` |
|
||||
| Language | Python 3.13+ | TypeScript 5.7 (Vue 3) |
|
||||
| Framework | FastAPI, SQLAlchemy 2.0, Alembic | Vue 3 + Vite, Vuetify, Pinia, Vue Router |
|
||||
| Infra | Redis + RQ (jobs/cache/sessions), Socket.IO | vue-i18n, Socket.IO client |
|
||||
| DB | MariaDB (default), MySQL, PostgreSQL | — |
|
||||
| Tooling | `uv`, pytest, Trunk (ruff/black/isort/mypy) | `npm`, vue-tsc, ESLint, Vitest, Storybook |
|
||||
|
||||
1. **v1 is frozen.** v2 lives under `src/v2/`, gated by `user.ui_settings.uiVersion`. v1 (`src/views/`, `src/components/`, `src/console/`, `src/layouts/`) is not refactored — it will be deleted wholesale in the final wave.
|
||||
2. **Three component tiers** (see §II). Primitives in `src/v2/lib/`, shared composites in `src/v2/components/shared/`, feature composites in `src/v2/components/<feature>/`.
|
||||
3. **Shared resources are canonical.** Pinia stores, API services, OpenAPI types, locales, utils — v2 imports them; v2 does not fork them. Additive changes are allowed when v1 still uses them.
|
||||
4. **Tokens are the only source of truth for theming.** No hex literals anywhere. If a value is missing a token, create the token.
|
||||
5. **Dual theme is mandatory.** Every component must work in `v2-dark` and `v2-light`.
|
||||
6. **Universal input.** All v2 UI works with mouse, touch, keyboard, and gamepad.
|
||||
7. **Wrapper contract.** Wrappers around Vuetify components use `defineOptions({ inheritAttrs: false })` + `v-bind="$attrs"` + slot passthrough. They accept every prop/slot of the wrapped component.
|
||||
8. **Universal substitution.** When an `R*` exists, it is used. If it doesn't exist, it is created or extended. Never drop to raw HTML elements or raw Vuetify when a primitive applies.
|
||||
9. **Layout via Vuetify utility classes + our wrappers, not plain CSS.** Utility classes (`d-flex`, `pa-4`, `align-center`) are used directly. Vuetify's layout components (`v-row`, `v-col`, `v-container`, `v-spacer`, `v-app`, `v-main`) are wrapped as `R*` when used as components — wrap on first use (lazy).
|
||||
10. **Storybook is mandatory for `/lib`.** Every primitive ships at least one story with controls.
|
||||
11. **TypeScript strict.** Zero `any` (justified with comment otherwise). No `as unknown as ...` workarounds.
|
||||
12. **Accessibility.** Semantic HTML, focus management, contrast, ARIA where needed.
|
||||
13. **Performance.** Lazy-load heavy views. Virtualize large lists. Avoid unnecessary watchers/computeds. `v-for` always has a stable `:key`.
|
||||
The frontend talks to the backend over `/api/*` (REST) and `/ws` (Socket.IO). TypeScript types are **generated** from the backend's OpenAPI schema into `frontend/src/__generated__/` — the backend is the single source of truth for API shapes.
|
||||
|
||||
### Deep-dive references
|
||||
|
||||
- **`docs/BACKEND_ARCHITECTURE.md`** — directory map, ER diagram, every endpoint, auth/scopes, tasks.
|
||||
- **`docs/FRONTEND_ARCHITECTURE.md`** — routing, stores, services, theming, build tooling.
|
||||
- **`DEVELOPER_SETUP.md`** — Docker and manual local setup (mock library, `.env`, services).
|
||||
- **`CONTRIBUTING.md`** — contribution flow, **AI-assistance disclosure**, translations.
|
||||
|
||||
---
|
||||
|
||||
## II. lib vs components — three tiers
|
||||
## The frontend has two UIs — know which you're in
|
||||
|
||||
| Tier | Path | Prefix | Stores / services / router / emitter / i18n | Story | Domain knowledge |
|
||||
| --------------------- | ------------------------------ | -------------- | ------------------------------------------- | --------- | --------------------------------- |
|
||||
| **Primitive** | `src/v2/lib/` | `R*` mandatory | **No** | Mandatory | None |
|
||||
| **Shared composite** | `src/v2/components/shared/` | no prefix | Yes | Optional | Cross-feature, no specific domain |
|
||||
| **Feature composite** | `src/v2/components/<feature>/` | no prefix | Yes | Optional | Feature-specific |
|
||||
- **v1 is frozen.** Everything under `frontend/src/views/`, `src/components/`, `src/console/`, `src/layouts/` is legacy and will be deleted wholesale in a final wave. **Do not refactor v1.** Only touch it for a critical bug, and when a v2 fork exists, mark the v1 export `@deprecated`.
|
||||
- **v2 is the active rewrite** under `frontend/src/v2/`, gated by `user.ui_settings.uiVersion`. It has its own design system (tokens), primitive library (`R*` components in `src/v2/lib/`), universal input (mouse/touch/keyboard/gamepad), and responsive system. New frontend work goes in v2.
|
||||
|
||||
### Decision criteria for primitive
|
||||
|
||||
A component is a primitive only if **all three** hold:
|
||||
|
||||
1. Does not depend on stores, services, router, or emitter.
|
||||
2. No knowledge of a product domain (ROM, Platform, Collection, User…). `RAvatar` yes, `UserAvatar` no.
|
||||
3. API can be described without naming features. Generic props/slots/events.
|
||||
|
||||
If any criterion fails: **shared composite** if it is generic across features, **feature composite** if it is owned by one feature.
|
||||
|
||||
### Primitive boundaries
|
||||
|
||||
- **Can use**: tokens, other primitives, Vue/Vuetify, generic composables (`useInput*`, `useFocus*`).
|
||||
- **Cannot use**: Pinia stores, API services, `emitter`, `router` (except `RouterLink` accepted as a prop), `i18n` directly.
|
||||
- **No `$t()` in primitives.** Text is always passed via props or slots.
|
||||
|
||||
### Folder structure
|
||||
|
||||
- Primitive: `RFoo/RFoo.vue`, `RFoo/RFoo.stories.ts`, `RFoo/index.ts`, optional `RFoo/types.ts`.
|
||||
- Composite: flat `.vue` if a single file is enough; folder with the same internal structure (no story required) if it has sub-pieces.
|
||||
|
||||
### Barrel
|
||||
|
||||
- `src/v2/lib/index.ts` re-exports every primitive. Update when a new primitive ships.
|
||||
- Composites are imported directly by path; no barrel.
|
||||
|
||||
### Promotion / demotion
|
||||
|
||||
If a component meets the three primitive criteria, it stays primitive regardless of consumer count. Edge cases get raised to the user, not auto-decided.
|
||||
v2 has a detailed constitution, split across focused skills (below). **Read the relevant skill before editing v2 code.**
|
||||
|
||||
---
|
||||
|
||||
## III. Visual language
|
||||
## Skills — load the focused guide for your task
|
||||
|
||||
1. **Single visual vocabulary.** Every surface (dialog, menu, popover, card, toolbar) reads as a sibling — same blur, same curvature, same depth. No standalone "dialog look" vs "menu look".
|
||||
2. **Canonical references when designing**: ask the user before consulting `https://mockup.thebirdcage.tv/`. They decide whether the mockup or existing primitives (`RDialog`, `RMenuPanel`, …) take priority.
|
||||
3. **Tokens drive everything** (premise 4). If a value isn't in tokens, create the token.
|
||||
4. **State semantics are shared across primitives**:
|
||||
- hover (neutral, or brand-tinted on selected rows)
|
||||
- selected/checked (`--r-color-brand-primary`)
|
||||
- active/favorite (`--r-color-fav`)
|
||||
- focus (modality-gated; visible only on `key`/`pad`)
|
||||
- busy/pending
|
||||
- disabled
|
||||
The set may grow; it does not get reinvented per component.
|
||||
5. **Implementation gotchas worth keeping**:
|
||||
- `RDialog` ships unscoped `<style>` at the bottom that strips Vuetify defaults from `.v-overlay__content`. Load-bearing — without it the corners of `--r-radius-card` disappear.
|
||||
- Every dialog goes through `RDialog`. Every menu through `RMenu`.
|
||||
These live in `.claude/skills/` and carry the detailed rules. Invoke the one that matches what you're doing:
|
||||
|
||||
| Skill | When |
|
||||
| --- | --- |
|
||||
| `frontend-v2-components` | Building/editing any v2 component — tiers (lib/shared/feature), file & SFC conventions, barrels, anti-patterns. |
|
||||
| `frontend-v2-theming` | Colors, tokens, light/dark themes, visual language — and the **zero-hex-literal** policy. |
|
||||
| `frontend-v2-input` | Interactive components, focus/spatial navigation, gamepad/keyboard, breakpoints & responsive layout. |
|
||||
| `frontend-v2-patterns` | Feature behavior — errors/snackbars, loading, sockets, state persistence, pagination, forms, permissions, destructive confirmations. |
|
||||
| `frontend-i18n` | Any user-visible string or change under `frontend/src/locales/**`. |
|
||||
| `backend-development` | Endpoints, handlers, models, schemas, metadata adapters, tasks, migrations under `backend/`. |
|
||||
| `pre-pr-verification` | Before committing / opening a PR / declaring done — the checks that keep CI green. |
|
||||
|
||||
---
|
||||
|
||||
## IV. Tokens & theming
|
||||
## Repo-wide rules
|
||||
|
||||
### Source of truth
|
||||
|
||||
`src/v2/tokens/index.ts` is the source. It feeds two consumers:
|
||||
|
||||
- `src/v2/styles/tokens.css` — **generated** by `scripts/build-tokens.ts` (npm script `build:tokens`, hooked into `predev` and `prebuild`). Do not hand-edit. This is the path for the vast majority of consumption.
|
||||
- **Direct JS/TS imports** of the named exports (`colorCanvas`, `colorCoverArt`, `layout`, …) for the few cases that need a token value in JavaScript rather than CSS — baking colours into an SVG string (`utils/covers`), canvas/QR backgrounds (`Player/Ruffle.vue`, `ShowQRCodeDialog`), and the virtualiser's pixel math (`Gallery/listColumns` reading `layout`).
|
||||
|
||||
v2 has **no Vuetify theme of its own.** Theming is purely CSS-scope-based: `tokens.css` emits a palette block per theme under `.r-v2.r-v2-dark` / `.r-v2.r-v2-light`, and `RomM.vue` toggles those classes on `<html>` (see "Where scope classes live"). v2 surfaces do **not** read Vuetify's runtime theme.
|
||||
|
||||
Components consume tokens via `var(--r-color-...)` in CSS (the default) or by importing the named export in JS. **Never hex literal in components.**
|
||||
|
||||
Caveat — the one non-token path: a wrapped Vuetify component still resolves `color="primary"` against Vuetify's _own_ registered themes (`dark` / `light` in `src/plugins/vuetify.ts`, sourced from `@/styles/themes` — v1's). Those happen to mirror the brand tokens by hand (both use `#8B74E8` for primary), so they line up, but they are a parallel source, not derived from `tokens/index.ts`. Prefer styling with `var(--r-...)` over relying on the `color` prop.
|
||||
|
||||
### Adding a new token
|
||||
|
||||
1. Add to `src/v2/tokens/index.ts` with a semantic name (role-based: `--r-color-danger`, not `--r-color-red`).
|
||||
2. Provide both dark **and** light values (premise 5).
|
||||
3. Consume via `var(--r-...)` (CSS) or the named export (JS). v2 registers no Vuetify theme, so there is no `color`-prop theme to update — if a wrapped Vuetify component needs the value, style it with `var(--r-...)`.
|
||||
4. Run `npm run build:tokens` to regenerate `tokens.css`.
|
||||
5. If the JS path → CSS variable name needs an exception (special abbreviation like `--r-nav-h`), add an entry to `NAME_OVERRIDES` in the generator.
|
||||
|
||||
### Where scope classes live
|
||||
|
||||
`.r-v2`, `.r-v2-dark`, `.r-v2-light` go on `<html>`. `RomM.vue` toggles them on `document.documentElement` whenever `uiVersion` or the active theme changes.
|
||||
|
||||
Why `<html>` and not `<v-app>` or `AppLayout`: Vuetify teleports overlays (`VDialog`, `VMenu`, `VTooltip`) to `<body> > .v-overlay-container`, which is **outside** `<v-app>`. Only `<html>` covers both the regular tree and the teleports — without it, overlays lose their tokens.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
When `var(--r-color-...)` resolves to nothing on an overlay:
|
||||
|
||||
1. Check `RomM.vue`'s watch on `documentElement.classList`. Load-bearing.
|
||||
2. Check that the teleported component carries a `content-class` that ties it back to scope (e.g., `RDialog` uses `content-class="r-dialog"`).
|
||||
3. **Never** "fix" by replacing the token with a hex literal — it hides the bug and breaks the dual theme.
|
||||
1. **Disclose AI assistance in the PR.** RomM requires it (see `CONTRIBUTING.md`): state that AI was used and to what extent. This is mandatory and non-negotiable for agent-written contributions.
|
||||
2. **Branch off `master`; open PRs against `master`.** Fork → feature branch → PR. Don't push to `master`.
|
||||
3. **Linting is via [Trunk](https://trunk.io)** (`trunk fmt && trunk check`) — it wraps ruff, black, isort, mypy, ESLint, Prettier, and more, and runs in CI on every PR. **Never commit with `--no-verify`.**
|
||||
4. **The backend owns the API contract.** Changed a response schema or route? Regenerate frontend types (`npm run generate`) and re-typecheck.
|
||||
5. **Tests travel with code.** New logic gets a test; new endpoints get endpoint tests; new v2 primitives get a Storybook story (+ `play()` if interactive).
|
||||
6. **Verify before handoff.** Don't say "done" on UI work without testing it in the browser in both themes and all input modalities. See `pre-pr-verification`.
|
||||
|
||||
---
|
||||
|
||||
## V. Universal input
|
||||
## Quick command reference
|
||||
|
||||
### Origin
|
||||
**Setup:** see `DEVELOPER_SETUP.md`. Docker path is `cp env.template .env` → `docker compose build` → `docker compose up -d` (app at `http://localhost:3000`).
|
||||
|
||||
The input system started in `src/console/` (gamepad-only UI). v2 generalises it: it lives in `src/v2/composables/useInput/` (bus, keyboard, gamepad, actions, scope) and applies to all of v2. There are no `/console/*` routes in v2.
|
||||
|
||||
### Modality
|
||||
|
||||
`useInputModality` puts `data-input="mouse|touch|key|pad"` on `<html>` based on the most recent input.
|
||||
|
||||
- Focus rings appear only with `key` and `pad` (CSS in `global.css`).
|
||||
- Hit targets may scale up for `touch` and `pad`.
|
||||
- **Never use `:focus` directly in styles** — use the modality-gated selectors. Otherwise focus rings flash on mouse click.
|
||||
|
||||
### Coverage
|
||||
|
||||
Every interactive primitive participates in spatial navigation: buttons, list items, tabs, menu items, focusable cards, toggleable chips. Not optional.
|
||||
|
||||
A new interactive primitive must:
|
||||
|
||||
- be focusable (proper tabindex, or wrapped Vuetify component already is).
|
||||
- react to logical actions (confirm/cancel) from `useInput`, in addition to native click.
|
||||
- show a modality-gated focus state.
|
||||
|
||||
Storybook `play()` covering gamepad input is required **only when applicable** (the primitive is interactive enough that gamepad navigation actually matters).
|
||||
|
||||
### Focus geometry
|
||||
|
||||
Each view declares its layout using focus primitives: `RFocusZone`, `RFocusGrid`, `RFocusRow`, `RFocusColumn`. Multiple regions = multiple zones. Predictable up/down/left/right movement is the view's responsibility.
|
||||
|
||||
### Element-level global shortcuts
|
||||
|
||||
Above the per-view geometry, certain elements have dedicated bindings regardless of focus location:
|
||||
|
||||
- `UserMenu` opens on Start.
|
||||
- Navbar tabs cycle with LB/RB (in addition to D-pad).
|
||||
- Context menu opens on X or Y.
|
||||
|
||||
These bind globally, not per-view.
|
||||
|
||||
### Scope (overlay stack)
|
||||
|
||||
When a dialog opens, push a scope. When it closes, pop. This prevents Escape from closing two things at once and prevents `confirm` from leaking to controls beneath an overlay.
|
||||
|
||||
`RDialog` and `RMenu` handle their scope automatically. Custom overlays are an anti-pattern (premise 8) — go through the primitives.
|
||||
|
||||
### Responsive layout
|
||||
|
||||
Premise 6 (universal input) has a sibling: **universal viewport.** Every v2 surface must read cleanly from a 320px phone to a 4K display. The mechanism is fixed; do not invent a parallel one.
|
||||
|
||||
- **Single breakpoint source: `useBreakpoint`** (`src/v2/composables/useBreakpoint/`). Material thresholds — `xs <600`, `sm 600–959`, `md 960–1279`, `lg 1280–1919`, `xl ≥1920`. `installBreakpointAttribute()` (mounted once in `AppLayout`) mirrors the active set onto `<html data-bp="…">`.
|
||||
- **Layout switches live in CSS** via the attribute selector: `html[data-bp~="xs"] .foo { … }`, `html[data-bp~="sm-and-down"] .foo { … }`. **No raw `@media` for layout** — the only allowed `@media` are `prefers-reduced-motion` and print. The attribute is on `<html>` so it reaches teleported overlays too.
|
||||
- **Conditional rendering** (mount/unmount a different component per tier, not just restyle) uses the `useBreakpoint()` refs in `<script>` — e.g. `v-if="xs"`. Prefer mount-gating over `display:none` for focusable chrome so hidden controls never sit in the tab/spatial-nav order.
|
||||
- **`--r-row-pad` is the global horizontal gutter** and is already re-scoped responsive in `global.css` (36 → 20 → 14px). Consume `var(--r-row-pad)`; don't hard-code a smaller `xs` padding per component.
|
||||
- **Touch targets** ≥ `--r-touch-target` (44px) on `xs` / touch. Gate the bump to touch/pad where desktop sizing would bloat.
|
||||
- **Overlays go full-bleed on `xs`.** `RDialog` renders as a full-screen / bottom sheet on phones (`fullscreenOnMobile`, default on); `RMenu` bottom-sheets large menus. Never float a 600px dialog on a 360px screen.
|
||||
- **Label→icon collapse** (the AppNav precedent) is the canonical way to compress chrome; the four primary destinations relocate to `BottomNav` (bottom tab bar) on `sm-and-down`.
|
||||
- **Grids** size via `useResponsiveColumns` (ResizeObserver), never a fixed column count.
|
||||
|
||||
Verification adds the breakpoint sweep — see §VII.6.
|
||||
|
||||
---
|
||||
|
||||
## VI. Architecture patterns
|
||||
|
||||
### A. Errors & snackbars
|
||||
|
||||
- Single channel: `useSnackbar()` (`src/v2/composables/useSnackbar/`) with `success | error | warning | info` methods. Internally emits `snackbarShow`; `NotificationHost` stacks toasts (improvement over v1's overwriting single snackbar).
|
||||
- Canonical tones: `success | error | warning | info`. The current color-string collapser in `NotificationHost` is debt; remove when v1 dies.
|
||||
- The call site emits — no global "wrap-every-promise" magic. Each feature decides what is significant.
|
||||
- Field validation errors render in-place, never as a snackbar.
|
||||
- Auth (401/403) is handled by the axios interceptor; no per-call-site checks.
|
||||
- Successful critical actions: `success` snackbar. Routine optimistic toggles: silent on success, `error` on failure.
|
||||
|
||||
### B. Loading states
|
||||
|
||||
- **Skeleton** for first load of a view with known layout (`RSkeletonBlock`). The skeleton mimics the real shape so the layout doesn't jump on data arrival.
|
||||
- **Inline `:loading` on the control itself** for actions in flight inside that control (`RBtn`, `RTextField`, `RSelect`). Never put an external `RSpinner` next to a button that has its own `loading`.
|
||||
- **`RSpinner` inline** when what's loading is not a control with native `loading`.
|
||||
- **Determinate progress (with %)**: build `RProgressLinear` when needed. Premise 8 — no raw `v-progress-linear`.
|
||||
- **Empty state ≠ loading state.** Zero items is a distinct UX (message, illustration, optional CTA).
|
||||
- **Optimistic toggles do not show spinners.** UI flips immediately; on failure, revert and snackbar.
|
||||
- **Spinner debounce**: `RBtn` ships with `loadingDebounce={200}` by default — actions resolving under 200ms never paint a spinner. Going loading → not-loading is immediate.
|
||||
|
||||
### C. Real-time updates (Socket.IO)
|
||||
|
||||
- One instance: `src/services/socket.ts`. Never new `io()`.
|
||||
- New consumers go through (or build) a `useSocketEvent(event, handler)` composable for typed subscriptions with automatic mount/unmount cleanup. Auto-connects if needed. _(Composable is debt — see §X.)_
|
||||
- **Event map typed in one place** (deferred until backend exposes typed event catalog).
|
||||
- **Ownership rule**: state living only while a view is open → `useSocketEvent` in the view. State that must outlive a view (e.g., scan badge in navbar) → a Pinia store subscribes globally; views just read.
|
||||
- Reconnection is socket.io's job. Do not roll your own.
|
||||
|
||||
### D. UI state persistence
|
||||
|
||||
Three layers, decision rule per state:
|
||||
|
||||
1. **Persistent preferences** (theme, language, gallery defaults like `groupRoms`/`boxartStyle`, Home panels): `useUISettings` (localStorage + backend `user.ui_settings` two-way sync). Add a key to `UI_SETTINGS_KEYS`.
|
||||
2. **Bookmarkable session state** (active filters, search query, sort, current tab in detail views): URL query params. Anyone copying the link reproduces what they see.
|
||||
3. **Ephemeral session state** (open dialog, hover, expansion): `ref` if local, Pinia store if cross-component within session.
|
||||
|
||||
**Active gallery filter must be in URL** (rule). Migration of `stores/galleryFilter.ts` to URL-sync is debt — see §X.
|
||||
|
||||
### E. Pagination & infinite scroll
|
||||
|
||||
- `LoadMore` is the canonical fallback: `RBtn + RSpinner + IntersectionObserver`. Used as fallback when virtualization stalls.
|
||||
- `RVirtualScroller` (wrapping `v-virtual-scroll`, primitive in `src/v2/lib/structural/`) is the substrate for large lists/grids. **Migration of `GameGrid` and `LetterGroupedGrid` is debt** — see §X.
|
||||
- Page size lives in the store (`fetchLimit`); not user-configurable for now.
|
||||
- **Scroll restoration** when navigating back: Vue Router `scrollBehavior` + Pinia keeps in-session offset. URL holds filters/sort/search but **not** scroll offset. Migration is debt — see §X.
|
||||
|
||||
### F. Forms & validation
|
||||
|
||||
- **`RForm` primitive** wrapping `v-form` with QoL: Enter on any field submits when valid, scroll-to-first-error after a failed `validate()`. Standard wrapper contract.
|
||||
- **Native Vuetify rules** — no Zod/Yup. Rules are arrays of `(v) => true | string` functions.
|
||||
- **Reusable rules** in `src/v2/utils/validation.ts` (`required(msg?)`, `email`, `asciiOnly`, `lengthBetween`, `usernameLength/Chars`, `passwordLength`). Utility code is allowed to call `i18n.global.t(...)` (the no-i18n rule covers lib primitives, not utils).
|
||||
- **Submit pattern**:
|
||||
- `await formRef.value?.validate()` before calling the API.
|
||||
- Submit button uses `:loading="submitting"`.
|
||||
- Errors → snackbar; field errors stay in-place.
|
||||
- Backend field errors map to `:error-messages` per field. Format is debt — backend to formalise `{ field: msg }`.
|
||||
|
||||
### G. Permissions
|
||||
|
||||
- Action vocabulary: `domain.action` (e.g., `rom.upload`, `rom.delete`, `library.scan`, `user.create`, `app.admin`). Defined in `src/v2/composables/useCan/actions.ts`.
|
||||
- Scope vocabulary:
|
||||
```ts
|
||||
type PermissionScope =
|
||||
| { kind: "global" }
|
||||
| { kind: "platform"; id: number }
|
||||
| { kind: "collection"; id: number }
|
||||
| { kind: "rom"; id: number };
|
||||
```
|
||||
- **`useCan(action, scope?)`** returns `ComputedRef<boolean>`, reactive to `permissionsStore.grants`. Without scope: "can do this anywhere."
|
||||
- **`stores/permissions.ts`** holds normalised grants. Today hydrated from `authStore.user.role` via the role-map (`installPermissionsHydration()` mounted in `AppLayout`); tomorrow served by `/permissions/me`.
|
||||
- **`v-if`** to hide options a user shouldn't see; **`:disabled`** with tooltip when the option must be visible but blocked.
|
||||
- **Backend is source of truth.** Frontend is UX hint. Never bypass with inline `user.role === "..."`.
|
||||
- **No `useCanAsync`** — all grants are pre-loaded. If granularity reaches per-ROM ACLs, revisit.
|
||||
|
||||
### H. Destructive confirmations
|
||||
|
||||
Three friction levels:
|
||||
|
||||
- **Low** / **High**: shared composite `ConfirmDialog` (in `components/shared/`) opened via `useConfirm({ title, body, confirmText, tone, requireTyped }) => Promise<boolean>`. Mounted once in `GlobalDialogs`.
|
||||
- **Medium**: feature composite when the destructive flow needs extra options (e.g., `DeleteRomDialog` with per-item filesystem checkboxes).
|
||||
|
||||
Common rules:
|
||||
|
||||
- All destruction goes through a dialog. No silent destructive action.
|
||||
- Confirm button is danger-toned; **focus initially on Cancel**. Enter cancels.
|
||||
- After success: success snackbar or navigate away; dialog closes.
|
||||
- After error: error snackbar; dialog stays open.
|
||||
- During action: confirm shows `:loading`; cancel disabled.
|
||||
- The destructive control respects `useCan(action, scope)`.
|
||||
- **No "don't ask again."** Every destructive action confirms.
|
||||
- **Type-to-confirm (`requireTyped`) is required when the action affects filesystem.**
|
||||
|
||||
---
|
||||
|
||||
## VII. Verification before handoff
|
||||
|
||||
### Static — local + CI
|
||||
|
||||
1. `npm run typecheck` — zero errors.
|
||||
2. `npm run lint` — zero errors. (`lint` is scoped to `./src/v2`; `lint:all` sweeps v1 too for visibility.)
|
||||
3. `npm run test` — zero failures (Vitest 4 + happy-dom; runs unit tests _and_ every `/lib` story's `play()` via `composeStories`).
|
||||
4. `npm run build` — zero failures (in CI, sanity check).
|
||||
|
||||
### Generated types
|
||||
|
||||
5. If you touched the backend API, run `npm run generate` and re-typecheck.
|
||||
|
||||
### UI (when changes are visible)
|
||||
|
||||
6. Browser test with `uiVersion = "v2"`: golden path + edge cases (empty, error, loading, no-permission, extreme data) + nearby regressions.
|
||||
7. Both themes: dark and light.
|
||||
8. All four input modalities: mouse, touch, keyboard, gamepad. Focus ring only on `key`/`pad`.
|
||||
9. Accessibility minimum: contrast, keyboard reachability without traps, aria-labels for icon-only controls.
|
||||
|
||||
### Storybook
|
||||
|
||||
10. New primitive → mandatory story with controls and at least one variant per theme.
|
||||
11. Modified primitive → existing story renders + interactions still pass.
|
||||
|
||||
### i18n
|
||||
|
||||
12. `en_US` is the source of truth, but **every key added to `en_US` must be added to all other locale directories in the same change** — never leave a key English-only. Translate where you can; otherwise copy the English value as a placeholder so the key exists. Run `python3 frontend/src/locales/check_i18n_locales.py` — it must pass with zero missing/extra keys (also enforced in CI via `i18n.yml`).
|
||||
13. Never hard-code strings in user-visible components.
|
||||
|
||||
### Performance (when applicable)
|
||||
|
||||
14. Lists/grids with 1000+ items still smooth. Use `RVirtualScroller` once the gallery migration lands; until then, `LoadMore` keeps things working at smaller scales.
|
||||
15. `v-for` always with a stable `:key` (not the index, except for trivial static lists).
|
||||
|
||||
### Tests
|
||||
|
||||
16. New logic in a composable / store / util → Vitest test.
|
||||
17. New primitive → story + `play()` interaction (counts as interactive test).
|
||||
|
||||
### Anti-checklist
|
||||
|
||||
- Never open a PR without manually testing the UI when UI was touched. Static checks don't prove the feature works.
|
||||
- Never `--no-verify` on commits.
|
||||
- Never duplicate coverage between Vitest and Storybook play().
|
||||
|
||||
---
|
||||
|
||||
## VIII. Anti-patterns
|
||||
|
||||
Listed only when they go beyond what premises already say.
|
||||
|
||||
1. **Don't change shared store APIs to work around v2 call-site issues.** Lesson from the Gallery migration: the fix for `fetchingRoms` not resetting was calling `romsStore.reset()` from the v2 view, not adding `_fetchSeq` to the store. Stores are canonical; their API doesn't bend to one consumer.
|
||||
2. **Don't "fix" an unresolved token by replacing it with a hex literal.** Diagnose scope on `<html>` instead.
|
||||
3. **Don't use bare `:focus` in CSS.** Use the modality-gated selectors from `global.css`.
|
||||
4. **Don't drop to inline role checks.** Always go through `useCan`.
|
||||
5. **Don't snackbar every rejected promise.** 401/403 are interceptor-handled. Field errors are in-place. Only significant errors hit the snackbar — the call site judges.
|
||||
6. **Don't push a state into `useUISettings` "so it persists."** Three-layer rule (D): bookmarkable → URL, persistent → useUISettings, ephemeral → ref/store.
|
||||
7. **Don't reinvent a surface.** Dialog / menu / popover / card panel all go through their primitive. Special cases get a new prop on the primitive, not a parallel surface.
|
||||
8. **Don't use `v-form` directly.** Use `RForm`.
|
||||
9. **Don't add backwards-compat shims inside v2.** Delete removed code; no `// removed`, no renamed-but-unused exports, no deprecated wrappers that just call the new function.
|
||||
10. **Don't write redundant tests.** Storybook `play()` covers components; Vitest covers pure logic. Don't repeat.
|
||||
11. **Don't touch v1.** Premise 1 — repeated because the temptation is real once v2 imports from `src/stores/`.
|
||||
12. **Mark v1 as `@deprecated` when a v2 equivalent exists.** When coexistence forces a v2 fork of a store/composable/util, annotate the v1 export with `@deprecated` pointing to the v2 replacement. Makes the v1 cleanup wave trivial to navigate.
|
||||
|
||||
### Allowed (because it's often misread)
|
||||
|
||||
- Modifying shared stores/services/utils **additively** is permitted. Premise 3 says "don't fork", not "don't touch."
|
||||
- Creating a new v2-only composable when a v1 equivalent exists **is allowed.** Premise 3 covers stores/services/types/locales/utils. Composables can be v2-only (`useCan`, `useSnackbar`, `useConfirm`, `useWebpSupport`, …).
|
||||
- Importing from `src/__generated__/` is canonical for v2 features.
|
||||
|
||||
---
|
||||
|
||||
## IX. File conventions
|
||||
|
||||
### Vue SFCs
|
||||
|
||||
- `<script setup lang="ts">` always.
|
||||
- `defineOptions({ inheritAttrs: false })` on every wrapper, paired with `v-bind="$attrs"` and slot passthrough. Without the bind, attrs vanish silently.
|
||||
- Props typed via `defineProps<Props>()` (interface), never runtime declarations.
|
||||
- Emits typed via `defineEmits<{...}>()`.
|
||||
- Slots with payload via `defineSlots<{}>()` (Vue 3.3+).
|
||||
- Order: `<script setup>` → `<template>` → `<style scoped>`. Unscoped `<style>` (teleport overrides) goes after the scoped block.
|
||||
|
||||
### Imports
|
||||
|
||||
```ts
|
||||
// 1. External
|
||||
// 2. v2 primitives
|
||||
import { RBtn, RDialog } from "@v2/lib";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { SimpleRom } from "@/__generated__";
|
||||
import romApi from "@/services/api/rom";
|
||||
// 5. Canonical shared resources
|
||||
import storeAuth from "@/stores/auth";
|
||||
// 4. v2 feature siblings
|
||||
import GameCard from "@/v2/components/GameCard.vue";
|
||||
import ConfirmDialog from "@/v2/components/shared/ConfirmDialog.vue";
|
||||
// 3. v2 composables / shared
|
||||
import { useCan } from "@/v2/composables/useCan";
|
||||
**Backend** (`cd backend`):
|
||||
```bash
|
||||
uv sync --all-extras --dev # install
|
||||
uv run python3 main.py # run (migrations auto-apply)
|
||||
uv run pytest [path/file] # test (subset) — or -vv for all
|
||||
uv run alembic revision --autogenerate -m "msg" # new migration (then HAND-REVIEW)
|
||||
uv run alembic upgrade head # apply migrations
|
||||
```
|
||||
|
||||
Aliases:
|
||||
**Frontend** (`cd frontend`):
|
||||
```bash
|
||||
npm install # install (Node 24)
|
||||
npm run dev # dev server :3000
|
||||
npm run typecheck # vue-tsc
|
||||
npm run test # vitest (+ Storybook play() tests)
|
||||
npm run build # production build
|
||||
npm run generate # regenerate types from backend OpenAPI (backend must be running)
|
||||
npm run build:tokens # regenerate v2 tokens.css (auto on predev/prebuild)
|
||||
npm run storybook # component library on :6006
|
||||
python3 src/locales/check_i18n_locales.py # i18n parity check
|
||||
```
|
||||
|
||||
- `@v2/lib` — primitives barrel.
|
||||
- `@/v2/...` — anything else under v2.
|
||||
- `@/...` — canonical shared resources.
|
||||
- Never relative paths (`../../foo`) when an alias exists.
|
||||
|
||||
### Styles
|
||||
|
||||
- Scoped by default.
|
||||
- Unscoped only for teleport overrides.
|
||||
- BEM-ish: `.feature__element--modifier`.
|
||||
- Class prefixes: `.r-v2-...` for surfaces outside components (app shell layers); `.r-...` for utilities/tokens shared globally.
|
||||
- Zero hex literals (premise 4).
|
||||
- Zero plain CSS where Vuetify utility classes cover the case (premise 9).
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Strict (premise 11). Zero `any` except justified with comment.
|
||||
- Zero `as unknown as ...`. Fix the source or define an intermediate type.
|
||||
- `type` for unions/mappings; `interface` for shapes that get extended. Internal consistency, not holy war.
|
||||
- Shared v2 types in `src/v2/types/`. Backend types from `src/__generated__/`. Not `src/types/` (legacy).
|
||||
|
||||
### Composables
|
||||
|
||||
- `use` prefix.
|
||||
- Single named export from `composables/useFoo/index.ts`.
|
||||
- Fully typed args and return.
|
||||
- No side effects on module load. Init on first call.
|
||||
|
||||
### Console
|
||||
|
||||
- `console.error` allowed for production-visible errors.
|
||||
- `console.log` / `console.warn` does not ship.
|
||||
- `console.debug` allowed during development; remove before PR.
|
||||
|
||||
### Files
|
||||
|
||||
- One primitive per folder: `RFoo/RFoo.vue` + `RFoo/RFoo.stories.ts` + `RFoo/index.ts` (+ `types.ts` if needed).
|
||||
- Composites: flat `.vue` if it fits one file; folder if sub-pieces.
|
||||
- `index.ts` only in barrels. No single-file `index.ts` re-exports just to shorten import paths.
|
||||
|
||||
### Language
|
||||
|
||||
Code, comments, identifiers, .md files, commit/PR messages — English. Always.
|
||||
|
||||
---
|
||||
|
||||
## X. Known debt
|
||||
|
||||
The constitution is fully described above. The following implementation tasks remain. Tackle each as a focused PR with browser verification.
|
||||
|
||||
### Frontend debt
|
||||
|
||||
1. **Virtualisation migration** — `RVirtualScroller` primitive is in `src/v2/lib/structural/`. Migrating `GameGrid` and `LetterGroupedGrid` to use it requires structural refactor of `Platform.vue` / `Search.vue` / `Collection.vue` (single virtualiser owning the scroll, hero/toolbar/content/load-more all as virtual items via dynamic-height mode). `useLetterGroups` has to become index-based for AlphaStrip's scroll-spy to keep working. Without this, libraries beyond ~3k ROMs degrade.
|
||||
2. **`useGalleryFilterUrl`** — sync galleryFilter store fields (search, multi-select selections, logic operators) to URL query params so links are bookmarkable. Decide which subset is bookmarkable; the v1 store stays as it is and the v2 composable mirrors it. Mark v1 store usage as `@deprecated`.
|
||||
3. **Vue Router scroll restoration** — galleries scroll on custom containers (`.r-v2-plat__scroll`), not window. Add a Pinia map of `routeFullPath → offsetTop` plus per-view onMounted/onBeforeUnmount hooks to restore scroll on back navigation. Bundle with the virtualisation migration.
|
||||
4. **`useSocketEvent` composable** — typed subscriptions with mount/unmount cleanup. Today consumers wire `socket.on/off` manually.
|
||||
|
||||
### Backend debt (frontend can't fix)
|
||||
|
||||
5. **`ActionKey` enum in OpenAPI** — eliminates the manual `actions.ts` + `role-map.ts` pair; the frontend regenerates and gets compile-time alignment.
|
||||
6. **`/permissions/me` endpoint** returning normalised grants. Replaces `hydrateFromRole`; drops the role-map.
|
||||
7. **`permissions:changed` socket event** for live grant updates.
|
||||
8. **`FrontendDict.IMAGES_WEBP`** in OpenAPI — drops the cast inside `useWebpSupport`.
|
||||
9. **Form error format** standardised as `{ field: msg }` so `RTextField :error-messages` integration is consistent.
|
||||
10. **Typed socket event map** from the backend so the eventual `useSocketEvent` composable is fully typed.
|
||||
|
||||
### When v1 is deleted
|
||||
|
||||
11. Move `uiVersion` from `useUiVersion` into `UI_SETTINGS_KEYS`.
|
||||
12. Drop `.r-v2-...` scope classes; tokens move to `:root`.
|
||||
13. Simplify `useUISettings` sync (remove `isSyncing` + `setTimeout(50)` flag-flip).
|
||||
14. Delete `useGameAnimation` (replaced by view transitions, premise 8 era).
|
||||
15. Drop the color-string-to-tone collapser in `NotificationHost`; `snackbarShow` payload becomes `{ msg, tone, ... }`.
|
||||
16. Remove the Vuetify rule arrays in `stores/users.ts` once v1 doesn't consume them; v2 already composes from `src/v2/utils/validation.ts`.
|
||||
|
||||
### Color-literal policy: zero exceptions
|
||||
|
||||
Outside `src/v2/tokens/index.ts` (the source-of-truth TS module) and the generated `src/v2/styles/tokens.css`, **no hex or `rgba()` literals exist anywhere in v2**. Premise 4 is enforced; the previous "intentional exceptions" list has been collapsed into tokens:
|
||||
|
||||
- Cover-overlay glass → `--r-color-overlay-*` (fixed dark glass; never theme-flips).
|
||||
- Cover artwork placeholder & shimmer → `--r-color-cover-placeholder`, `--r-color-cover-placeholder-bright`.
|
||||
- Panel / tooltip / shimmer-sweep → `--r-color-panel`, `--r-color-panel-border`, `--r-color-tooltip-bg`, `--r-color-shimmer-sweep` (paired dark/light values).
|
||||
- Backdrop scrims in `global.css` → `color-mix(in srgb, var(--r-color-bg) X%, transparent)` (inherits the theme's base bg).
|
||||
- Status tints (success/warning/danger/info backgrounds, borders) → `color-mix(in srgb, var(--r-color-status-base-{success,warning,danger,info}) X%, transparent)`.
|
||||
- Brand-tinted backgrounds (selected rows, focus rings) → `color-mix(in srgb, var(--r-color-brand-primary) X%, transparent)`.
|
||||
- Black/white shadows → `color-mix(in srgb, black X%, transparent)` (CSS named colour, not a hex literal).
|
||||
- Metadata-provider chips → `--r-color-provider-*`.
|
||||
- Player canvas → `--r-color-canvas-bg`, `--r-color-canvas-bg-deep`.
|
||||
- Emphasis pill (always-white-on-dark "Play" CTA over cover art) → `--r-color-overlay-emphasis-bg/-fg/-bg-hover`.
|
||||
|
||||
If a literal would otherwise be needed, the answer is: **add a token**. Update `src/v2/tokens/index.ts`, run `npm run build:tokens`, then consume via `var(--r-color-...)` (CSS) or by importing the named export (TS/JS — e.g., `colorCanvas`, `colorOverlay`).
|
||||
**Lint (both stacks):** `trunk fmt && trunk check`.
|
||||
|
||||
Reference in New Issue
Block a user