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, review-polish.
Premises (stable)
- 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.
- Three component tiers (below).
- 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.
- TypeScript strict. Zero
any (justify with a comment if unavoidable). No as unknown as ...; fix the source or define an intermediate type.
- 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.
- Wrapper contract. Wrappers around Vuetify use
defineOptions({ inheritAttrs: false }) + v-bind="$attrs" + slot passthrough, and accept every prop/slot of the wrapped component.
- 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).
- 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
- Does not depend on stores, services, router, or emitter.
- No knowledge of a product domain (ROM, Platform, Collection, User…).
RAvatar yes, UserAvatar no.
- 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
// 1. External
// 2. v2 primitives
import { RBtn, RDialog } from "@v2/lib";
import { computed, ref } from "vue";
import type { SimpleRom } from "@/__generated__";
// 5. Canonical shared resources
import storeAuth from "@/stores/auth";
// 4. v2 feature siblings
import GameCard from "@/v2/components/GameCard.vue";
// 3. v2 composables / shared
import { useCan } from "@/v2/composables/useCan";
@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)
- 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.)
- Don't drop to inline role checks — always go through
useCan (see frontend-v2-patterns).
- Don't reinvent a surface — dialog/menu/popover/card all go through their primitive; special cases become a new prop, not a parallel surface.
- Don't use
v-form directly — use RForm.
- 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.
- 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.
1---2name: frontend-v2-components3description: 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/.4---56# RomM Frontend v2 — Component Constitution78This 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`.910> Official language for all code, comments, identifiers, `.md`, and commit/PR messages: **English**.1112Related skills: `frontend-v2-theming` (tokens/colors), `frontend-v2-input` (focus/gamepad/responsive), `frontend-v2-patterns` (errors/loading/forms/permissions/confirmations), `frontend-i18n`, `review-polish`.1314---1516## Premises (stable)17181. **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.192. **Three component tiers** (below).203. **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.214. **TypeScript strict.** Zero `any` (justify with a comment if unavoidable). No `as unknown as ...`; fix the source or define an intermediate type.225. **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.236. **Wrapper contract.** Wrappers around Vuetify use `defineOptions({ inheritAttrs: false })` + `v-bind="$attrs"` + slot passthrough, and accept every prop/slot of the wrapped component.247. **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).258. **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`.2627---2829## The three tiers3031| Tier | Path | Prefix | Stores/services/router/emitter/i18n | Story | Domain knowledge |32| --------------------- | ------------------------------ | -------------- | ----------------------------------- | --------- | --------------------------------- |33| **Primitive** | `src/v2/lib/` | `R*` mandatory | **No** | Mandatory | None |34| **Shared composite** | `src/v2/components/shared/` | no prefix | Yes | Optional | Cross-feature, no specific domain |35| **Feature composite** | `src/v2/components/<feature>/` | no prefix | Yes | Optional | Feature-specific |3637### A component is a primitive only if all three hold38391. Does not depend on stores, services, router, or emitter.402. No knowledge of a product domain (ROM, Platform, Collection, User…). `RAvatar` yes, `UserAvatar` no.413. Its API can be described without naming features — generic props/slots/events.4243If 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.4445### Primitive boundaries4647- **Can use**: tokens, other primitives, Vue/Vuetify, generic composables (`useInput*`, `useFocus*`).48- **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.4950---5152## File & folder conventions5354- **Primitive**: one per folder — `RFoo/RFoo.vue`, `RFoo/RFoo.stories.ts`, `RFoo/index.ts`, optional `RFoo/types.ts`.55- **Composite**: flat `.vue` if one file suffices; a folder with the same internal structure (no story required) if it has sub-pieces.56- **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.5758### SFC structure5960- `<script setup lang="ts">` always.61- `defineOptions({ inheritAttrs: false })` on every wrapper, paired with `v-bind="$attrs"` and slot passthrough (without the bind, attrs vanish silently).62- Props via `defineProps<Props>()` (interface), never runtime declarations. Emits via `defineEmits<{...}>()`. Slots with payload via `defineSlots<{}>()`.63- Order: `<script setup>` → `<template>` → `<style scoped>`. Unscoped `<style>` (teleport overrides only) goes after the scoped block.6465### Import order & aliases6667```ts68// 1. External69// 2. v2 primitives70import { RBtn, RDialog } from "@v2/lib";71import { computed, ref } from "vue";72import type { SimpleRom } from "@/__generated__";73// 5. Canonical shared resources74import storeAuth from "@/stores/auth";75// 4. v2 feature siblings76import GameCard from "@/v2/components/GameCard.vue";77// 3. v2 composables / shared78import { useCan } from "@/v2/composables/useCan";79```8081- `@v2/lib` — primitives barrel. `@/v2/...` — anything else under v2. `@/...` — canonical shared resources. Never relative paths (`../../foo`) when an alias exists.82- Shared v2 types live in `src/v2/types/`; backend types come from `src/__generated__/`; not `src/types/` (legacy).8384### Composables8586- `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.8788### Console logging8990- `console.error` allowed for production-visible errors. `console.log`/`console.warn` must not ship. `console.debug` is dev-only — remove before PR.9192---9394## Storybook (mandatory for `/lib`)9596- Every primitive ships at least one story with controls and at least one variant per theme.97- A new interactive primitive that warrants gamepad navigation ships a `play()` interaction.98- Modified primitive: existing story must still render and its interactions still pass.99- `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).100101---102103## Anti-patterns (beyond what the premises already say)1041051. 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.)1062. Don't drop to inline role checks — always go through `useCan` (see `frontend-v2-patterns`).1073. Don't reinvent a surface — dialog/menu/popover/card all go through their primitive; special cases become a new prop, not a parallel surface.1084. Don't use `v-form` directly — use `RForm`.1095. 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.1106. Don't write redundant tests; don't touch v1; never `--no-verify` on commits.111112**Allowed (often misread):** modifying shared stores/services/utils _additively_; creating v2-only composables; importing from `src/__generated__/`.113114---115116## Known debt (focused follow-ups)117118- **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.119- **`useGalleryFilterUrl`** — sync `galleryFilter` store fields to URL query params for bookmarkable links; mark v1 store usage `@deprecated`.120- **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.121- **`useSocketEvent` composable** — typed socket subscriptions with mount/unmount cleanup (consumers currently wire `socket.on/off` by hand).122- **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`.123124Full reference: `docs/FRONTEND_ARCHITECTURE.md`.