RomM v2 — Architecture Patterns
SkillFiles & storageTeaches your agent the standard frontend coding patterns for RomM v2 features like errors, loading states, forms, and permissions.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the RomM v2 — Architecture Patterns skill
About this capability
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
What this skill tells your AI
The instructions your AI receives, as published by rommapp/romm in .claude/skills/frontend-v2-patterns/SKILL.md and read by ahel’s review.
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/) withsuccess | error | warning | infomethods. It emitssnackbarShow;NotificationHoststacks 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 →
successsnackbar. Routine optimistic toggles → silent on success,erroron 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
:loadingon the control itself for in-flight actions (RBtn,RTextField,RSelect). Never put an externalRSpinnernext to a button that has its ownloading. RSpinnerinline when what's loading isn't a control with nativeloading.- Determinate progress (%): use
RProgressLinear— no rawv-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.
RBtnshipsloadingDebounce={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. Nevernew io(). - Subscriptions go through
useSocketEvent(event, handler)(src/v2/composables/useSocketEvent/): typed payload, auto-connect by default ({ connect: false }opts out), cleanup viaonScopeDisposeso it also works inside a store action or a manualeffectScope. No v2 code wiressocket.on/offby hand; don't start. - 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
- Persistent preferences (theme, language, gallery defaults like
groupRoms/boxartStyle, Home panels) →useUISettings(localStorage + backenduser.ui_settingstwo-way sync). Add a key toUI_SETTINGS_KEYS. - 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.
- Ephemeral session state (open dialog, hover, expansion) →
refif local, Pinia store if cross-component within the session.
Don't push state into useUISettings "so it persists", follow the rule above. Layer 3 never touches localStorage: if a value has to survive a reload, it is layer 1 or the per-entity variant below, not ephemeral state.
Per-entity device preferences (a bezel hidden for one game, the core picked for one game) are a narrow variant of layer 1: they persist per device but stay out of useUISettings, because they are keyed by entity rather than global and must not sync to user.ui_settings. Use useLocalStorage from VueUse with writeDefaults: false and a serializer, not a ref plus a watch plus localStorage.setItem. Key it off the route param so it binds before the entity resolves, and make the read fail safe to the default so a stale value can't wedge the view.
D2. Async and reactive lifecycle
Three mistakes that keep reaching review:
- Snapshot before the first
await. Any reactive value a decision depends on can move while requests are in flight. Read it into a local before the call, not between calls:const wasAllFavorited = allFavorited.valuegoes aboveawait ensureFavoriteCollection(), because the response replaces the veryrom_idsthatallFavoritedderives from. - Watch the narrowest source.
watch(() => authStore.user, ...)refires on every unrelated profile update, which then needs a manual "already ran for this id" flag. Watch a derived primitive instead so the watch is self-guarding:() => user?.oauth_scopes.includes("tasks.run") ? user.id : null. - Guard late resolutions with
useIsAlive()(src/v2/composables/useIsAlive/), not a localunmountedflag plusonBeforeUnmount. It usesonScopeDispose, so it also works inside another composable. VueUse'suseMountedis not a substitute.
Name a helper for what it touches: syncCachedRom, not syncRom, when it updates the cache and does not fetch.
E. Pagination & infinite scroll
LoadMore(RBtn+RSpinner+ IntersectionObserver) is the canonical fallback when virtualization stalls.RVirtualScroller(src/v2/lib/structural/) is the substrate for large lists/grids: a custom windowed list that owns its offset math, not a wrapper around anything.- Page size lives in the store (
fetchLimit); not user-configurable for now. - Scroll restoration on back-nav: the
scrollRestorationPinia store keyed byroute.fullPath. Vue Router'sscrollBehavioronly restoreswindowscroll, and galleries scrollRVirtualScroller's container, soGalleryShellowns persistence: it saves the outgoing route's offset in both itsonBeforeRouteUpdateandonBeforeRouteLeaveguards. Views don't repeat that (their ownonBeforeRouteUpdatejust triggers the new context's load); they call the exposedapplyRestoredScroll()at the end of their load flow. URL holds filters/sort/search but not scroll offset.
F. Forms & validation
- Use the
RFormprimitive (a native<form>providing a registration context that descendant fields auto-enroll into: Enter-to-submit when valid, scroll-to-first-error after a failedvalidate()). Never hand-roll a<form>. - Plain function rules, no Zod/Yup and no validation library. Rules are arrays of
(v) => true | string, run by the field primitives themselves. - Reusable rules in
src/v2/utils/validation.ts(required(msg?),email,asciiOnly,lengthBetween,usernameLength/Chars,passwordLength). Utility code may calli18n.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) insrc/v2/composables/useCan/actions.ts. - Scope vocabulary:
type PermissionScope = | { kind: "global" } | { kind: "platform"; id: number } | { kind: "collection"; id: number } | { kind: "rom"; id: number }; useCan(action, scope?)returnsComputedRef<boolean>, reactive topermissionsStore.grants. Without scope: "can do this anywhere."stores/permissions.tsholds normalised grants, hydrated fromauthStore.user.rolevia the role-map (installPermissionsHydration()inAppLayout); a future/permissions/mewill replace it.v-ifto hide options a user shouldn't see;:disabledwith 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 (nouseCanAsync).
H. Destructive confirmations
Three friction levels:
- Low / High → shared composite
ConfirmDialog(components/shared/) opened viauseConfirm({ title, body, confirmText, tone, requireTyped }) => Promise<boolean>(mounted once inGlobalDialogs). - Medium → a feature composite when the flow needs extra options (e.g.
DeleteRomDialogwith 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.
Signals
- GitHub stars
- 13k
- Forks
- 735
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
frontend-v2-patterns- Source
- github.com/rommapp/romm