Remove the no-emdash-in-comment ESLint rule after a one-time sweep

Replace the em-dashes in frontend comments outside the frozen v1 UI
(script, template and style comments, plus the header build-tokens.ts
writes into tokens.css), then drop the rule. String literals keep their
em-dash glyphs. The AGENTS.md rule stays, and pr-ready's humanizer pass
catches new ones in prose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RyamrxVw5BzEidVzm68LY7
This commit is contained in:
Claude
2026-09-28 17:25:42 +00:00
parent eb44dfe414
commit 54a6ca6c96
337 changed files with 1718 additions and 1847 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import type { StorybookConfig } from "@storybook/vue3-vite";
import { fileURLToPath, URL } from "node:url";
const config: StorybookConfig = {
// Only pick up v2 stories — the v1 UI is frozen and does not ship stories.
// Only pick up v2 stories: the v1 UI is frozen and does not ship stories.
stories: ["../src/v2/**/*.stories.@(js|jsx|ts|tsx)", "../src/v2/**/*.mdx"],
addons: [
"@storybook/addon-docs",
+1 -1
View File
@@ -26,7 +26,7 @@ import {
installBreakpointAttribute();
// Each story runs inside a Vue app with Pinia + i18n + Vuetify registered.
// v2 primitives are Vuetify-free at runtime — Vuetify stays registered only
// v2 primitives are Vuetify-free at runtime: Vuetify stays registered only
// because some shared dependencies still pull it in. The visible theme for
// v2 stories comes from the `.r-v2-dark` / `.r-v2-light` class toggled on
// <html> by the theme switcher decorator below.
-2
View File
@@ -1,6 +1,5 @@
// @ts-check
import noColorLiteral from "./rules/no-color-literal.js";
import noEmdashInComment from "./rules/no-emdash-in-comment.js";
import noLayoutMediaQuery from "./rules/no-layout-media-query.js";
/** @type {import("eslint").ESLint.Plugin} */
@@ -8,7 +7,6 @@ export default {
meta: { name: "eslint-plugin-romm" },
rules: {
"no-color-literal": noColorLiteral,
"no-emdash-in-comment": noEmdashInComment,
"no-layout-media-query": noLayoutMediaQuery,
},
};
@@ -1,57 +0,0 @@
// @ts-check
import { reportAt } from "../utils/report.js";
import { rangeOf, sfcFragment, sfcStyleRoots } from "../utils/sfcStyles.js";
const EM_DASH = "—";
/**
* Source ranges of every comment in the file: script, SFC template, and SFC style.
* @param {import("eslint").Rule.RuleContext} context
* @returns {[number, number][]}
*/
function commentRanges(context) {
/** @type {[number, number][]} */
const ranges = [];
for (const c of context.sourceCode.getAllComments()) {
if (c.range) ranges.push(c.range);
}
for (const c of sfcFragment(context)?.comments ?? []) ranges.push(c.range);
for (const block of sfcStyleRoots(context)) {
block.root.walkComments((c) => {
ranges.push(rangeOf(block, c));
});
}
return ranges;
}
/** @type {import("eslint").Rule.RuleModule} */
export default {
meta: {
type: "problem",
docs: {
description:
"Disallow the em dash (U+2014) in comments (script, template, and style). Strings and template text are code and stay allowed.",
},
schema: [],
messages: {
emDash:
"Replace the em dash with a comma, parentheses, or a separate sentence.",
},
},
create(context) {
return {
Program() {
const { text } = context.sourceCode;
if (!text.includes(EM_DASH)) return;
for (const [start, end] of commentRanges(context)) {
const comment = text.slice(start, end);
let offset = comment.indexOf(EM_DASH);
while (offset !== -1) {
reportAt(context, start + offset, 1, "emDash");
offset = comment.indexOf(EM_DASH, offset + 1);
}
}
},
};
},
};
@@ -1,69 +0,0 @@
import rule from "./no-emdash-in-comment.js";
import { ruleTester, sfc } from "./testing";
const error = { messageId: "emDash" };
ruleTester.run("no-emdash-in-comment", rule, {
valid: [
{ code: "// A comma, then (parentheses) - and a hyphen.\nconst a = 1;" },
{ code: 'const empty = "—";' },
{ code: "const label = `Save — now`;" },
{ code: 'describe("criteria — tags", () => {});' },
{ code: "const foo = `${this}—${that}`;" },
{ code: "const foo = `${a} — ${b}`; // joined with a separator" },
{ code: 'const url = "https://example.com/a—b";' },
{ code: 'const s = "// not a comment — just text";' },
{ code: 'const s = "/* also — not a comment */";' },
{ code: "const s = `<!-- — -->`;" },
{ code: "const dash = /—/g;" },
{
code: '<script setup lang="ts"></script>\n<template><p :title="`${a}—${b}`">{{ `${a} — ${b}` }}</p></template>\n',
filename: "A.vue",
},
{
code: '<script setup lang="ts"></script>\n<template><span>—</span><p>Save — now</p></template>\n',
filename: "A.vue",
},
{
code: '<script setup lang="ts"></script>\n<template><RTag text="—" /></template>\n',
filename: "A.vue",
},
sfc('.a::after { content: "—"; }'),
],
invalid: [
{
code: "// FilterDrawer (v2) — gallery filter side panel.\nconst a = 1;",
errors: [{ ...error, line: 1, column: 22 }],
},
{
code: "/* No card surface — the cover is the card. */\nconst a = 1;",
errors: [error],
},
{
code: "/**\n * First line.\n * more text — continues here.\n */\nexport const x = 1;",
errors: [{ ...error, line: 3 }],
},
{
code: "/** One — two — three. */\nexport const x = 1;",
errors: [error, error],
},
{
code: 'const empty = "—"; // placeholder — no value',
errors: [{ ...error, column: 35 }],
},
{
code: '<script setup lang="ts">\n// Header — detail\nconst a = "—";\n</script>\n<template><div /></template>\n',
filename: "A.vue",
errors: [{ ...error, line: 2 }],
},
{
code: '<script setup lang="ts"></script>\n<template><!-- Row — note --><p>—</p></template>\n',
filename: "A.vue",
errors: [{ ...error, line: 2 }],
},
{
...sfc('/* Glass — dark */\n.a { content: "—"; }'),
errors: [{ ...error, line: 4 }],
},
],
});
-1
View File
@@ -208,7 +208,6 @@ export default tseslint.config(
files: ["src/v2/**/*.ts", "src/v2/**/*.vue"],
plugins: { romm },
rules: {
"romm/no-emdash-in-comment": "error",
"romm/no-color-literal": "error",
"romm/no-layout-media-query": "error",
},
+8 -8
View File
@@ -1,5 +1,5 @@
/**
* build-tokens — emits src/v2/styles/tokens.css from src/v2/tokens/index.ts.
* build-tokens: emits src/v2/styles/tokens.css from src/v2/tokens/index.ts.
*
* The TypeScript module is the single source of truth. This script declares
* the JS-path → CSS-variable mapping and writes the CSS file. Run it via
@@ -102,7 +102,7 @@ const NAME_OVERRIDES = {
rommGold: "--r-color-romm-gold",
},
// Stacking-layer tokens drop the "-index" suffix the default generator
// would produce — components consume `--r-z-menu` etc., shorter and
// would produce: components consume `--r-z-menu` etc., shorter and
// matches the convention from §VI of the constitution.
zIndex: {
drawer: "--r-z-drawer",
@@ -192,15 +192,15 @@ const LIGHT: Entry[] = [
];
const HEADER = `/*
* RomM v2 Design Tokens — CSS Custom Properties
* RomM v2 Design Tokens, CSS Custom Properties
*
* GENERATED FILE — do not hand-edit. Source: src/v2/tokens/index.ts
* GENERATED FILE, do not hand-edit. Source: src/v2/tokens/index.ts
* Regenerate with: npm run build:tokens
*
* Scoped under .r-v2 so v1 styling is unaffected. Theme palettes live under
* .r-v2.r-v2-dark and .r-v2.r-v2-light. The classes are toggled on <html>
* by RomM.vue so teleported overlays (RDialog, RMenu, RTooltip) — which
* land in <body> outside the app root — still resolve var(--r-color-*).
* by RomM.vue so teleported overlays (RDialog, RMenu, RTooltip), which
* land in <body> outside the app root, still resolve var(--r-color-*).
*/
`;
@@ -218,12 +218,12 @@ const css = [
block(
".r-v2.r-v2-dark",
DARK,
"Dark surface palette — translucent white over the near-black base.",
"Dark surface palette: translucent white over the near-black base.",
),
block(
".r-v2.r-v2-light",
LIGHT,
"Light surface palette — translucent black over the off-white base.",
"Light surface palette: translucent black over the off-white base.",
),
].join("\n");
+3 -3
View File
@@ -43,7 +43,7 @@ languageStore.setLanguage(selectedLanguage.value);
// NOTE: uiVersion uses a module-level singleton ref (useUiVersion) so a write
// from the settings page is the SAME ref RomM.vue reads, triggering a gate
// re-evaluation and an instant v1 ↔ v2 swap with no reload. Theme likewise
// reads the raw localStorage ref — we stay off useUISettings here because it
// reads the raw localStorage ref: we stay off useUISettings here because it
// imports the API layer and would trigger an API-client ↔ router circular-
// import TDZ during bootstrap (RomM.vue is the first module main.ts loads).
const uiVersion = useUiVersion();
@@ -56,7 +56,7 @@ const { idle: mouseIdle } = useIdle(100, {
events: ["mousemove", "mousedown", "wheel", "touchstart"],
});
// Centralized theme resolution — Vuetify only knows the "dark" / "light"
// Centralized theme resolution: Vuetify only knows the "dark" / "light"
// pair (used by v1 surfaces and any remaining v1 components rendered
// inside v2). v2's own colour story is driven by tokens on the .r-v2-*
// classes below, not by Vuetify's runtime theme.
@@ -98,7 +98,7 @@ watch(
const isV2 = computed(() => uiVersion.value === "v2");
// Apply the v2 token scope to <html> when v2 is active. Vuetify teleports
// overlays (VDialog, VMenu) into `<body> > .v-overlay-container` — which
// overlays (VDialog, VMenu) into `<body> > .v-overlay-container`: which
// sits OUTSIDE both the AppLayout `.r-v2` wrapper AND <v-app>. Putting the
// classes on <html> means the entire document inherits the v2 CSS custom
// properties so `var(--r-color-...)` resolves inside any teleported
+1 -1
View File
@@ -59,7 +59,7 @@ export const UI_SETTINGS_KEYS = {
key: "settings.libraryStatsMode",
default: "compact",
},
// Widget render order — comma-separated list of widget IDs.
// Widget render order: comma-separated list of widget IDs.
// Persisted as a string in localStorage (the useUISettings helper
// only handles primitives; consumers parse/serialize at the edges).
// Unknown IDs are filtered out on read so removing a widget from
+1 -1
View File
@@ -1,4 +1,4 @@
// useUiVersion — singleton ref for the v1/v2 UI toggle.
// useUiVersion: singleton ref for the v1/v2 UI toggle.
//
// vueuse's useLocalStorage creates an INDEPENDENT ref per call. Two callers
// with the same key share localStorage but not Vue reactivity within the
+10 -10
View File
@@ -275,7 +275,7 @@ const routes = [
v2: v2For(ROUTES.STREAM_DESKTOP),
},
},
// Settings group — every settings route shares the same v2
// Settings group: every settings route shares the same v2
// sub-layout (sidebar + content panel). Library Tools (Scan /
// Upload / Patcher) live here too so they share the settings
// sidebar shell. v1 keeps its existing per-view structure via the
@@ -441,7 +441,7 @@ const routes = [
title: "common.logs",
bare: true,
// The log panel fills the viewport and scrolls internally
// instead of growing the document — see SettingsLayout `fill`.
// instead of growing the document: see SettingsLayout `fill`.
fill: true,
},
components: {
@@ -510,7 +510,7 @@ const routes = [
name: ROUTES.PAIR,
component: () => import("@/v2/views/PairDispatcher.vue"),
},
// Console mode (separate UI namespace under /console) — v1 only; v2 merges
// Console mode (separate UI namespace under /console): v1 only; v2 merges
// console behavior into the main UI via the universal input system.
{
path: "/console",
@@ -564,14 +564,14 @@ const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
// popstate (back/forward) — restore the saved offset.
// popstate (back/forward): restore the saved offset.
if (savedPosition) return savedPosition;
// Same path → only query/hash changed (e.g., the v2 GameDetails
// tab/subtab params, gallery filter syncs). The user's view should
// stay where it is; scrolling to top would make the URL update
// visible as a UX jump.
if (to.path === from.path) return false;
// Genuine route change — start fresh from the top.
// Genuine route change: start fresh from the top.
return { left: 0, top: 0 };
},
});
@@ -629,7 +629,7 @@ router.beforeEach(async (to, from, next) => {
const currentRoute = to.name?.toString();
try {
// Backend unreachable/broken — we can't trust the setup/auth state, and
// Backend unreachable/broken: we can't trust the setup/auth state, and
// bouncing to /login would just strand the user on a page that can't work
// either. Let them stay on (and navigate within) whatever the cached state
// allows; the offline notice explains it and the connection layer
@@ -644,7 +644,7 @@ router.beforeEach(async (to, from, next) => {
return currentRoute !== "setup" ? next({ name: ROUTES.SETUP }) : next();
}
// Handle authentication — unauth'd users visiting a non-exempt route
// Handle authentication: unauth'd users visiting a non-exempt route
// land on /login. Without this branch, they fall through to the
// permission check below, fail it, get redirected to the catch-all 404
// (which matches /), and the guard re-runs forever.
@@ -657,7 +657,7 @@ router.beforeEach(async (to, from, next) => {
});
}
// SHOW_SETUP_WIZARD is false here, so setup is already done — nobody
// SHOW_SETUP_WIZARD is false here, so setup is already done: nobody
// belongs on /setup anymore. `/setup` is auth-exempt (so the block above
// won't bounce an unauthenticated visitor), so redirect both cases:
// authenticated users go home, everyone else to login. Without covering
@@ -701,11 +701,11 @@ watch(i18n.global.locale, async (locale) => {
});
router.beforeResolve(async (to, from) => {
// Query/hash-only changes (same path — e.g. the v2 GameDetails `?tab=`
// Query/hash-only changes (same path: e.g. the v2 GameDetails `?tab=`
// param) aren't a real view change. Running a view transition would
// snapshot every `view-transition-name` element (like the details cover)
// into the browser's top layer for the crossfade, briefly floating it over
// the fixed navbar. Skip them — matching `scrollBehavior` above.
// the fixed navbar. Skip them: matching `scrollBehavior` above.
if (to.path === from.path) return;
const viewTransition = startViewTransition();
await viewTransition.captured;
+1 -1
View File
@@ -5,7 +5,7 @@ import { VDateInput } from "vuetify/labs/VDateInput";
import "vuetify/styles";
import { dark, light } from "@/styles/themes";
// Initial theme resolution only — runtime theme changes are owned by
// Initial theme resolution only: runtime theme changes are owned by
// RomM.vue, which keeps Vuetify's name in sync with user preference.
// v2 surfaces don't read from Vuetify's runtime theme; they read tokens
// off `.r-v2-dark` / `.r-v2-light` on <html>.
+1 -1
View File
@@ -8,7 +8,7 @@ export interface ActivityEntry {
rom_name: string;
rom_cover_path: string;
// "Where they are" image (latest save screenshot / title / first
// screenshot); empty when none — the card then shows cover art.
// screenshot); empty when none: the card then shows cover art.
screenshot_path: string;
platform_slug: string;
platform_name: string;
+3 -3
View File
@@ -66,8 +66,8 @@ api.interceptors.response.use(
return response;
},
async (error) => {
// Mirror the success path's bookkeeping: a settled request — even a failed
// or canceled one — leaves the inflight set so `network-quiesced` can still
// Mirror the success path's bookkeeping: a settled request, even a failed
// or canceled one: leaves the inflight set so `network-quiesced` can still
// fire once the network goes quiet.
inflightRequests.delete(error.config?.url);
if (inflightRequests.size === 0) {
@@ -86,7 +86,7 @@ api.interceptors.response.use(
// * 5xx from a non-heartbeat endpoint → "suspect"; ask the connection
// layer to confirm via the authoritative /heartbeat probe (so one
// buggy endpoint doesn't flash the banner, and the heartbeat request
// itself never re-triggers this — which would loop).
// itself never re-triggers this: which would loop).
// * 4xx → backend is alive; emit backend-online so a stale offline banner
// clears immediately instead of waiting for the next heartbeat poll.
const status = error.response?.status as number | undefined;
+1 -1
View File
@@ -529,7 +529,7 @@ async function getRomSimple({
romId: number;
signal?: AbortSignal;
}) {
// `/roms/{id}/simple` — returns `SimpleRomSchema` with no eager-loaded
// `/roms/{id}/simple`: returns `SimpleRomSchema` with no eager-loaded
// notes / saves / states / screenshots / collections arrays. Designed
// for the v2 gallery card's per-card fetch path. Detail-level data is
// pulled on demand (game details page, quick-note dialog open).
+1 -1
View File
@@ -19,7 +19,7 @@ export type FilterType =
export type FilterLogicOperator = "any" | "all" | "none";
// Built lazily so `romStatusMap` isn't read at module-evaluation time —
// Built lazily so `romStatusMap` isn't read at module-evaluation time:
// utils ↔ stores have a circular import chain (utils → navigation → router →
// roms → galleryFilter → utils) that's harmless in the app shell because
// main.ts loads router first, but trips Storybook (no router boot) with a
+1 -1
View File
@@ -1,4 +1,4 @@
// permissionGroups — Pinia store holding the admin list of permission groups.
// permissionGroups: Pinia store holding the admin list of permission groups.
//
// Single source of truth shared by the groups table, the user create/edit
// dialogs and the users table, so creating or deleting a group is reflected
+2 -2
View File
@@ -1,9 +1,9 @@
// permissions — Pinia store holding the current user's effective grants.
// permissions: Pinia store holding the current user's effective grants.
//
// Hydrated from the backend's `/permissions/me` (the source of truth) via
// `installPermissionsHydration()` in AppLayout, and refreshed live on the
// `permissions:changed` socket event. v2 features consume this store via
// `useCan(action, scope?)`. v1 does not import from here — its existing inline
// `useCan(action, scope?)`. v1 does not import from here: its existing inline
// scope checks keep working.
import { defineStore } from "pinia";
import type {
+6 -6
View File
@@ -23,21 +23,21 @@ export type Events = {
showDeletePlatformDialog: Platform;
showCreateCollectionDialog: null;
showCreateSmartCollectionDialog: null;
/** v1 — opens the legacy AddRoms picker. The v2 equivalent is
/** v1: opens the legacy AddRoms picker. The v2 equivalent is
* `showManageCollectionsDialog`; this entry stays while v1 still
* consumes it.
* @deprecated v2 → use `showManageCollectionsDialog`. */
showAddToCollectionDialog: SimpleRom[];
/** v2 — opens ManageCollectionsDialog with the given selection. */
/** v2: opens ManageCollectionsDialog with the given selection. */
showManageCollectionsDialog: SimpleRom[];
/** v2 — fired by ManageCollectionsDialog when it closes, so the
/** v2: fired by ManageCollectionsDialog when it closes, so the
* GameActionBtn that opened it can drop its pinned-hover state. */
closeManageCollectionsDialog: null;
showRemoveFromCollectionDialog: SimpleRom[];
showDeleteCollectionDialog: Collection;
showDeleteSmartCollectionDialog: SmartCollection;
showMatchRomDialog: SimpleRom;
/** v2 — `rom` is optional; when provided, the dialog also fetches
/** v2: `rom` is optional; when provided, the dialog also fetches
* per-provider covers via `/search/roms` so the user can pick the
* IGDB / MobyGames / Screenscraper / … artwork without going
* through the manual-match flow. Collection-cover edits omit it. */
@@ -49,7 +49,7 @@ export type Events = {
updateUrlCover: string;
showEditRomDialog: SimpleRom;
showRefreshMetadataDialog: SimpleRom;
/** v2-only — bulk refresh of multiple ROMs from the SelectionBar.
/** v2-only: bulk refresh of multiple ROMs from the SelectionBar.
* The v2 RefreshMetadataDialog listens to both this and the single
* event; v1 keeps the single-rom contract. */
showRefreshMetadataDialogBulk: SimpleRom[];
@@ -125,7 +125,7 @@ export type Events = {
files: File[];
};
playGame: number;
// v2 only — generic confirmation dialog. Consumers go through
// v2 only: generic confirmation dialog. Consumers go through
// `useConfirm()`; the payload carries an id used to route the result
// back via the matching `confirmResolved` event.
showConfirm: {
+3 -3
View File
@@ -805,7 +805,7 @@ export function getNintendoDSFiles(
rom: DetailedRom | SimpleRom,
): RomFileSchema[] {
// `files` only ships on DetailedRom. Gallery surfaces pass SimpleRom,
// where the inner-file check is impossible — return an empty list so
// where the inner-file check is impossible: return an empty list so
// the caller falls back to the extension check.
const files = (rom as DetailedRom).files;
if (!files) return [];
@@ -824,8 +824,8 @@ export function getNintendoDSFiles(
* Check if a ROM is a valid NDS/3DS/DSi game.
*
* Accepts both SimpleRom (gallery cards) and DetailedRom (detail view).
* With SimpleRom the inner-file check is skipped — only the root
* extension counts — which is what every gallery surface can ever see.
* With SimpleRom the inner-file check is skipped, only the root
* extension counts, which is what every gallery surface can ever see.
*/
export function isNintendoDSRom(rom: DetailedRom | SimpleRom): boolean {
if (
@@ -6,7 +6,7 @@
// The whole card is a RouterLink to the game detail view, so it participates
// in spatial / gamepad nav (useWrapGridNav discovers it via the `a[href]` it
// renders). No "live" badge: every card in this view is an active session by
// definition, so the badge carried no per-card information — the page's
// definition, so the badge carried no per-card information: the page's
// session counter is the single live indicator.
import { RAvatar, RBtn, RIcon } from "@v2/lib";
import { useI18n } from "vue-i18n";
@@ -17,7 +17,7 @@ import GameCover from "@/v2/components/shared/GameCover.vue";
interface Props {
to: RouteLocationRaw;
coverSrc: string | null;
/** Cover-art URL for the corner PIP — set only when `coverSrc` is a
/** Cover-art URL for the corner PIP: set only when `coverSrc` is a
* screenshot, so the game stays identifiable. Null shows no PIP. */
pipCoverSrc?: string | null;
romName: string;
@@ -26,7 +26,7 @@ interface Props {
avatarSrc: string;
elapsedLabel: string;
deviceType: string;
/** Emulator core running the session, e.g. "RA Snes9x" — streaming
/** Emulator core running the session, e.g. "RA Snes9x": streaming
* sessions only. */
emulatorLabel?: string | null;
/** Admin-only: this is a streaming session an admin can force-release. */
@@ -52,7 +52,7 @@ const { t } = useI18n();
:title="romName"
class="activity-card__art"
>
<!-- Cover-art PIP — shown when the main image is a screenshot, so the
<!-- Cover-art PIP: shown when the main image is a screenshot, so the
game stays identifiable. -->
<CoverArtPip
v-if="pipCoverSrc"
@@ -105,7 +105,7 @@ const { t } = useI18n();
</template>
<style scoped>
/* No card surface — the cover is the card (gallery-card vocabulary): a
/* No card surface; the cover is the card (gallery-card vocabulary): a
rounded art box that lifts on hover, with the title/meta stacked below on
the bare page. */
.activity-card {
@@ -114,7 +114,7 @@ const { t } = useI18n();
gap: 8px;
text-decoration: none;
color: inherit;
/* Each card keeps its cover's natural width and wraps — no grow/shrink. */
/* Each card keeps its cover's natural width and wraps: no grow/shrink. */
flex: 0 0 auto;
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
// AppNav — the top navigation. Logo on the left, centred tab pill of
// AppNav: the top navigation. Logo on the left, centred tab pill of
// content destinations (Home / Platforms / Collections / Search), and
// a right cluster of utility chrome (scanning indicator, the mini player on
// phones, user menu).
@@ -8,7 +8,7 @@
// light up the Home tab.
//
// Library tools (Scan / Upload) are administrative actions, not content
// destinations — they live in the user menu's Library group, keeping the
// destinations: they live in the user menu's Library group, keeping the
// primary nav focused on browsing destinations.
import { RSliderBtnGroup, RImg } from "@v2/lib";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
@@ -32,7 +32,7 @@ const { destinations: tabs, activeId: activeTab } = useNavDestinations();
// bg / cover art reads cleanly. Once the user scrolls, a `::before`
// pseudo-element fades in carrying the glass surface (bg + blur). The
// blur is **static** on the pseudo, with only `opacity` transitioning
// — transitioning `backdrop-filter` directly kept the blur layer alive
// transitioning `backdrop-filter` directly kept the blur layer alive
// and any hover repaint nearby would flash it.
const { innerScrolled, innerGlass, handoff, threshold } = useNavGlass();
const windowScrolled = ref(false);
@@ -103,7 +103,7 @@ onBeforeUnmount(() => {
The glass (bg + blur) lives on a `::before` pseudo-element so we
can transition only its **opacity** between scrolled / not-scrolled
instead of transitioning `backdrop-filter` directly. Transitioning
backdrop-filter is notoriously expensive — the browser keeps the
backdrop-filter is notoriously expensive: the browser keeps the
blur layer "alive" the whole time and any nearby hover repaint
invalidates it, producing a one-frame flicker. Static blur +
opacity-only transition keeps the compositor layer stable. */
@@ -211,7 +211,7 @@ onBeforeUnmount(() => {
centre pill is dropped from the top nav and the bar keeps only the
logo (far left) + user cluster (far right). With just those two
children, collapse to a 2-column grid so the cluster tracks the right
edge — the default `1fr auto 1fr` would auto-place it into the now-
edge: the default `1fr auto 1fr` would auto-place it into the now-
empty centre column instead of the trailing one. The gutter follows
`--r-row-pad` (20px sm, 14px xs) so logo + user line up with the Home
sections' content edge. */
@@ -1,9 +1,9 @@
<script setup lang="ts">
// BackendStatusBanner — soft-red connection notice. Invisible while the
// BackendStatusBanner: soft-red connection notice. Invisible while the
// backend is healthy; surfaces a compact, centred toast-style card near the
// top the moment the heartbeat probe (or a request) reports the backend as
// down/broken. Unlike a redirect to /login, the user can keep navigating
// wherever the cached state allows — the card just explains the degraded
// wherever the cached state allows: the card just explains the degraded
// state and the connection layer auto-recovers.
//
// Calling `useServerConnection()` here in <script setup> both gives us the
@@ -1,5 +1,5 @@
<script setup lang="ts">
// BackgroundArt — the two-layer cross-fading backdrop. Views paint a cover
// BackgroundArt: the two-layer cross-fading backdrop. Views paint a cover
// URL via the injected `r-v2-set-background-art` provider; we swap between
// layer A and layer B so there's always a smooth transition, never a flash.
@@ -1,10 +1,10 @@
<script setup lang="ts">
// BottomNav — the primary-navigation pill, relocated to the bottom edge
// BottomNav: the primary-navigation pill, relocated to the bottom edge
// on phones (xs) so the four destinations sit within thumb reach. AppNav
// drops its centre pill on xs in turn (see AppNav.vue).
//
// This is the SAME RSliderBtnGroup tab pill the top nav uses — same glass
// surface, same border, same sliding active indicator — just pinned to
// This is the SAME RSliderBtnGroup tab pill the top nav uses: same glass
// surface, same border, same sliding active indicator: just pinned to
// the bottom and stretched full-width. No bespoke nav surface and no
// alternate layout, so the brand look and feel stay identical top vs
// bottom. Routing and universal input (keyboard / gamepad roving,
@@ -52,7 +52,7 @@ const { destinations, activeId } = useNavDestinations();
z-index: 100;
}
/* Transparent — the pill itself carries the glass surface (tab variant),
/* Transparent: the pill itself carries the glass surface (tab variant),
so it reads as a floating control matching the top nav. Side gutters
follow the responsive `--r-row-pad`; the safe-area inset keeps the pill
above a notched phone's home indicator. `pointer-events: none` on the
@@ -81,7 +81,7 @@ const { destinations, activeId } = useNavDestinations();
/* Each destination stretches to an equal quarter of the pill so the
active indicator spans its full cell and reaches close to the pill's
rounded background edges — and keeps doing so as the viewport widens,
rounded background edges: and keeps doing so as the viewport widens,
instead of leaving the items bunched in the centre. The indicator sits
inside the pill's 4px padding, so even with sub-pixel cell rounding it
never grazes the border. */
@@ -90,7 +90,7 @@ const { destinations, activeId } = useNavDestinations();
justify-content: center;
}
/* Icon-only on phones — four full-width inline labels won't fit, and the
/* Icon-only on phones: four full-width inline labels won't fit, and the
icon-only treatment matches the top-nav-on-xs precedent. The
router-link `aria-label` (set by RSliderBtnGroup from each item's
`ariaLabel`) stays the source of accessible naming. */
@@ -1,11 +1,11 @@
<script setup lang="ts">
// CrtWarmup — a purely cosmetic "CRT phosphor warm-up" easter egg, fired when
// CrtWarmup: a purely cosmetic "CRT phosphor warm-up" easter egg, fired when
// CRT mode is switched ON (see useCrtMode / Settings → User Interface → Theme). Plays a fast, glitchy,
// old-television power-on flash: instant black-out, a bright collapsing
// scanline, a phosphor bloom with chromatic-aberration tearing, a sync-roll
// band and jittering scanlines, then a quick settle back to the live app.
//
// Chrome-only — no stores/services/domain, no user-visible text (so it stays
// Chrome-only: no stores/services/domain, no user-visible text (so it stays
// trivially theme-agnostic). Teleported to <body> so it covers teleported
// overlays too, and rendered pointer-events:none so it never traps input.
// Auto-removes when the master timeline ends. Honours prefers-reduced-motion
@@ -14,7 +14,7 @@ import { nextTick, ref } from "vue";
const visible = ref(false);
// Bumping the key remounts the layer so the CSS animations restart from
// frame 0 — lets the user re-trigger the gimmick mid-flash.
// frame 0: lets the user re-trigger the gimmick mid-flash.
const runId = ref(0);
async function play() {
@@ -24,7 +24,7 @@ async function play() {
visible.value = true;
}
// `.self` so only the container's own master animation ends the run — child
// `.self` so only the container's own master animation ends the run: child
// layer animations bubble their animationend but must not cut the timeline
// short. The master animation is the longest, so it fires last.
function onEnded() {
@@ -78,7 +78,7 @@ defineExpose({ play });
animation-fill-mode: both;
}
/* White-hot core fading out to phosphor green — the main "flash". */
/* White-hot core fading out to phosphor green: the main "flash". */
.r-crt__bloom {
background: radial-gradient(
ellipse at center,
@@ -105,7 +105,7 @@ defineExpose({ play });
animation-timing-function: ease-in;
}
/* Chromatic-aberration tearing — red/cyan RGB-split fringes that jump
/* Chromatic-aberration tearing: red/cyan RGB-split fringes that jump
sideways in hard steps while the tube "locks on". */
.r-crt__glitch {
box-shadow:
@@ -1,9 +1,9 @@
<script setup lang="ts">
// DebugOverlay — developer-facing diagnostic panel pinned bottom-right
// DebugOverlay: developer-facing diagnostic panel pinned bottom-right
// (right side avoids clashing with the browser's own link-preview tooltip).
//
// Gated by `useDebugMode().enabled` (a per-device localStorage toggle in
// Settings → Developer), NOT by `import.meta.env.DEV` — the whole point is
// Settings → Developer), NOT by `import.meta.env.DEV`: the whole point is
// being able to flip it on against the deployed build to debug the live
// webui fast. It absorbs the old dev-only BreakpointBadge as its first row.
//
@@ -31,7 +31,7 @@ import { useInputModality } from "@/v2/composables/useInputModality";
import { useThemeMode } from "@/v2/composables/useThemeMode";
import { useVirtualScrollDebug } from "@/v2/composables/useVirtualScrollDebug";
// — Breakpoint + viewport ------------------------------------------------
// Breakpoint + viewport ------------------------------------------------
const { xs, smAndUp, mdAndUp, lgAndUp, xlAndUp } = useBreakpoint();
const { width, height } = useWindowSize();
const tier = computed(() => {
@@ -43,20 +43,20 @@ const tier = computed(() => {
return "—";
});
// — Input modality -------------------------------------------------------
// Input modality -------------------------------------------------------
const { modality } = useInputModality();
// — Theme ----------------------------------------------------------------
// Theme ----------------------------------------------------------------
const { isDark } = useThemeMode();
const themeLabel = computed(() => (isDark.value ? "v2-dark" : "v2-light"));
// — Route ----------------------------------------------------------------
// Route ----------------------------------------------------------------
const route = useRoute();
const routeName = computed(() =>
typeof route.name === "string" ? route.name : "—",
);
// — Gamepad + focus ------------------------------------------------------
// Gamepad + focus ------------------------------------------------------
const padConnected = ref(false);
const lastButton = ref("—");
@@ -77,7 +77,7 @@ useEventListener(
},
);
// Currently focused element — track via focusin (bubbles, unlike focus).
// Currently focused element: track via focusin (bubbles, unlike focus).
const focusEl = ref<Element | null>(null);
function describeFocus(el: Element | null): string {
if (!el || el === document.body || el === document.documentElement)
@@ -101,13 +101,13 @@ useEventListener(document, "focusout", () => {
focusEl.value = document.activeElement;
});
// — Virtual scroll -------------------------------------------------------
// Virtual scroll -------------------------------------------------------
// Published by the active virtualised gallery (null elsewhere). If windowing
// is healthy, `rendered` stays ~constant (viewport + 2×overscan) however far
// you scroll; a climbing value means the DOM isn't being trimmed.
const { stats: vstats } = useVirtualScrollDebug();
// — Performance ----------------------------------------------------------
// Performance ----------------------------------------------------------
const fps = useFps();
const { isSupported: memSupported, memory } = useMemory();
const heapMb = computed(() =>
@@ -85,7 +85,7 @@ const counterLabel = computed(() => {
<!-- Live progress bar pinned to the pill's bottom edge.
Determinate when totals are known, indeterminate
otherwise — both modes keep the bar visually busy. -->
otherwise: both modes keep the bar visually busy. -->
<RProgressLinear
class="r-scan-indicator__progress"
:indeterminate="!hasTotal"
@@ -156,7 +156,7 @@ const counterLabel = computed(() => {
white-space: nowrap;
}
/* Radar glyph stands in for the label on phones — hidden by default so
/* Radar glyph stands in for the label on phones: hidden by default so
the desktop pill keeps its word. */
.r-scan-indicator__icon {
display: none;
@@ -178,7 +178,7 @@ html[data-bp~="xs"] .r-scan-indicator__icon {
bottom: 0;
}
/* Enter / leave — opacity + slide-in from the right side, matching
/* Enter / leave: opacity + slide-in from the right side, matching
the prior implementation so navbar micro-interactions feel related. */
.r-scan-indicator-enter-active {
transition:
@@ -1,5 +1,5 @@
<script setup lang="ts">
// LoginForm — username + password form. Emits `submit` on valid submission;
// LoginForm: username + password form. Emits `submit` on valid submission;
// the parent owns the API call + snackbars so this stays purely presentational.
import { RBtn, RTextField } from "@v2/lib";
import { ref } from "vue";
@@ -1,5 +1,5 @@
<script setup lang="ts">
// OIDCButton — OIDC / SSO login button with the provider's dashboard-icon
// OIDCButton: OIDC / SSO login button with the provider's dashboard-icon
// (falls back to a generic key if the icon can't be found).
import { RBtn, RIcon, RImg } from "@v2/lib";
import { ref } from "vue";
@@ -1,5 +1,5 @@
<script setup lang="ts">
// ResetForm — username-only form that triggers a password-reset email.
// ResetForm: username-only form that triggers a password-reset email.
// Emits `done` on success and `cancel` when the user backs out.
import { RBtn, RTextField } from "@v2/lib";
import { ref } from "vue";
@@ -1,5 +1,5 @@
<script setup lang="ts">
// SetupStepAdmin — Step 2 of the setup wizard. Creates the first admin
// SetupStepAdmin: Step 2 of the setup wizard. Creates the first admin
// account that owns the library and manages other users.
//
// Two-column layout: left side carries the avatar picker + context (what
@@ -233,7 +233,7 @@ html[data-bp~="sm-and-down"] .r-setup-admin {
grid-template-columns: 1fr;
gap: var(--r-space-5);
/* The body clips (overflow: hidden) so this step scrolls its own content,
like the other steps — otherwise the stacked intro + form overflow and
like the other steps: otherwise the stacked intro + form overflow and
the last field (with its bottom margin) gets cut off. Align to the top so
the scroll starts at the first row; the layer promotion keeps the region
from collapsing when the form re-renders on input. */
@@ -258,7 +258,7 @@ html[data-bp~="sm-and-down"] .r-setup-admin__intro {
text-align: center;
}
/* Avatar — clickable round button sits next to a small trash icon that
/* Avatar: clickable round button sits next to a small trash icon that
removes the picked photo. The trash button stays disabled until a
photo is picked so the affordance is discoverable without inviting a
no-op click. It bottom-aligns next to the avatar so the danger glyph
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CollectionListHeader — column-header strip for the Collections
// CollectionListHeader: column-header strip for the Collections
// list-mode view. Mirrors GameListHeader's anatomy: each sortable
// column is a button that toggles asc → desc → asc on the parent's
// sort state via the `sort` event.
@@ -46,7 +46,7 @@ const sortOptions = computed(() =>
function handleClick(col: CollectionListColumn) {
if (!col.sortKey) return;
// Toggle direction when re-clicking the active column; otherwise
// start the new column at ascending — same rule as GameListHeader.
// start the new column at ascending: same rule as GameListHeader.
const nextDir: "asc" | "desc" =
props.sortKey === col.sortKey && props.sortDir === "asc" ? "desc" : "asc";
emit("sort", { key: col.sortKey, dir: nextDir });
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CollectionListRow — single row of the Collections list-mode index.
// CollectionListRow: single row of the Collections list-mode index.
//
// Anatomy mirrors GameListRow: cover mosaic + name on the left, kind
// chip in the middle, game count on the right. A public collection's
@@ -64,7 +64,7 @@ const morphStyle = computed(() =>
: undefined,
);
// Kind cell — icon + label for every kind (the list view can afford the
// Kind cell: icon + label for every kind (the list view can afford the
// text). Icons match the grid cards.
const kindBadge = computed<{ icon: string; label: string }>(() => {
if (props.kind === "smart") {
@@ -277,7 +277,7 @@ function onRowClick(e: MouseEvent) {
text-overflow: ellipsis;
}
/* Smart = accent (warm), virtual = brand (cool) — so the two computed
/* Smart = accent (warm), virtual = brand (cool): so the two computed
kinds read apart at a glance. */
.coll-list-row__kind--smart {
color: var(--r-color-brand-accent);
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CollectionMosaic — 2×2 cover grid for collection artwork. Feature
// CollectionMosaic: 2×2 cover grid for collection artwork. Feature
// component used by CollectionTile and the collection info panel.
// Behaviour:
// * 0 covers → empty state with a bookmark glyph
@@ -30,7 +30,7 @@ const displayCovers = computed(() =>
(props.covers ?? []).filter((c): c is string => Boolean(c)),
);
// Mosaic slots — if we have 2 or 3 covers, cycle through them so every
// Mosaic slots: if we have 2 or 3 covers, cycle through them so every
// slot is filled. Deterministic (index % len) instead of random so the
// mosaic doesn't shuffle between re-renders.
const mosaicSlots = computed(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CollectionPickerRow — one row in the ManageCollectionsDialog. Portrait
// CollectionPickerRow: one row in the ManageCollectionsDialog. Portrait
// thumb (globed when public) + name + rom-count + brand-primary circular
// tick when checked.
// Click toggles; parent owns the pending/checked state and handles the
@@ -111,7 +111,7 @@ const isFull = computed(() => props.state === "all");
cursor: progress;
}
/* Portrait thumb — width tracks the configurable tile size; height is
/* Portrait thumb: width tracks the configurable tile size; height is
computed by CollectionMosaic from its 140/188 aspectRatio. */
.pick-row__thumb {
position: relative;
@@ -161,7 +161,7 @@ const isFull = computed(() => props.state === "all");
color: var(--r-color-overlay-fg);
}
/* Indeterminate (`some`) — the brand colour reads as a hint but the
/* Indeterminate (`some`): the brand colour reads as a hint but the
tick swaps to a horizontal dash so users can tell "partially in" from
"fully in" at a glance. */
.pick-row--partial .pick-row__tick {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CollectionTile — collection card (Home row + /collections grid). Feature
// CollectionTile: collection card (Home row + /collections grid). Feature
// composite that wraps CollectionMosaic + name + count. `kind` overlays
// "Smart" or "Virtual" badge. `variant` controls sizing ("row" = 150px
// fixed, "grid" = fills cell).
@@ -29,7 +29,7 @@ interface Props {
/** Owner-set visibility; public ones carry the globe. Virtual
* collections are autogenerated and never carry a public/private state. */
isPublic?: boolean;
/** Stable id used to derive the shared-element morph tag. Optional —
/** Stable id used to derive the shared-element morph tag. Optional:
* if absent the tile still navigates but won't participate in the
* morph (some callers pass a virtual/smart wrapper object). */
id?: number | string;
@@ -46,7 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
const { t } = useI18n();
// Kind badge — smart/virtual collapse to an icon chip; the tooltip
// Kind badge: smart/virtual collapse to an icon chip; the tooltip
// carries the word. Regular collections show no kind badge.
const kindBadge = computed<{ icon: string; label: string } | null>(() => {
if (props.kind === "smart") {
@@ -149,7 +149,7 @@ const morphStyle = computed(() =>
box-shadow var(--r-motion-fast);
}
/* Hover gated to mouse/touch — keeps a parked cursor from competing
/* Hover gated to mouse/touch: keeps a parked cursor from competing
with the focused tile when driving with a gamepad. */
html:not([data-input="pad"]) .coll-tile:hover .coll-tile__cover,
.coll-tile:focus-visible .coll-tile__cover {
@@ -157,7 +157,7 @@ html:not([data-input="pad"]) .coll-tile:hover .coll-tile__cover,
box-shadow: var(--r-elev-3);
}
/* Keyboard / gamepad focus — ring + brand glow on the cover, brighten
/* Keyboard / gamepad focus: ring + brand glow on the cover, brighten
the name. Matches the v1 console card visual language. */
.coll-tile:focus-visible {
outline: none;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// NewCollectionRow — the "create collection" CTA row inside the
// NewCollectionRow: the "create collection" CTA row inside the
// ManageCollectionsDialog. Collapsed: purple-tinted plus-tile + label.
// Expanded: inline name input and visibility switch with Create / Cancel
// actions.
@@ -157,13 +157,13 @@ function onInput(e: Event) {
/* Edge-to-edge CTA row matching the picker rows below. The host
dialog (`ManageCollectionsDialog`) drops the standard RDialog body
padding AND we drop the row's horizontal padding too, so the tile
and actions go flush with the dialog walls — same "menu items, not
and actions go flush with the dialog walls: same "menu items, not
padded cards" idiom as RMenu. */
.new-row {
display: grid;
/* First column tracks the configurable tile width so the label always
starts past the tile, even when the consumer makes the tile bigger.
Third column is `auto` — sizes to the actions when present, collapses
Third column is `auto`: sizes to the actions when present, collapses
to 0 when the Transition unmounts them. */
grid-template-columns: var(--tile-w, 36px) 1fr auto;
align-items: center;
@@ -184,7 +184,7 @@ function onInput(e: Event) {
background: color-mix(in srgb, var(--r-color-brand-primary) 5%, transparent);
}
/* Portrait plus-tile — matches the CollectionMosaic footprint of the
/* Portrait plus-tile: matches the CollectionMosaic footprint of the
picker rows so the first column aligns whether you're looking at the
CTA or an existing collection thumb. */
.new-row__tile {
@@ -207,7 +207,7 @@ function onInput(e: Event) {
color: var(--r-color-overlay-fg);
}
/* Label-as-button — left-aligned, no chrome so it reads as a row label
/* Label-as-button: left-aligned, no chrome so it reads as a row label
that happens to be focusable. */
.new-row__cta {
appearance: none;
@@ -262,7 +262,7 @@ function onInput(e: Event) {
color: color-mix(in srgb, var(--r-color-fg) 35%, transparent);
}
/* Cancel/Create transition — slides in from the right on expand and
/* Cancel/Create transition: slides in from the right on expand and
slides back out on cancel. Both directions animate because the
actions cluster lives outside the form, so its Vue Transition leave
isn't cut short by a form unmount. */
@@ -11,7 +11,7 @@ export type CollectionListSortKey = "name" | "kind" | "rom_count";
export interface CollectionListColumn {
/** Unique column id. */
key: CollectionListSortKey;
/** i18n key for the header label — resolved in CollectionListHeader. */
/** i18n key for the header label: resolved in CollectionListHeader. */
labelKey: string;
/** Sort axis when the column is sortable; omitted for display-only columns. */
sortKey?: CollectionListSortKey;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// ControllerPad — renders a single connected Gamepad as the silhouette of
// ControllerPad: renders a single connected Gamepad as the silhouette of
// a generic controller: triggers + shoulders top corners, D-pad bottom-
// left, face buttons in a diamond bottom-right, sticks below their
// respective halves, and Back / Home / Start in the centre. Each element
@@ -277,7 +277,7 @@ function magnitude(x: number, y: number) {
</template>
<style scoped>
/* Outer wrapper centres the silhouette and caps its width — at full
/* Outer wrapper centres the silhouette and caps its width: at full
container width the layout starts looking sparse. */
.r-v2-pad__wrap {
padding: 24px 16px;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// v2 AboutDialog — emitter-driven. Replaces the v1 AboutDialog in the v2
// v2 AboutDialog: emitter-driven. Replaces the v1 AboutDialog in the v2
// GlobalDialogs stack so the "About" entry in UserMenu renders the v2 glass
// panel instead of the legacy card.
import { RDialog, RIcon, RImg, RTooltip } from "@v2/lib";
@@ -1,5 +1,5 @@
<script setup lang="ts">
// AddPhysicalGameDialog — manually add a game you own physically (cartridge,
// AddPhysicalGameDialog: manually add a game you own physically (cartridge,
// disc, boxed copy) that has no file on disk. The backend stores it as a
// file-less Rom (is_physical=true) and auto-links metadata by name/UPC in a
// single quick scan.
@@ -1,5 +1,5 @@
<script setup lang="ts">
// v2 ChangelogDialog — emitter-driven. Fetches the latest releases from
// v2 ChangelogDialog: emitter-driven. Fetches the latest releases from
// the public GitHub API on first open and reuses the cached payload on
// subsequent opens within the same session. Each release renders as a
// glass-panel block (tag + date) with the release body rendered through
@@ -269,7 +269,7 @@ function closeDialog() {
min-width: 0;
overflow: hidden;
}
/* md-editor surface tweaks — the preview ships its own white card; we
/* md-editor surface tweaks: the preview ships its own white card; we
strip it and let the tinted body wrapper provide the surface. Same
approach NotesTab uses. */
.r-v2-changelog__body :deep(.md-editor),
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CopyDownloadLinkDialog — fallback surface for `useGameActions.copyDownloadLink`
// CopyDownloadLinkDialog: fallback surface for `useGameActions.copyDownloadLink`
// when the Clipboard API isn't usable (insecure context, denied
// permission, older browsers). Renders the link in a selectable box so
// the user can copy it by hand, and offers a Retry button that tries
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CreateSmartCollectionDialog — captures the current galleryFilter state
// CreateSmartCollectionDialog: captures the current galleryFilter state
// as a smart collection. v2 replacement for v1's
// `components/common/Collection/Dialog/CreateSmartCollection.vue`.
//
@@ -7,7 +7,7 @@
// • Emitter fires `showCreateSmartCollectionDialog` (typically from
// the FilterDrawer footer CTA).
// • If no filters are active we refuse to open and surface a snackbar
// hint — same guard v1 had, just routed through the v2 channel.
// hint: same guard v1 had, just routed through the v2 channel.
// • The dialog snapshots `galleryFilter` once on open (so further
// toggles in the gallery don't leak into the preview) and lets the
// user name / describe / mark public.
@@ -65,14 +65,14 @@ const isPublic = ref(false);
// Snapshot the criteria once when the dialog opens. We don't want the
// preview to keep updating as the user toggles filters in the gallery
// behind the dialog — what the user saw at the moment they clicked the
// behind the dialog: what the user saw at the moment they clicked the
// CTA is what gets saved.
const snapshot = ref<SmartFilterCriteria>({});
const nameRules = [required(t("common.required"))];
const openHandler = () => {
// v1 guarded with a snackbar warning here — keep that behaviour so
// v1 guarded with a snackbar warning here: keep that behaviour so
// the user gets a hint instead of an empty preview.
const next = buildSmartFilterCriteria(
{
@@ -361,7 +361,7 @@ html[data-bp~="sm-and-up"] .r-v2-csc__grid {
border-radius: var(--r-radius-md);
min-height: 200px;
/* Bound the panel so a filter-heavy snapshot can't stretch the dialog
past the viewport — the head stays pinned, the list scrolls. */
past the viewport: the head stays pinned, the list scrolls. */
max-height: clamp(200px, 48vh, 420px);
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
// DeleteManualDialog — confirms removing the primary manual from a ROM or
// DeleteManualDialog: confirms removing the primary manual from a ROM or
// a single manual file (multi-manual ROMs). The emitter payload picks the
// scope.
import { RBtn, RDialog, RIcon } from "@v2/lib";
@@ -1,8 +1,8 @@
<script setup lang="ts">
// ManageCollectionsDialog — collection picker that matches the mockup's
// ManageCollectionsDialog: collection picker that matches the mockup's
// menu-panel shape. Driven by the `showManageCollectionsDialog` emitter.
//
// Uses the v2 RDialog primitive — RDialog shares the RMenu visual
// Uses the v2 RDialog primitive: RDialog shares the RMenu visual
// language (14px radius, deep glass, menu-style shadow) so this picker
// reads as a sibling of the user menu / ROM context menu.
//
@@ -14,7 +14,7 @@
// "New Collection" label. Click expands to an inline Create /
// Cancel input.
// * Divider.
// * List of owned collections — avatar, name, count, brand-primary
// * List of owned collections: avatar, name, count, brand-primary
// circular tick when on. Clicking toggles INSTANTLY (optimistic
// update + API call; reverts on failure).
// * Empty state if no collections exist.
@@ -60,7 +60,7 @@ const roms = ref<SimpleRom[]>([]);
const pendingCollections = ref(new Set<number>());
// Optimistic state is now tri-state (off | some | all). When the user
// clicks a row we paint the *resolved* next state immediately and
// reconcile when the API responds — `undefined` means "fall back to
// reconcile when the API responds: `undefined` means "fall back to
// the collection's actual membership computed against `roms.value`".
const optimistic = ref(new Map<number, "off" | "some" | "all">());
@@ -86,7 +86,7 @@ emitter?.on("showManageCollectionsDialog", openHandler);
onBeforeUnmount(() => emitter?.off("showManageCollectionsDialog", openHandler));
// Notify any GameActionBtn that opened us so it can drop its pinned-hover
// state — covers every close path (X, scrim, Escape, programmatic close).
// state: covers every close path (X, scrim, Escape, programmatic close).
watch(show, (open) => {
if (!open) emitter?.emit("closeManageCollectionsDialog", null);
});
@@ -94,7 +94,7 @@ watch(show, (open) => {
/** Resolve a collection's membership against the open dialog's
* selection. "all" when every selected rom is in the collection,
* "some" when at least one (but not all) is, "off" otherwise. The
* "some" state only appears for bulk dialogs — a single-rom dialog
* "some" state only appears for bulk dialogs: a single-rom dialog
* collapses to off / all. */
function membershipState(collection: Collection): "off" | "some" | "all" {
const override = optimistic.value.get(collection.id);
@@ -125,7 +125,7 @@ async function toggle(collection: Collection) {
pendingCollections.value.add(collection.id);
// For the "some → all" transition we still issue an add against all
// selected ids — the backend de-dupes against existing membership,
// selected ids: the backend de-dupes against existing membership,
// so this is safe and saves a per-id diff round-trip from the
// frontend.
//
@@ -205,7 +205,7 @@ const subtitle = computed(() => {
const ownedCollections = computed(() => collectionsStore.ownedCollections);
// Header cover thumbnail — single-rom invocations get the game's cover
// Header cover thumbnail: single-rom invocations get the game's cover
// alongside the title (same `<GameCard size="xs" decorative>` shape
// the list-mode row uses, so the header reads as a sibling of the
// gallery surface). Bulk invocations stay text-only since one cover
@@ -268,7 +268,7 @@ function closeDialog() {
<RDivider v-if="ownedCollections.length > 0" full-width />
<!-- Existing collection rows — instant toggle, no commit step. -->
<!-- Existing collection rows: instant toggle, no commit step. -->
<ul v-if="ownedCollections.length" class="r-v2-mng-coll__list">
<li v-for="collection in ownedCollections" :key="collection.id">
<CollectionPickerRow
@@ -288,7 +288,7 @@ function closeDialog() {
</template>
<style scoped>
/* Header — optional GameCard thumbnail + stacked title (14px bold) +
/* Header: optional GameCard thumbnail + stacked title (14px bold) +
subtitle (11.5px muted) inside RDialog's single header slot. */
.r-v2-mng-coll__head {
display: flex;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// RefreshMetadataDialog — kicks off a per-ROM (or bulk) metadata
// RefreshMetadataDialog: kicks off a per-ROM (or bulk) metadata
// re-scan. Shares the visual vocabulary of the Scan view config card:
// provider selects split into General / Specific, hash-matcher proxies
// rendered as switch pills, and a scan-type select with two per-ROM-
@@ -34,7 +34,7 @@ const { t } = useI18n();
const emitter = inject<Emitter<Events>>("emitter");
const snackbar = useSnackbar();
const show = ref(false);
// Accept either a single rom or an array — the SelectionBar passes
// Accept either a single rom or an array: the SelectionBar passes
// many at once, individual menus pass one. Internally we always
// normalise to an array so the scan emit groups by platform without
// branching on the input shape.
@@ -147,7 +147,7 @@ const singleRomTitle = computed(() => {
function onScan() {
if (roms.value.length === 0) return;
// Group rom ids by platform — the scan socket event accepts one
// Group rom ids by platform: the scan socket event accepts one
// platform list + one rom-id list, so a selection that spans
// multiple platforms is fanned into N events, one per platform.
const byPlatform = new Map<number, number[]>();
@@ -205,7 +205,7 @@ function closeDialog() {
</template>
<template #content>
<div class="r-v2-refresh">
<!-- ROM identity row — cover + name for the single-ROM case
<!-- ROM identity row: cover + name for the single-ROM case
(visual consistency with EditRomDialog / DeleteRomDialog),
count chip for bulk. -->
<div v-if="singleRom" class="r-v2-refresh__rom">
@@ -237,7 +237,7 @@ function closeDialog() {
<span>{{ t("rom.selection-count", { n: roms.length }) }}</span>
</div>
<!-- 1. Providers section — General + Specific RSelects, sharing
<!-- 1. Providers section: General + Specific RSelects, sharing
one `metadataSources` model. Both render icon-only chips so
a multi-select stays visually quiet in the activator. -->
<section class="r-v2-refresh__section">
@@ -298,7 +298,7 @@ function closeDialog() {
</div>
</div>
<!-- LaunchBox Local/Cloud toggle — inline inside its
<!-- LaunchBox Local/Cloud toggle: inline inside its
dropdown row, disabled until LaunchBox itself is
selected. Same pattern as Scan.vue. -->
<div
@@ -394,7 +394,7 @@ function closeDialog() {
</div>
</section>
<!-- 2. Proxies (hash matchers) — compact switch pills, same as
<!-- 2. Proxies (hash matchers): compact switch pills, same as
the Scan view. -->
<section class="r-v2-refresh__section">
<h3 class="r-v2-refresh__section-title">
@@ -443,7 +443,7 @@ function closeDialog() {
</div>
</section>
<!-- 3. Scan type — per-ROM friendly options. -->
<!-- 3. Scan type: per-ROM friendly options. -->
<section class="r-v2-refresh__section">
<h3 class="r-v2-refresh__section-title">
{{ t("scan.section-scan-type") }}
@@ -508,7 +508,7 @@ function closeDialog() {
gap: 16px;
}
/* ROM identity row — cover + name + filename. Mirrors the row layout
/* ROM identity row: cover + name + filename. Mirrors the row layout
in DeleteRomDialog so the two "do-something-with-this-ROM" dialogs
read as siblings. */
.r-v2-refresh__rom {
@@ -576,7 +576,7 @@ function closeDialog() {
align-self: flex-start;
}
/* Section vocabulary — small uppercase label above the controls,
/* Section vocabulary: small uppercase label above the controls,
hairline divider between sections. Same rhythm as Scan.vue. */
.r-v2-refresh__section {
display: flex;
@@ -598,7 +598,7 @@ function closeDialog() {
color: var(--r-color-fg-muted);
}
/* Provider groups (General / Specific) — same layout as Scan.vue: a
/* Provider groups (General / Specific) use the same layout as Scan.vue: a
small caption above each RSelect, two groups stacked with a tight
inter-group margin. */
.r-v2-refresh__providers-group {
@@ -616,7 +616,7 @@ function closeDialog() {
color: var(--r-color-fg-faint);
}
/* Icon-only chip rendered in the activator — keeps the multi-select
/* Icon-only chip rendered in the activator: keeps the multi-select
visually quiet when many providers are picked. */
.r-v2-refresh__provider-chip {
display: inline-flex;
@@ -624,7 +624,7 @@ function closeDialog() {
justify-content: center;
}
/* Hash matcher pills — same compact icon + switch rows as Scan.vue. */
/* Hash matcher pills: same compact icon + switch rows as Scan.vue. */
.r-v2-refresh__matchers {
display: flex;
flex-direction: row;
@@ -69,7 +69,7 @@ const show = ref(false);
const searching = ref(false);
const searchText = ref("");
const covers = ref<SearchCoverSchema[]>([]);
// Source rom for the in-flight search — drives the optional
// Source rom for the in-flight search: drives the optional
// `/search/roms` companion call. Reset on close so a follow-up open
// without a `rom` payload (e.g. from CollectionSettingsDrawer) doesn't
// inherit a stale rom.
@@ -101,7 +101,7 @@ function gridProviderLogo(key: CoverProvider): string {
// Client-side filtering + sorting over the two fetched lists. The
// backend returns every content variant (NSFW / humor / epilepsy) with
// its per-cover metadata in one call, so the controls just narrow the
// already-loaded results — no re-fetch.
// already-loaded results: no re-fetch.
const {
coverType,
resolutionFilter,
@@ -138,7 +138,7 @@ const sortLabel = computed(
() => sortItems.value.find((item) => item.id === sortMode.value)?.label,
);
// SGDB animated covers ship their `thumb` as a `.webm` clip — an `<img>`
// SGDB animated covers ship their `thumb` as a `.webm` clip: an `<img>`
// element can't render those (broken-image icon). Detect by type or
// extension and swap to a `<video>` for those resources so the preview
// actually plays.
@@ -247,8 +247,8 @@ async function doSearch() {
) {
const results = providersResult.value.data;
// Prefer the result that shares the most IDs with our rom (the
// same identified game). When no result matches by ID — e.g. an
// unidentified rom — fall back to the first one so the user
// same identified game). When no result matches by ID: e.g. an
// unidentified rom: fall back to the first one so the user
// still gets something. `0` score with a populated `results`
// array still picks `results[0]`.
const best = [...results]
@@ -291,7 +291,7 @@ function pickCover(url: string, provider: CoverProvider) {
}
// Provider covers (IGDB / Moby / SS / …) already come at full
// resolution from `/search/roms`, so no thumb-→-grid swap — just hand
// resolution from `/search/roms`, so no thumb-→-grid swap: just hand
// the URL off to the consumer.
function pickProviderCover(url: string) {
emitter?.emit("updateUrlCover", url);
@@ -505,7 +505,7 @@ function closeDialog() {
</div>
<div v-else-if="hasResults" class="r-v2-sgdb__results">
<!-- Provider covers — one card per metadata source that
<!-- Provider covers: one card per metadata source that
returned a URL for this rom. Renders only when the
dialog was opened with a `rom` payload; the provider
logo overlays the card so the user can read at a
@@ -696,7 +696,7 @@ html[data-bp~="xs"] .r-v2-sgdb__content-toggles {
gap: 10px;
}
/* Flow-pack of cover cards — each tile adopts its cover's natural aspect
/* Flow-pack of cover cards: each tile adopts its cover's natural aspect
(fixed height, width follows the art), like the gallery cards, instead of
a rigid uniform-width grid. */
.r-v2-sgdb__grid {
@@ -730,7 +730,7 @@ html[data-bp~="xs"] .r-v2-sgdb__content-toggles {
}
.r-v2-sgdb__cover-img {
display: block;
/* Fixed height, natural width — the card takes the cover's true aspect,
/* Fixed height, natural width: the card takes the cover's true aspect,
never cropped. `max-width` caps the rare ultra-wide cover to the tile so
it letterboxes instead of overflowing past its rounded corners. */
height: 180px;
@@ -739,7 +739,7 @@ html[data-bp~="xs"] .r-v2-sgdb__content-toggles {
object-fit: contain;
}
/* Provider logo overlay — top-right corner badge on the cover so the
/* Provider logo overlay: top-right corner badge on the cover so the
user sees at a glance which metadata source the artwork belongs to. */
.r-v2-sgdb__cover-provider {
position: absolute;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// ShowQRCodeDialog — emitter-driven QR for downloading a single ROM from a
// ShowQRCodeDialog: emitter-driven QR for downloading a single ROM from a
// handheld/phone. The qrcode library renders into the canvas directly
// after the dialog opens (nextTick so the canvas is in the DOM).
import { RDialog } from "@v2/lib";
@@ -1,10 +1,10 @@
<script setup lang="ts">
// AdditionalDetails (v2) — manual metadata overrides for the edit-ROM
// AdditionalDetails (v2): manual metadata overrides for the edit-ROM
// dialog. Companies / genres / franchises / game modes / age ratings
// are multi-tag combobox fields; first release date is a date picker;
// YouTube video ID is a plain text field.
//
// All writes go through `manual_metadata` — the backend layer where
// All writes go through `manual_metadata`: the backend layer where
// admins override automatic scraper output. Emitting `update:rom` with
// the merged shape keeps the parent in charge of when to actually
// commit (Save button on EditRomDialog).
@@ -54,7 +54,7 @@ const GAME_MODE_ITEMS = computed(() => [
const manual = computed(() => props.rom.manual_metadata || {});
// Age ratings are stored as `SYSTEM:RATING` strings — convert to the
// Age ratings are stored as `SYSTEM:RATING` strings: convert to the
// human-readable `SYSTEM - RATING` shape the combobox uses, and back.
const ageRatingsView = computed(() =>
(manual.value.age_ratings ?? []).map((r) => r.replace(":", " - ")),
@@ -1,11 +1,11 @@
<script setup lang="ts">
// MetadataIdSection (v2) — provider ID inputs for the edit-ROM dialog.
// MetadataIdSection (v2): provider ID inputs for the edit-ROM dialog.
// Each field maps a single metadata provider's ID (IGDB / MobyGames /
// ScreenScraper / RetroAchievements / LaunchBox / SteamGridDB /
// Hasheous / Flashpoint / HowLongToBeat) so admins can manually fix
// up mismatched scrapes.
//
// Feature composite — knows UpdateRom; lives under
// Feature composite: knows UpdateRom; lives under
// `components/EditRom/` next to its siblings.
import { RTextField } from "@v2/lib";
import { computed } from "vue";
@@ -22,7 +22,7 @@ const emit = defineEmits<{
const { t } = useI18n();
// `field` is keyof UpdateRom; the parsed value is `number | string |
// null` — most providers use numeric ids, Flashpoint stores a string.
// null`: most providers use numeric ids, Flashpoint stores a string.
function updateField(field: keyof UpdateRom, value: string | number | null) {
emit("update:rom", { ...props.rom, [field]: value });
}
@@ -41,7 +41,7 @@ function parseIdValue(
return Number.isNaN(n) ? null : n;
}
// Field config — keeps the markup tight and lets us tweak the layout
// Field config: keeps the markup tight and lets us tweak the layout
// without re-hand-writing nine identical RTextField blocks.
interface IdField {
key: keyof UpdateRom;
@@ -1,12 +1,12 @@
<script setup lang="ts">
// RawMetadataPanel (v2) — textarea + action row for one provider's raw
// RawMetadataPanel (v2): textarea + action row for one provider's raw
// metadata payload. The visible "what provider is this" affordance is
// owned by whoever hosts the panel (today: the active tab in
// EditRomDialog's RTabNav, which shows the provider logo + label).
// That lets this component drop a redundant title and keep the surface
// quiet — the JSON is the content.
// quiet: the JSON is the content.
//
// Feature composite — knows the UpdateRom shape and the emitter event
// Feature composite: knows the UpdateRom shape and the emitter event
// bus. Renders nothing if the rom has no stored data for the configured
// provider.
import { RBtn, RTextField } from "@v2/lib";
@@ -57,7 +57,7 @@ function cancelEdit() {
}
function saveMetadata() {
// Empty wipe — reset this provider's slot to `{}` in raw_metadata.
// Empty wipe: reset this provider's slot to `{}` in raw_metadata.
if (!metadataJson.value || metadataJson.value.trim() === "") {
emit("update:rom", {
...props.rom,
@@ -90,7 +90,7 @@ function saveMetadata() {
}
}
// aria-only — RTextField turns `label` into the input's aria-label when
// aria-only: RTextField turns `label` into the input's aria-label when
// no `prefix-label` is set. No visible chrome rendered for it.
const ariaLabel = computed(() => `${props.label} ${t("rom.metadata")} JSON`);
</script>
@@ -1,11 +1,11 @@
<script setup lang="ts">
// AlphaStrip — A-Z-# jump sidebar for letter-grouped grids (Platform and
// Collection gallery). Feature composite — not a design-system primitive.
// AlphaStrip: A-Z-# jump sidebar for letter-grouped grids (Platform and
// Collection gallery). Feature composite: not a design-system primitive.
//
// Two highlight signals are supported:
// * `current` — single letter used to mark a deliberate jump (e.g. the
// * `current`: single letter used to mark a deliberate jump (e.g. the
// user clicked "F"). One active at a time.
// * `visible` — a Set of every letter whose section currently intersects
// * `visible`: a Set of every letter whose section currently intersects
// the viewport. Multiple letters light up together when the first row
// of the grid spans several groups (A, B, C, …).
//
@@ -22,9 +22,9 @@ defineOptions({ inheritAttrs: false });
const { t } = useI18n();
// Two non-alphabetic buckets bookend the alphabet:
// * `#` — digits (0-9) — sits BEFORE A
// * `@` — any other non-alphanumeric first character — sits AFTER Z
// Order in asc: `# A B … Z @` — `#` at the top, `@` at the bottom.
// * `#`, digits (0-9), sits BEFORE A
// * `@`, any other non-alphanumeric first character, sits AFTER Z
// Order in asc: `# A B … Z @`, `#` at the top, `@` at the bottom.
// When the gallery sorts desc the whole array reverses (`@` to the top,
// `#` to the bottom) so the strip's visual order tracks the data's
// order and the scroll-spy highlight follows the scroll direction.
@@ -34,7 +34,7 @@ interface Props {
available?: Set<string> | string[];
current?: string;
visible?: Set<string> | string[];
/** Render order — reversed when the gallery sorts descending. */
/** Render order: reversed when the gallery sorts descending. */
direction?: "asc" | "desc";
/** Lay the letters out as a wrapping grid instead of a column. */
grid?: boolean;
@@ -140,7 +140,7 @@ watch(
overflow-y: auto;
overscroll-behavior: contain;
padding: 8px 0;
/* Breathe away from the viewport edge — the strip shouldn't touch the
/* Breathe away from the viewport edge: the strip shouldn't touch the
right border of the gallery section. */
margin-right: var(--r-alpha-strip-gap, 12px);
user-select: none;
@@ -175,7 +175,7 @@ watch(
background: var(--r-color-surface);
}
/* Scroll-spied letter — primary brand colour to stand out against the
/* Scroll-spied letter: primary brand colour to stand out against the
plain-white `--has` letters. */
.alpha-strip__btn--current,
.alpha-strip__btn--has.alpha-strip__btn--current {
@@ -1,11 +1,11 @@
<script setup lang="ts">
// CollectionHead — the collection-context strip that sits above the
// CollectionHead: the collection-context strip that sits above the
// gallery / settings tab body. Mirrors `PlatformHead` in shape: an
// InfoPanel (4-cover mosaic + eyebrow + name + description chip +
// stats) plus the RTabNav underneath. Re-used in two render branches
// inside Collection.vue:
//
// 1. Library tab — passed to `GalleryShell`'s `#header` slot so the
// 1. Library tab: passed to `GalleryShell`'s `#header` slot so the
// head scrolls naturally with the cards (and the toolbar pins
// below it).
//
@@ -121,7 +121,7 @@ defineEmits<{
}
/* Match the collection-index card covers (which fill a ~150px grid cell on
phones) — the InfoPanel stacks and centres the cover on xs, so there's room
phones): the InfoPanel stacks and centres the cover on xs, so there's room
for a proper hero instead of the old cramped 100px thumbnail. */
html[data-bp~="xs"] .r-v2-coll__panel-cover {
width: var(--r-coll-cover-w-xs);
@@ -130,7 +130,7 @@ html[data-bp~="xs"] .r-v2-coll__panel-cover {
.r-v2-coll__tabs {
/* Tuck the nav up against the InfoPanel's bottom padding so the
two read as a single head band — same vocabulary as PlatformHead. */
two read as a single head band: same vocabulary as PlatformHead. */
margin-top: -8px;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CollectionSettingsTab — collection-scoped settings rendered as the
// CollectionSettingsTab: collection-scoped settings rendered as the
// `Settings` tab inside Collection.vue. Same content surface as the
// previous `CollectionSettingsDrawer`, now lives inline in the
// collection view. One component, two branches (drawer-era split):
@@ -8,12 +8,12 @@
// (SteamGridDB search · file upload · remove), filter_criteria
// not applicable.
// * smart → name / description / public, filter_criteria displayed
// read-only (editing the criteria isn't surfaced here — happens
// read-only (editing the criteria isn't surfaced here: happens
// via the create-smart-collection flow).
//
// Delete moved into this tab's danger zone (was a kebab menu item in
// the pre-tabs design). The destructive flow is still owned by the
// parent view — `@delete` emit triggers the confirm + router
// parent view: `@delete` emit triggers the confirm + router
// navigation in `Collection.vue`.
//
// Cover artwork flow:
@@ -127,7 +127,7 @@ const dirty = computed(() => {
// changes (route swap, socket-driven backend update). Keeps the form
// in sync with the canonical record without trampling in-flight edits
// while a save is running. Accepts an explicit source so `save()` can
// re-sync from the update response — `props.collection` only receives
// re-sync from the update response: `props.collection` only receives
// the fresh value asynchronously (store swap + parent reassign), so it
// still holds the pre-save object at the moment `save()` snapshots.
function snapshot(source: Collection | SmartCollection = props.collection) {
@@ -180,7 +180,7 @@ const mosaicFallback = computed<string[]>(() => {
return (c.path_covers_small ?? []).slice(0, 4).map(toWebp);
});
// Smart-collection filter criteria — read-only display. The summary
// Smart-collection filter criteria: read-only display. The summary
// helper translates the raw JSON into a structured list of rows.
function platformLookup(id: number): string | null {
return allPlatforms.value.find((p) => p.id === id)?.display_name ?? null;
@@ -283,7 +283,7 @@ async function save() {
snackbar.success(t("collection.updated", "Collection updated"), {
icon: "mdi-check-bold",
});
// Re-sync from the response, not the prop — the prop hasn't been
// Re-sync from the response, not the prop: the prop hasn't been
// updated yet (see `snapshot` note), so snapshotting it would revert
// the form to the pre-save values and keep `dirty` true.
if (syncSaved(saved, target)) snapshot(saved);
@@ -375,7 +375,7 @@ async function setVisibility(next: boolean) {
<template>
<div class="r-settings-column">
<!-- Cover artwork — regular collections only. Smart collections
<!-- Cover artwork: regular collections only. Smart collections
derive their cover from the contained ROMs at runtime, so an
upload UI here would be misleading. -->
<section v-if="kind === 'regular'" class="r-v2-coll-set__section">
@@ -432,7 +432,7 @@ async function setVisibility(next: boolean) {
</div>
</section>
<!-- Details (edit form) — both kinds. -->
<!-- Details (edit form): both kinds. -->
<section class="r-v2-coll-set__section">
<header class="r-section-head">
<RIcon icon="mdi-information-outline" size="14" />
@@ -1,5 +1,5 @@
<script setup lang="ts">
// DeleteFirmwareDialog — RDialog that confirms deletion of one or more
// DeleteFirmwareDialog: RDialog that confirms deletion of one or more
// firmware files and lets the user opt-in (per item) to also delete
// the underlying file from disk. The actual deletion is performed by
// the parent (FirmwareDrawer) via the `onConfirm` prop so the dialog
@@ -48,7 +48,7 @@ const {
clear: clearFs,
} = useIdSelection(() => props.firmware);
// Each open trip starts with no filesystem deletes selected — the
// Each open trip starts with no filesystem deletes selected: the
// user has to opt in deliberately. Resetting when modelValue flips
// from false → true prevents a previous-session selection from
// resurfacing.
@@ -143,7 +143,7 @@ async function confirm() {
</li>
</ul>
<!-- Warning strip — only painted when at least one row will hit
<!-- Warning strip: only painted when at least one row will hit
the filesystem, so the destructive consequence is impossible
to miss. -->
<p v-if="fsCount > 0" class="r-v2-del-fw__warn">
@@ -1,5 +1,5 @@
<script setup lang="ts">
// FilterDrawer (v2) — gallery filter side panel. v2-native replacement
// FilterDrawer (v2): gallery filter side panel. v2-native replacement
// for `src/components/Gallery/AppBar/common/FilterDrawer/Base.vue`.
//
// Surface area (matches v1 1:1 so URLs stay compatible):
@@ -10,15 +10,15 @@
// where you can mix platforms).
// • 11 multi-select filter groups (genres / franchises / collections /
// companies / age-ratings / regions / languages / tags /
// player-counts / metadata-providers / statuses) — each paired with
// player-counts / metadata-providers / statuses): each paired with
// an AND/OR/NONE logic toggle.
// • Game length: an open-ended HowLongToBeat main-story range in hours.
// • Reset button at the bottom.
//
// Apply is implicit — the URL composable + galleryRoms watcher refresh
// Apply is implicit: the URL composable + galleryRoms watcher refresh
// results on every store change. There is no "Apply" button.
//
// Feature composite — knows the galleryFilter store layout and v2
// Feature composite: knows the galleryFilter store layout and v2
// primitives. Mounted by GalleryShell so it's available everywhere a
// gallery is rendered. The shell controls `modelValue` and forwards
// `showPlatformsFilter`.
@@ -503,7 +503,7 @@ function resetAll() {
filter.setSelectedFilterLengthHours(null, null);
}
// Hand off to CreateSmartCollectionDialog — closing the drawer first
// Hand off to CreateSmartCollectionDialog: closing the drawer first
// keeps focus management clean (the drawer's escape stack pops before
// the dialog pushes its own).
function saveAsSmartCollection() {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// FirmwareTab — platform-scoped firmware manager rendered as the
// FirmwareTab: platform-scoped firmware manager rendered as the
// `Firmware` tab inside Platform.vue. Lists every firmware file
// associated with the platform and lets admins upload, download, or
// delete files. Same content surface as the previous
@@ -65,7 +65,7 @@ const {
toggleAll,
} = useIdSelection(() => firmwareList.value);
// ── Inline upload (no dialog — the dropzone lives in the tab) ──────
// ── Inline upload (no dialog: the dropzone lives in the tab) ──────
const firmwareDz = ref<InstanceType<typeof RDropzone> | null>(null);
const pendingFiles = ref<File[]>([]);
const uploading = ref(false);
@@ -141,7 +141,7 @@ function onDeleted(deletedIds: number[]) {
syncFirmware(firmwareList.value.filter((f) => !deletedIds.includes(f.id)));
}
// `firmware_count` is a readonly derived field on PlatformSchema —
// `firmware_count` is a readonly derived field on PlatformSchema:
// patched locally so the InfoPanel stat reacts instantly; a future
// refetch reconciles.
function syncFirmware(next: FirmwareSchema[]) {
@@ -201,7 +201,7 @@ async function performDelete(
@files="addFiles"
/>
<!-- Otherwise the whole tab is a drop target (overlay) — dropping anywhere
<!-- Otherwise the whole tab is a drop target (overlay): dropping anywhere
adds files to the pending list. -->
<RDropzone
v-else
@@ -215,7 +215,7 @@ async function performDelete(
@files="addFiles"
>
<div class="r-v2-fw">
<!-- Pending upload — the files selected / dropped, awaiting upload. -->
<!-- Pending upload: the files selected / dropped, awaiting upload. -->
<section v-if="pendingFiles.length > 0" class="r-v2-fw__pending">
<header class="r-v2-fw__pending-head">
<span>{{
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GalleryShell — shared layout for Platform / Search / Collection.
// GalleryShell: shared layout for Platform / Search / Collection.
//
// Three structural sections, top to bottom, all sharing one scrollbar:
// 1. HEADER: view-supplied via `#header` slot. Whatever the view
@@ -9,7 +9,7 @@
// below the top bar; once pinned it shares one glass surface
// with the top bar, so cards blur behind both.
// 3. GRID / TABLE: the row-virtualised content (cards in grid mode,
// div-based rows in list mode — same shell scroller, same
// div-based rows in list mode: same shell scroller, same
// AlphaStrip wiring; the list column header lives in the
// prepend, sticky below the toolbar).
//
@@ -96,12 +96,12 @@ interface Props {
emptyMessage: string;
/** Empty-state icon, shared with not-found mode. */
emptyIcon?: string;
/** "Not found" mode — replaces all body items with a single empty row. */
/** "Not found" mode: replaces all body items with a single empty row. */
notFound?: boolean;
/** Override the empty-state message in not-found mode. */
notFoundMessage?: string;
/** Whether GameCards should display the platform badge corner (Search /
* Collection: yes; Platform: no — the cards already share a platform). */
* Collection: yes; Platform: no, the cards already share a platform). */
showPlatformBadge?: boolean;
/** Skeleton row count painted while the very first window is loading. */
skeletonRowCount?: number;
@@ -128,7 +128,7 @@ const props = withDefaults(defineProps<Props>(), {
defineSlots<{
/** View-specific header (InfoPanel / PageHeader / etc). Rendered in
* the scroller's `#prepend` slot — scrolls naturally with the rest
* the scroller's `#prepend` slot: scrolls naturally with the rest
* of the content. Must NOT carry a divider of its own; the shell
* paints the single divider at the bottom of the prepend band. */
header(): unknown;
@@ -189,10 +189,10 @@ const {
selectedLengthMaxHours,
} = storeToRefs(galleryFilterStore);
// Drawer open state — bound to FilterDrawer via v-model.
// Drawer open state: bound to FilterDrawer via v-model.
const filterDrawerOpen = ref(false);
// Active filter count — drives the toolbar badge. Counts each
// Active filter count: drives the toolbar badge. Counts each
// boolean/tri-state filter that's set, plus each multi-select group
// with at least one selection. Mirrors `FilterDrawer`'s own count so
// the badge agrees with the drawer header.
@@ -298,7 +298,7 @@ const { total, charIndex, initialFetching, orderBy, orderDir } =
const { groupBy, layout, toolbarPosition } = useGalleryMode();
// Responsive columns — measure the section to chunk roms into rows.
// Responsive columns: measure the section to chunk roms into rows.
// Card width and inset track the breakpoint so phones pack more, smaller
// cards instead of one stretched card per row:
// inset = scroller padding (--r-row-pad × 2), plus the AlphaStrip column
@@ -331,7 +331,7 @@ const { columns, usableWidth } = useResponsiveColumns(sectionEl, {
(stripVisible.value ? STRIP_INSET_PX : 0),
});
// Fallback cover ratio (boxart style) — the per-card `--r-cover-ratio` seed
// Fallback cover ratio (boxart style): the per-card `--r-cover-ratio` seed
// before GameCover measures the real image, plus the bootstrap skeletons.
// The flow-packer takes it too (`fallbackRatio`): a card with no artwork
// paints its placeholder at this ratio and never reports a measured one, so
@@ -343,7 +343,7 @@ const coverAspectRatio = computed(() =>
),
);
// Measured natural cover ratios feeding the flow-packer — GameCard reports
// Measured natural cover ratios feeding the flow-packer: GameCard reports
// each cover's ratio on load (`onCardRatio`), the packer reads `ratioAt`,
// and `ratioVersion` bumps (debounced) to trigger a single re-pack.
const { ratioVersion, ratioAt, onCardRatio } = useGalleryCoverRatios();
@@ -372,15 +372,15 @@ watch(
);
// 2D arrow / gamepad nav for both layouts of the gallery. Two passes:
// * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item
// * Grid mode: rows are `.r-v2-shell__row` (the per-virtualizer-item
// wrapper around the row's GameCards). ArrowLeft/Right within a row,
// ArrowUp/Down jumps to the same column in the next row.
// * List mode — each `.game-list-row` is both row and cell. ArrowLeft/
// * List mode: each `.game-list-row` is both row and cell. ArrowLeft/
// Right is no-op (single cell per row); ArrowUp/Down moves between
// rows.
// Both call sites resolve `current()` against the same focused element
// and only the matching one actually moves focus, so they don't fight.
// Virtualised rows past the overscan window simply aren't in the DOM —
// Virtualised rows past the overscan window simply aren't in the DOM:
// nav clamps at the boundary; scrolling past mounts more rows.
useGridNav(sectionEl, { rowSelector: ".r-v2-shell__row" });
useGridNav(sectionEl, {
@@ -536,7 +536,7 @@ watchEffect(() => {
const overscan = virtualOverscan.value;
const first = empty ? 0 : Math.max(0, vr.first - overscan);
const last = empty ? -1 : Math.min(total - 1, vr.last + overscan);
// Count the cards actually mounted in the rendered window — grid rows fan
// Count the cards actually mounted in the rendered window: grid rows fan
// out into many cards, so this is the real DOM weight (not just row count).
let renderedCards = 0;
for (let i = first; i <= last; i++) {
@@ -594,11 +594,11 @@ const currentLetter = computed<string>(() => {
// aligns each to its shared 72-item window, dedupes, starts the windows
// covering the viewport, and cancels any that scrolled out of view.
// Batching visible cards into a handful of paginated `getRoms` requests
// (instead of one request per card) is what keeps a fast scroll — or two
// users scrolling at once — from flooding the single-worker backend.
// (instead of one request per card) is what keeps a fast scroll: or two
// users scrolling at once: from flooding the single-worker backend.
//
// A small debounce on viewport changes prevents fire-and-cancel storms
// during smooth scrolling — only when the viewport settles for
// during smooth scrolling: only when the viewport settles for
// `FETCH_DEBOUNCE_MS` do we sync. Both layouts share this one path, so the
// list is debounced too (list rows no longer self-fetch on mount).
const FETCH_DEBOUNCE_MS = 80;
@@ -653,7 +653,7 @@ watch(virtualItems, () => {
}
pendingRange = null;
// Re-sync against the current viewport so visible rows in the new
// context start fetching immediately (no debounce — items just
// context start fetching immediately (no debounce: items just
// changed, the user is staring at skeletons).
syncFetches(viewportRange.value);
});
@@ -684,7 +684,7 @@ function scrollToLetter(letter: string) {
jumpLetter.value = letter;
jumpDeadline = Date.now() + LETTER_JUMP_MAX_MS;
anchorLetter(letter, true);
// The viewport-driven fetch sync handles the destination — once the
// The viewport-driven fetch sync handles the destination: once the
// smooth scroll settles, `update:viewportRange` fires and the windows at
// the landing zone start loading via `syncFetches` (both layouts). No
// manual prefetch needed.
@@ -902,7 +902,7 @@ const itemKind = (i: GalleryItem) => i.kind;
// (keyed by `:key="p"`) patch in place. `unknown` matches the prop signature.
const galleryItemKey = (item: unknown): string => (item as GalleryItem).key;
// View-facing surface. Methods only — internal state stays internal.
// View-facing surface. Methods only: internal state stays internal.
defineExpose({
/** Re-apply the previously-saved scroll position for the current route
* (typically called by the view at the end of its load flow). */
@@ -1000,7 +1000,7 @@ defineExpose({
</div>
</template>
<!-- LIST COLUMN HEADER — sticky below the toolbar in list mode.
<!-- LIST COLUMN HEADER: sticky below the toolbar in list mode.
Shares `LIST_GRID_TEMPLATE` with every GameListRow underneath
so columns align. Header click cycles asc/desc into the
store's orderBy/orderDir. -->
@@ -1021,7 +1021,7 @@ defineExpose({
</template>
</template>
<!-- GRID / TABLE (Section 3) — letter-headers + rows of cards in
<!-- GRID / TABLE (Section 3): letter-headers + rows of cards in
grid/grouped mode, or a single RTable in list mode. Skeleton
rows render while the first window is in flight. The empty
/ not-found state replaces everything below the toolbar
@@ -1115,7 +1115,7 @@ defineExpose({
@pick="scrollToLetter"
/>
<!-- FLOATING-DOCK TOOLBAR — the alternative dock; sits permanently
<!-- FLOATING-DOCK TOOLBAR: the alternative dock; sits permanently
in the top-right and never scrolls. Mutually exclusive with
the in-scroller header dock above. -->
<GalleryToolbar
@@ -1146,7 +1146,7 @@ defineExpose({
</template>
</GalleryToolbar>
<!-- FILTER DRAWER — owned by the shell so every gallery view gets
<!-- FILTER DRAWER: owned by the shell so every gallery view gets
it for free. Forwards `showPlatformsInFilter` from the view so
single-platform pages can hide the platform multi-select. -->
<FilterDrawer
@@ -1154,7 +1154,7 @@ defineExpose({
:show-platforms-filter="showPlatformsInFilter"
/>
<!-- SELECTION BAR — floating bottom panel surfaced whenever the
<!-- SELECTION BAR: floating bottom panel surfaced whenever the
user has selected at least one ROM. Owns the bulk actions
(favorite, collections, download, refresh, delete). Stays
outside the scroller so it never scrolls away. -->
@@ -1177,7 +1177,7 @@ defineExpose({
/* Explicit viewport-relative height instead of `height: 100%`.
The parent `<main>` is a flex item, and percentage heights on
descendants of flex-computed boxes don't always resolve in every
browser / stacking context — when they fail to resolve the
browser / stacking context: when they fail to resolve the
section becomes content-sized, the scroller inside ends up with
`height: auto`, and overflow-y stops doing anything because
there's nothing to overflow. A viewport height bypasses that
@@ -1196,7 +1196,7 @@ defineExpose({
scroll UNDER the translucent bottom tab bar (the glass effect). The layout
<main> adds a bottom padding for the bar (natural-flow views need it);
cancel it here with a matching negative margin so this full-height section
doesn't push the document past one viewport — otherwise a second, global
doesn't push the document past one viewport: otherwise a second, global
scroll stacks on top of the internal one. The scroller's bottom spacer
(below) lifts the last row clear of the bar. */
html[data-bp~="sm-and-down"] .r-v2-shell {
@@ -1282,7 +1282,7 @@ html[data-bp~="md-and-up"] .r-v2-shell__list-header {
flex-shrink: 0;
}
/* Card reveal animation (.r-v2-card-fade) lives in global.css — shared
/* Card reveal animation (.r-v2-card-fade) lives in global.css: shared
with the Home dashboard rows. */
.r-v2-shell__empty {
@@ -1298,7 +1298,7 @@ html[data-bp~="md-and-up"] .r-v2-shell__list-header {
}
/* When the list scrolls horizontally (columns wider than the viewport), the
page chrome — view header, divider and in-flow toolbar — belongs to the
page chrome, view header, divider and in-flow toolbar, belongs to the
page, not the table, so pin them to the left (`left: 0`). They stay in place
while only the column header and rows scroll sideways. `left` engages only
while the scroller has horizontal overflow, i.e. in list mode. */
@@ -1389,7 +1389,7 @@ html[data-bp~="xs"] .r-v2-shell {
}
/* Last row rests clear of the bottom tab bar (the rest of the scroll passes
under its glass). A real in-flow spacer, not `padding-bottom` — Safari /
under its glass). A real in-flow spacer, not `padding-bottom`: Safari /
older Chromium drop a scroll container's bottom padding from its
scrollable overflow, trapping the last row behind the bar. */
html[data-bp~="sm-and-down"] .r-v2-shell__scroller::after {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GalleryToolbar — the "how should I see these games?" control strip for
// GalleryToolbar: the "how should I see these games?" control strip for
// Platform / Collection / Search views.
//
// Controlled externally via `useGalleryMode()`; the parent passes the refs
@@ -70,7 +70,7 @@ const props = withDefaults(
position?: ToolbarPosition;
/** Show the GroupBy toggle (hide it on views where grouping doesn't make sense). */
showGroupBy?: boolean;
/** Override the GroupBy options — used by index views (Platforms)
/** Override the GroupBy options: used by index views (Platforms)
* to expose richer modes (family / category / generation) beyond
* the default flat / letter pair. */
groupByItems?: GroupByItem[];
@@ -137,7 +137,7 @@ defineSlots<{
actions?(): unknown;
}>();
// Support both a Ref or a plain value — keeps consumption flexible.
// Support both a Ref or a plain value: keeps consumption flexible.
function toValue<T>(source: Ref<T> | T): T {
return source && typeof source === "object" && "value" in (source as object)
? ((source as Ref<T>).value as T)
@@ -277,7 +277,7 @@ const { smAndUp } = useBreakpoint();
</RTextField>
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
<!-- Filter button — sits flush against the search field. Same disc
<!-- Filter button: sits flush against the search field. Same disc
shape as the kebab (outlined icon-only RBtn); the active-count
chip is `RBadge` anchored top-end, slightly overlapping the disc. -->
<RBadge
@@ -312,7 +312,7 @@ const { smAndUp } = useBreakpoint();
/>
</template>
<!-- View controls cluster — pushed right via margin-left: auto.
<!-- View controls cluster: pushed right via margin-left: auto.
At ≥ smAndUp the inline sliders carry every option; below that
they collapse into the kebab menu so the toolbar fits on phones
without overflowing. -->
@@ -471,7 +471,7 @@ const { smAndUp } = useBreakpoint();
padding: var(--r-space-2) 0 var(--r-space-5);
}
/* Floating variant — fixed top-right of the gallery body. */
/* Floating variant: fixed top-right of the gallery body. */
.gallery-toolbar--floating {
position: absolute;
top: 14px;
@@ -484,7 +484,7 @@ const { smAndUp } = useBreakpoint();
backdrop-filter: blur(20px);
}
/* Controls cluster — gets pushed right by its own margin. */
/* Controls cluster: gets pushed right by its own margin. */
.gallery-toolbar__controls {
display: flex;
align-items: center;
@@ -492,7 +492,7 @@ const { smAndUp } = useBreakpoint();
margin-left: auto;
}
/* Search — bounded width so the pills stay visible on wide screens. */
/* Search: bounded width so the pills stay visible on wide screens. */
.gallery-toolbar__search {
flex: 0 1 360px;
min-width: 0;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GameListHeader — sticky column-header row for list-mode galleries.
// GameListHeader: sticky column-header row for list-mode galleries.
//
// Layout: a single CSS-grid div sharing `LIST_GRID_TEMPLATE` with every
// `GameListRow` underneath, so columns line up regardless of viewport.
@@ -8,7 +8,7 @@
//
// Sticky positioning is owned by the parent (`GalleryShell` pins this
// below the toolbar at `top: --r-v2-shell-toolbar-h`). The header
// itself only paints — it doesn't manage scroll.
// itself only paints: it doesn't manage scroll.
import { RCheckbox, RIcon } from "@v2/lib";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@@ -1,8 +1,8 @@
<script setup lang="ts">
// GameListRow — single row of the list-mode gallery.
// GameListRow: single row of the list-mode gallery.
//
// Owns:
// * Skeleton ↔ real swap — when `getRomAt(position)` returns null the
// * Skeleton ↔ real swap: when `getRomAt(position)` returns null the
// row paints skeleton placeholders in every column; once the fetch
// resolves it flips to the real cells. Same row height in both
// states (no scroll reflow on hydration). Skeleton cells iterate
@@ -64,11 +64,11 @@ interface Props {
* lookup key into the store's `byPosition` map; the shell drives the
* windowed fetch that fills it. Pass either this or `rom`, not both. */
position?: number;
/** Static ROM data — used by non-gallery surfaces (Settings → Missing
/** Static ROM data: used by non-gallery surfaces (Settings → Missing
* games) that already own the rom list. When provided, the row skips
* the galleryRoms position lookup. */
rom?: SimpleRom | null;
/** Cover variant — when the browser supports webp the thumb URL is
/** Cover variant: when the browser supports webp the thumb URL is
* rewritten to .webp before the request. Wired from the shell so the
* choice is decided once per gallery render, not per row. */
webp?: boolean;
@@ -218,7 +218,7 @@ const providers = computed(() => {
});
// Status badge surfaces only when the rom actually has a play status
// set — otherwise GameActionBtn would render the dashed-circle
// set: otherwise GameActionBtn would render the dashed-circle
// "no status set" placeholder on every row, which reads as visual
// noise across a tall list. Mirrors the flags `useGameActions`
// inspects in `currentStatusKey`.
@@ -269,7 +269,7 @@ function navigateTo(item: SimpleRom, currentTarget: HTMLElement | null) {
const navigate = async () => {
await router.push(`/rom/${item.id}`);
};
// The thumb is the `<GameCard decorative>`'s inner art element —
// The thumb is the `<GameCard decorative>`'s inner art element:
// querying `.r-gc__art` reaches it through the GameCard wrapper.
// GameCard's own `morphStyle` computed paints the reverse-paint name
// on the same element when we come back from the detail page, so the
@@ -299,7 +299,7 @@ function onRowClick(e: MouseEvent) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
return;
}
// Default click — prevent the anchor's native navigation, run the
// Default click: prevent the anchor's native navigation, run the
// morph, then push the route.
e.preventDefault();
navigateTo(item, e.currentTarget as HTMLElement | null);
@@ -894,7 +894,7 @@ function onRowPointerDown(e: PointerEvent) {
background: var(--r-color-bg-elevated);
}
/* Selected row — brand-tinted background so the selection reads at
/* Selected row: brand-tinted background so the selection reads at
a glance without competing with the per-row hover (`bg-elevated`).
The two states can overlap (hover on a selected row); we stack the
hover delta on top of the selected tint via `color-mix`. */
@@ -914,7 +914,7 @@ html[data-input="pad"] .game-list-row:focus-visible {
box-shadow: inset 0 0 0 var(--r-focus-ring-width) var(--r-color-focus);
}
/* Select cell — checkbox column. Empty when the row is in skeleton
/* Select cell: checkbox column. Empty when the row is in skeleton
mode so the chrome only appears once a real row is loaded. */
.game-list-row__select {
display: flex;
@@ -924,7 +924,7 @@ html[data-input="pad"] .game-list-row:focus-visible {
overflow: visible;
}
/* RCheckbox carries its own visual language — we only own the row's
/* RCheckbox carries its own visual language: we only own the row's
reveal behaviour. Hidden at rest, visible on row hover/focus and
whenever the row is selected. */
.game-list-row__check {
@@ -972,7 +972,7 @@ html[data-input="pad"] .game-list-row:focus-visible {
and let the cover letterbox (contain) within, so the whole cover still
shows at its true aspect. Portrait/square covers are unaffected (their
natural width already fits, and contain renders identically to cover when
the box matches the cover's ratio). Scoped to the list cover only — the
the box matches the cover's ratio). Scoped to the list cover only: the
gallery grid keeps its natural-width flow. */
.game-list-row__cover :deep(.r-gc__art) {
max-width: var(--r-card-art-h);
@@ -1013,7 +1013,7 @@ html[data-input="pad"] .game-list-row:focus-visible {
flex: 0 1 auto;
}
/* Inline badges next to the title — status, sibling count, etc.
/* Inline badges next to the title: status, sibling count, etc.
`flex-shrink: 0` keeps them visible when the name truncates. The
SiblingBadge in GameCard absolute-positions itself over the cover;
inline here it falls back to its natural pill layout. */
@@ -1038,7 +1038,7 @@ html[data-input="pad"] .game-list-row:focus-visible {
margin-top: 4px;
}
/* Pills cell — chips wrap to multiple lines inside the cell when they
/* Pills cell: chips wrap to multiple lines inside the cell when they
don't fit on a single row, so short language/region lists read as a
single horizontal strip and longer lists stack vertically. The cell
keeps `overflow: hidden` (defense against extreme lists) and the
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GameListSkeletonRow — bootstrap-phase placeholder row for list-mode.
// GameListSkeletonRow: bootstrap-phase placeholder row for list-mode.
//
// Painted by `GalleryShell` while the first metadata window is in
// flight (no `total` yet). Self-contained: owns its row geometry +
@@ -1,16 +1,16 @@
<script setup lang="ts">
// InfoPanel — hero strip at the top of Platform / Collection gallery
// InfoPanel: hero strip at the top of Platform / Collection gallery
// pages (matches the artist mockup's `.plat-info-panel`). Feature
// composite around Stat, chips, and callsite-provided cover art.
//
// Slots: cover, eyebrow, title (defaults to `title` prop), tags, stats,
// providers, actions.
//
// `providers` sits below stats — used by Platform.vue to surface the
// `providers` sits below stats: used by Platform.vue to surface the
// external-metadata chip cluster (IGDB / SS / MobyGames / RA / …)
// inline rather than buried in a drawer.
//
// IMPORTANT — no divider here. The gallery shell owns the divider
// IMPORTANT: no divider here. The gallery shell owns the divider
// between the hero (header) and the toolbar so all three views
// (Platform / Collection / Search) get the same separator regardless
// of which header they render. The InfoPanel just provides the inner
@@ -123,7 +123,7 @@ html[data-bp~="xs"] .info-panel {
gap: 14px;
padding: 16px 0;
}
/* Drive the vertical rhythm purely from one flex gap on xs — the
/* Drive the vertical rhythm purely from one flex gap on xs: the
per-child margins (title / tags / providers) compound with the column
gap otherwise and the stack reads cramped + uneven. Centre the whole
hero (cover, title, chip clusters) so it reads as a balanced mobile
@@ -1,10 +1,10 @@
<script setup lang="ts">
// PlatformHead — the platform-context strip that sits above the
// PlatformHead: the platform-context strip that sits above the
// gallery / firmware / settings tab body. Composes the InfoPanel
// (icon + name + stats + provider chips + action ribbon) with the
// RTabNav. Re-used in two render branches inside Platform.vue:
//
// 1. Library tab — passed to `GalleryShell`'s `#header` slot so the
// 1. Library tab: passed to `GalleryShell`'s `#header` slot so the
// head scrolls naturally with the cards (and the toolbar pins
// below it). Matches the pre-tabs experience.
//
@@ -74,7 +74,7 @@ defineEmits<{
// A square icon sized per breakpoint (smaller on phones). Driving the size
// in JS keeps the box able to grow to the icon (reflow) instead of forcing
// the icon to a fixed box height, which — for a tall icon — either overflowed
// the icon to a fixed box height, which, for a tall icon, either overflowed
// (overlap) or, once clipped, cut it off.
const { xs } = useBreakpoint();
const { t } = useI18n();
@@ -170,13 +170,13 @@ const iconSize = computed(() => (xs.value ? 116 : 148));
</a>
</template>
<!-- Action ribbon — Random / Upload / Scan. Edit and Delete moved
<!-- Action ribbon: Random / Upload / Scan. Edit and Delete moved
inline into the Settings tab (the editable `custom_name` lives
next to the read-only details there, and Delete sits in the
tab's danger zone). Same circular icon-button vocabulary as
the GameDetails action row. Random is open to anyone who can
view the platform — it's a navigation shortcut, not an admin
action — so it sits left of the gated Upload/Scan buttons. -->
view the platform, it's a navigation shortcut, not an admin
action, so it sits left of the gated Upload/Scan buttons. -->
<template #actions>
<RBtn
variant="outlined"
@@ -251,7 +251,7 @@ const iconSize = computed(() => (xs.value ? 116 : 148));
}
/* Keep the platform icon prominent on phones (it's the page's identity)
rather than shrinking it to a thumbnail — just a touch smaller than the
rather than shrinking it to a thumbnail: just a touch smaller than the
desktop size (see `iconSize`) so it leaves room for the centred title. */
html[data-bp~="xs"] .r-v2-plat__panel-icon {
width: 150px;
@@ -266,7 +266,7 @@ html[data-bp~="xs"] .r-v2-plat__panel-icon {
/* ── Provider chip cluster ───────────────────────────────────────
Compact pill that pairs the provider's logo with its remote ID.
`--passive` (Flashpoint / HLTB / Libretro — no public lookup URL)
`--passive` (Flashpoint / HLTB / Libretro: no public lookup URL)
drops the hover lift since clicking does nothing. */
.r-v2-plat__provider {
display: inline-flex;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// ScanPlatformDialog — kicks off a scan for a single platform with
// ScanPlatformDialog: kicks off a scan for a single platform with
// the user's choice of providers, hash-matchers, and scan type.
//
// Visual + interaction language mirrors `RefreshMetadataDialog`
@@ -7,7 +7,7 @@
// proxies as switch pills, scan-type select), but the identity row
// at the top shows the platform instead of a ROM, and the scan-type
// list mirrors the Scan view's per-platform options (no "new
// platforms" — that's a discovery scan against the whole library,
// platforms": that's a discovery scan against the whole library,
// not a single platform).
import {
RAlert,
@@ -59,7 +59,7 @@ const {
persistSelection,
} = useScanProviders();
// Per-platform scan types — the full Scan-view list minus
// Per-platform scan types: the full Scan-view list minus
// `new_platforms` (a discovery scan against fs_slugs not yet in the
// DB, which can't be scoped to a known platform).
type ScanType = Exclude<SharedScanType, "new_platforms">;
@@ -135,7 +135,7 @@ function onScan() {
<template #content>
<div class="r-v2-scan-plat">
<!-- Platform identity row — icon + name. Mirrors the
<!-- Platform identity row: icon + name. Mirrors the
ROM-identity row in RefreshMetadataDialog so the two
scan-launching surfaces read as siblings. -->
<div class="r-v2-scan-plat__head">
@@ -157,7 +157,7 @@ function onScan() {
</div>
</div>
<!-- 1. Providers — General + Specific RSelects. -->
<!-- 1. Providers: General + Specific RSelects. -->
<section class="r-v2-scan-plat__section">
<h3 class="r-v2-scan-plat__section-title">
{{ t("scan.section-providers") }}
@@ -309,7 +309,7 @@ function onScan() {
</div>
</section>
<!-- 2. Hash-matcher proxies — same compact switch pills as
<!-- 2. Hash-matcher proxies: same compact switch pills as
RefreshMetadataDialog. -->
<section class="r-v2-scan-plat__section">
<h3 class="r-v2-scan-plat__section-title">
@@ -358,8 +358,8 @@ function onScan() {
</div>
</section>
<!-- 3. Scan type — full per-platform option list (no "new
platforms" — that's a library-wide discovery scan). -->
<!-- 3. Scan type, full per-platform option list (no "new
platforms", that's a library-wide discovery scan). -->
<section class="r-v2-scan-plat__section">
<h3 class="r-v2-scan-plat__section-title">
{{ t("scan.section-scan-type") }}
@@ -421,7 +421,7 @@ function onScan() {
gap: 16px;
}
/* Platform identity row — sibling of `.r-v2-refresh__rom` in
/* Platform identity row: sibling of `.r-v2-refresh__rom` in
RefreshMetadataDialog. */
.r-v2-scan-plat__head {
display: flex;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// SelectionBar — floating bottom panel that surfaces bulk actions
// SelectionBar: floating bottom panel that surfaces bulk actions
// over the currently-selected ROMs. Replaces v1's `FabOverlay`
// speed-dial: the bar sits below the gallery, never inside the
// toolbar, so filter/search/sort all stay reachable while the user
@@ -7,29 +7,29 @@
//
// Visibility: bound to `gallerySelection.enabled`. Slides up from
// `bottom: 0` when the first ROM is selected and slides back down
// when the count drops to zero — the panel stays mounted so its
// when the count drops to zero: the panel stays mounted so its
// buttons keep their ripple state cleanly between cycles.
//
// Actions wire-up:
// * favorite / unfavorite — direct collectionApi bulk call against
// * favorite / unfavorite: direct collectionApi bulk call against
// the favorite collection. The Card/Row's per-rom favourite
// toggle still routes through `useGameActions` per-rom; here we
// bypass only its per-rom write to issue a single add/remove call
// for the whole set, while still reusing its
// `ensureFavoriteCollection` so a fresh instance gets one.
// * manage collections — re-uses the existing
// * manage collections: re-uses the existing
// `ManageCollectionsDialog` (already accepts SimpleRom[]) via
// the `showManageCollectionsDialog` emitter event.
// * download — a single selected ROM downloads directly (like the
// * download: a single selected ROM downloads directly (like the
// per-rom `useGameActions.download`); multi-selections go through
// the bulk endpoint so the server bundles them into one zip.
// `hideDownload` drops the action for hosts whose rows have no
// file to serve (the Missing games tab).
// * refresh metadata — emits `showRefreshMetadataDialog` for each
// * refresh metadata: emits `showRefreshMetadataDialog` for each
// ROM in turn. (Phase-2 follow-up: the dialog will accept arrays
// so the user only sees the scan-type picker once for the whole
// batch instead of N dialogs.)
// * delete — emits `showDeleteRomDialog` with the full selection;
// * delete: emits `showDeleteRomDialog` with the full selection;
// the dialog already paints a per-ROM "remove from disk"
// checklist for bulk deletes.
//
@@ -117,7 +117,7 @@ const canEditRom = useCan("rom.edit");
// Bulk delete hits `POST /roms/delete`, which gates on ROMS_WRITE
const canDelete = computed(() => hasDeleteGrant.value && canEditRom.value);
// `favorite` is the favourite collection — used to compute "are all
// `favorite` is the favourite collection: used to compute "are all
// selected ROMs in favorites?" so the button can toggle between
// "add to" / "remove from" instead of forcing a separate unfavorite
// action. Same model as the per-card favourite button.
@@ -163,7 +163,7 @@ async function bulkFavorite() {
collectionsStore.setFavoriteCollection(data);
if (wasAllFavorited && galleryRomsStore.currentCollection?.id === fav.id) {
// We were on the favourites collection view and just removed
// every selected rom from it — drop them from the visible
// every selected rom from it: drop them from the visible
// roms so the UI reflects the new membership immediately.
removeCachedRoms(roms);
}
@@ -182,7 +182,7 @@ async function bulkFavorite() {
}
}
// Bulk status — toggles one play-status across the whole selection,
// Bulk status: toggles one play-status across the whole selection,
// mirroring the favourite button's all-or-nothing model: a status reads
// as "active" only when *every* selected ROM already has it. Clicking an
// active status clears it on all; clicking an inactive one (at least one
@@ -410,7 +410,7 @@ function clear() {
<!-- Default slot: action buttons. Order mirrors the v1 FAB:
download → favourite → collections → refresh → delete.
Every button is wrapped in RTooltip so the user gets a
consistent hover hint and gamepad users see the label —
consistent hover hint and gamepad users see the label:
the v2 visual vocabulary for icon-only buttons. -->
<RTooltip
v-if="canDownload && !hideDownload"
@@ -440,7 +440,7 @@ function clear() {
</template>
</RTooltip>
<!-- Set status — opens a picker that toggles one play-status across
<!-- Set status: opens a picker that toggles one play-status across
the whole selection. Mirrors the per-ROM status menu
(GameActionBtn) and the favourite button's all-or-nothing
model: a row reads active only when every selected ROM already
@@ -463,7 +463,7 @@ function clear() {
:aria-label="t('gallery.selection-status')"
/>
</template>
<!-- Enum statuses — single play-status per ROM. Active row tints
<!-- Enum statuses: single play-status per ROM. Active row tints
brand when all selected share it. -->
<RMenuItem
v-for="key in ENUM_KEYS"
@@ -477,7 +477,7 @@ function clear() {
<RDivider />
<!-- Play-status flags — independent toggles. Active rows tint
<!-- Play-status flags: independent toggles. Active rows tint
brand and show a trailing check when every selected ROM has
the flag set. -->
<RMenuItem
@@ -496,7 +496,7 @@ function clear() {
<RDivider />
<!-- Visibility flag — distinct category (library visibility,
<!-- Visibility flag: distinct category (library visibility,
not play state) so it lives in its own section. -->
<RMenuItem
v-for="key in VISIBILITY_FLAG_KEYS"
@@ -564,7 +564,7 @@ function clear() {
</template>
</RTooltip>
<!-- Append region: clear button — floats to the right edge via
<!-- Append region: clear button, floats to the right edge via
RToolbar's built-in spacer. -->
<template #append>
<RTooltip :text="t('gallery.selection-clear')">
@@ -1,12 +1,12 @@
<script setup lang="ts">
// SettingsTab — platform-scoped settings rendered as the `Settings`
// SettingsTab: platform-scoped settings rendered as the `Settings`
// tab inside Platform.vue. Details (editable name + read-only platform
// fields) with a danger zone underneath holding the destructive
// "Delete platform" action.
//
// Mutation path:
// • `custom_name` / `description` → `platformApi.updatePlatform(...)`
// Optimistic — the form updates the local platform reactively, a
// Optimistic: the form updates the local platform reactively, a
// snackbar fires on success/failure.
//
// Delete: emitted upward (`@delete`) so the view orchestrator can
@@ -3,7 +3,7 @@
// and `GameListSkeletonRow` (bootstrap-phase placeholder rows).
//
// CSS grid template lives here so all three components pick up the same
// column geometry — change column widths in one place and the header
// column geometry: change column widths in one place and the header
// stays aligned with every row underneath. The fr units in the title
// column let the row stretch to fill remaining width while the metric
// columns hold fixed pixel widths so numbers align cleanly.
@@ -49,7 +49,7 @@ export interface ListColumn {
* as a grid. */
numeric?: boolean;
/** Skeleton placeholder width (px) for this column's loading state.
* `undefined` means the column owns a custom skeleton shape — the
* `undefined` means the column owns a custom skeleton shape: the
* title column paints cover + meta lines, the actions column paints
* nothing. */
skeletonWidth?: number;
@@ -141,7 +141,7 @@ export function getListColumns(showPlatform: boolean): readonly ListColumn[] {
return cols;
}
// Fixed track widths (px) — kept as data so the grid template AND the row's
// Fixed track widths (px): kept as data so the grid template AND the row's
// natural min-width (below) derive from the same numbers.
const LIST_SELECT_TRACK_PX = parseInt(layout.listSelectWidth, 10);
const LIST_PLATFORM_TRACK_PX = 200;
@@ -155,7 +155,7 @@ export const LIST_TITLE_MIN_PX = 200;
// CSS. Token values are guaranteed to be `<number>px` strings.
//
// The list-row avatar is rendered by `<GameCard size="xs" />`, so the
// cover dimensions come from the shared xs tokens — there's no
// cover dimensions come from the shared xs tokens: there's no
// dedicated "list cover" token any more. Keep these JS mirrors so the
// skeleton placeholder paints at the same footprint as the real card.
export const LIST_ROW_HEIGHT_PX = parseInt(layout.listRowHeight, 10);
@@ -224,7 +224,7 @@ export function getListMinWidth(showPlatform: boolean): number {
return tracks + (columnCount - 1) * LIST_GRID_GAP_PX + 2 * LIST_ROW_PAD_X_PX;
}
/** Default exports — the cross-platform variant. Used by the bootstrap-
/** Default exports: the cross-platform variant. Used by the bootstrap-
* phase skeleton/header pair when no consumer-specific override is
* available; per-view code should always go through the explicit
* `getListColumns(...)` / `getListGridTemplate(...)` so the column set
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GameActionBtn — single icon button for the per-ROM action set
// GameActionBtn: single icon button for the per-ROM action set
// shared between the GameCard hover overlay and the GameDetails
// header. One component, three sizes, auto-wires to `useGameActions`
// so both surfaces stay in sync.
@@ -18,12 +18,12 @@
// current status icon when set, dashed border when empty
// more → open MoreMenu (GameActionsList dropdown)
//
// Sizes (controls diameter + icon size + padding) — same vocabulary as
// Sizes (controls diameter + icon size + padding): same vocabulary as
// RBtn / RChip / RTag:
// x-small → 22px
// small → 28px — GameCard hover overlay
// default → 40px — GameDetails header
// large → 44px — larger emphasis (GameActions row)
// small → 28px: GameCard hover overlay
// default → 40px: GameDetails header
// large → 44px: larger emphasis (GameActions row)
// x-large → 52px
//
// Variants:
@@ -133,7 +133,7 @@ const { smAndDown } = useBreakpoint();
const romRef = toRef(props, "rom");
// Reuse the host's shared instance when one is provided (GameCard provides a
// single `useGameActions` for all its buttons — see GAME_ACTIONS_KEY). Falls
// single `useGameActions` for all its buttons: see GAME_ACTIONS_KEY). Falls
// back to an own instance for standalone use (GameDetails header, list rows).
// Safe to call conditionally: useGameActions registers no lifecycle hooks, and
// setup decides the branch once at mount. The provided instance is bound to
@@ -169,7 +169,7 @@ type Preset = {
image?: string;
};
// Presentation metadata per action — icon swaps when active, different
// Presentation metadata per action: icon swaps when active, different
// aria labels, different click handlers. Written as an if-chain instead
// of a switch so the linter can see every path returns.
const preset = computed<Preset>(() => {
@@ -286,7 +286,7 @@ const preset = computed<Preset>(() => {
active: count > 0,
};
}
// "more" — the RMenu owns activation; no direct click handler.
// "more": the RMenu owns activation; no direct click handler.
return {
icon: "mdi-dots-horizontal",
activeIcon: null,
@@ -326,7 +326,7 @@ onBeforeUnmount(() =>
);
// Single signal the GameCard `:has()` selectors watch to keep the card's
// hover state painted while an action is in flight — more menu, status
// hover state painted while an action is in flight: more menu, status
// picker, or collection-manage dialog.
const pinned = computed(() => {
if (props.action === "more") return moreOpen.value;
@@ -336,14 +336,14 @@ const pinned = computed(() => {
});
function pickEnum(key: RomUserStatus) {
// Re-clicking the active enum clears just the enum — gives the user a
// Re-clicking the active enum clears just the enum: gives the user a
// way to drop the status without also wiping the flags via "Clear all".
void actions.setStatusEnum(enumStatus.value === key ? null : key);
statusOpen.value = false;
}
function toggleFlag(key: StatusFlagKey) {
// Don't close — flags are independent toggles, the user may flip several.
// Don't close: flags are independent toggles, the user may flip several.
void actions.setStatus(key);
}
@@ -367,7 +367,7 @@ function onClick(e: MouseEvent) {
</script>
<template>
<!-- More — opens the shared GameActionsList dropdown. On phones it docks
<!-- More: opens the shared GameActionsList dropdown. On phones it docks
as a bottom sheet (content-height) instead of a floating dropdown. -->
<RMenu
v-if="action === 'more'"
@@ -407,7 +407,7 @@ function onClick(e: MouseEvent) {
<GameActionsList :rom="rom" @close="moreOpen = false" />
</RMenu>
<!-- Status — enum picker; icon mirrors the current value, dashed
<!-- Status: enum picker; icon mirrors the current value, dashed
border when no status is set. Keeps the per-ROM action set in
one place instead of a parallel widget. With several states
active the activator stretches into a multi-icon pill; the
@@ -492,7 +492,7 @@ function onClick(e: MouseEvent) {
<RDivider />
<!-- Visibility flag — distinct category (controls library
<!-- Visibility flag: distinct category (controls library
visibility, not play state) so it lives in its own section. -->
<RMenuItem
v-for="key in VISIBILITY_FLAG_KEYS"
@@ -527,7 +527,7 @@ function onClick(e: MouseEvent) {
</template>
</RMenu>
<!-- Plain action — direct click, or a real anchor for a linked launch. -->
<!-- Plain action: direct click, or a real anchor for a linked launch. -->
<component
:is="linkHref ? 'a' : 'button'"
v-else
@@ -575,7 +575,7 @@ function onClick(e: MouseEvent) {
/* Dark glass so the button still reads when sitting on top of a bright
or busy cover image in the GameCard overlay. In GameDetails the
backdrop is already a dark blurred cover so this tone lands neutral
there too. Overlay tokens never theme-flip — they stay dark over
there too. Overlay tokens never theme-flip: they stay dark over
any cover artwork. */
border: 1px solid var(--r-color-overlay-border);
background: var(--r-color-overlay-scrim-soft);
@@ -605,7 +605,7 @@ function onClick(e: MouseEvent) {
transform: scale(0.94);
}
/* Size ladder — circular unless `--labelled`. Matches the
/* Size ladder: circular unless `--labelled`. Matches the
x-small/small/default/large/x-large vocabulary used across primitives. */
.r-v2-game-btn--x-small {
width: 22px;
@@ -668,7 +668,7 @@ function onClick(e: MouseEvent) {
height: 26px;
}
/* Labelled — expands to a pill with text. Used by Play in the
/* Labelled: expands to a pill with text. Used by Play in the
GameDetails header. Height stays the same as the circular variant so
it can live in the same row without visual jumps. */
.r-v2-game-btn--labelled {
@@ -688,9 +688,9 @@ function onClick(e: MouseEvent) {
padding: 0 32px;
}
/* Surface — RTag-style translucent grey. Used in the GameDetails
/* Surface: RTag-style translucent grey. Used in the GameDetails
header where buttons sit on the page background (not over cover
art) — matches the visual vocabulary of RTag and RSelect there. */
art): matches the visual vocabulary of RTag and RSelect there. */
.r-v2-game-btn--surface {
background: var(--r-color-surface);
border-color: var(--r-color-border-strong);
@@ -703,7 +703,7 @@ function onClick(e: MouseEvent) {
color: var(--r-color-fg);
}
/* Bare — no chrome. The active-state colour swap (`--active-favorite`
/* Bare: no chrome. The active-state colour swap (`--active-favorite`
etc.) still applies because it overrides `color` only. Hover paints a
subtle surface tint so the hit-target reads. */
.r-v2-game-btn--bare {
@@ -718,7 +718,7 @@ function onClick(e: MouseEvent) {
color: var(--r-color-fg);
}
/* Emphasized — the primary-action look (white on dark). Used by Play. */
/* Emphasized: the primary-action look (white on dark). Used by Play. */
.r-v2-game-btn--emphasized {
background: var(--r-color-overlay-emphasis-bg) !important;
border-color: var(--r-color-overlay-emphasis-bg) !important;
@@ -755,14 +755,14 @@ function onClick(e: MouseEvent) {
color: var(--r-color-brand-primary) !important;
}
/* Status — dashed border when no status is set, signals "click to
/* Status: dashed border when no status is set, signals "click to
pick". Once set, the button uses the regular solid border + the
status icon shows the choice. */
.r-v2-game-btn--action-status:not(.r-v2-game-btn--active-status) {
border-style: dashed;
}
/* Multi-status — the button stretches to fit every active state's
/* Multi-status: the button stretches to fit every active state's
icon. Width/height go auto with a min that keeps the single-state
diameter, so 0/1 active still reads as a circle. The icon row/column
lives in `__icons`. */
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GameActions — the action row in the game-details header.
// GameActions: the action row in the game-details header.
// Composes GameActionBtn atoms that are shared with the GameCard hover
// overlay so both surfaces stay visually and behaviourally in sync.
// The Play button uses the emphasized + withLabel variant to match the
@@ -8,7 +8,7 @@
//
// Right-side group (desktop only): completion + rating + difficulty
// pickers, separated from the main ribbon by a spacer. All three share
// MetricMenuBtn — the rating/difficulty trigger an RRating popup,
// MetricMenuBtn: the rating/difficulty trigger an RRating popup,
// completion triggers an RSlider popup. On phones these move into the
// status button's sheet (GameActionBtn `withMetrics`) to save a row.
// Writes are optimistic via useGameActions.setScore.
@@ -32,7 +32,7 @@ const { t } = useI18n();
const romRef = toRef(props, "rom");
const actions = useGameActions(() => romRef.value);
// Shrink the ribbon on phones — the large (44px) buttons crowd the narrow
// Shrink the ribbon on phones: the large (44px) buttons crowd the narrow
// column; the default (36px) size fits more per row and reads cleaner.
const { smAndDown } = useBreakpoint();
const btnSize = computed<"default" | "large">(() =>
@@ -43,7 +43,7 @@ const btnSize = computed<"default" | "large">(() =>
// itself the row; cells are every action button (`.r-v2-game-btn`) plus
// the right-side metrics (`.r-v2-metric-btn`), skipping the layout
// spacer. On pad-modality autofocus, `focusFirst` lands on the first
// rendered button — Play if available (template renders it first when
// rendered button: Play if available (template renders it first when
// `canPlay`), otherwise Download.
const rootEl = ref<HTMLElement | null>(null);
useGridNav(rootEl, {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// GameMetricsSections — the three per-user metric editors (completion,
// GameMetricsSections: the three per-user metric editors (completion,
// rating, difficulty) stacked as sections under a "Your progress"
// header. Mounted inside the Status button's mobile sheet (see
// GameActionBtn `withMetrics`) so phones edit scores there instead of
@@ -1,5 +1,5 @@
<script setup lang="ts">
// MetricMenuBtn — picker for a per-user numeric metric on a ROM.
// MetricMenuBtn: picker for a per-user numeric metric on a ROM.
// Powers all three score controls in the GameActions ribbon: rating,
// difficulty (kind='rating'), and completion (kind='percent').
//
@@ -88,7 +88,7 @@ const valueLabel = computed(() => {
</template>
<style scoped>
/* Trigger — parallels .r-v2-game-btn--surface so it sits in the
/* Trigger: parallels .r-v2-game-btn--surface so it sits in the
action row without visual breaks. Translucent grey RTag-style
surface, not the dark cover-overlay scrim. */
.r-v2-metric-btn {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// MetricSection — inline editor body for a single per-user metric
// MetricSection: inline editor body for a single per-user metric
// (rating, difficulty, completion). Shared between MetricMenuBtn's
// popup (desktop ribbon) and GameMetricsSections' mobile status sheet.
// Renders a header (icon + label + live value), an RRating for 1..max
@@ -1,5 +1,5 @@
<script setup lang="ts">
// MoreMenu — dropdown "more actions" for a ROM, anchored to the trigger
// MoreMenu: dropdown "more actions" for a ROM, anchored to the trigger
// button you pass in via the `#activator` slot. RMenu hosts the shared
// GameActionsList so the items match the right-click context menu
// exactly.
@@ -1,5 +1,5 @@
<script setup lang="ts">
// CardFlags — region / language emoji chips over the cover's bottom-left
// CardFlags: region / language emoji chips over the cover's bottom-left
// corner, the v2 counterpart of v1's cover flags. Visibility is driven by
// the showRegions / showLanguages UI settings; each chip caps at three
// emoji and carries the full list in its hover title. Purely informational:
@@ -1,10 +1,10 @@
<script setup lang="ts">
// GameCard — portrait game cover with hover overlay.
// GameCard: portrait game cover with hover overlay.
//
// Feature composite (not a lib primitive): depends on SimpleRom,
// useGameActions (via GameActionBtn), useBackgroundArt, and the store
// layer. Lives under `components/` instead of `lib/` for
// that reason — the library is reserved for truly generic primitives
// that reason: the library is reserved for truly generic primitives
// that a Storybook reader can drop into a page without wiring stores or
// a router.
//
@@ -12,15 +12,15 @@
// * 158×213 card art, 8px radius
// * Rating badge top-right (appear on hover)
// * Platform icon (TR, always visible): semi-transparent circle with the
// platform's icon — toggle via `showPlatformIcon`.
// platform's icon: toggle via `showPlatformIcon`.
// * Hover overlay: play (center) · action row (BL: download, collection,
// favorite, more) — action buttons are the shared GameActionBtn atom
// favorite, more): action buttons are the shared GameActionBtn atom
// so the card and the GameDetails header stay visually + behaviourally
// in sync.
// * Label below the card, 11.5px, truncated
// * Optional `hero` variant: 300×169 (16:9) + larger multi-line label
//
// Static mode (`static`) strips the gallery chrome — no router-link,
// Static mode (`static`) strips the gallery chrome: no router-link,
// no action overlay, no rating / status / platform-icon badges, no
// background-art highlight on hover. Click emits `@click` instead of
// navigating. Hover scale stays by default (suppress with `noHover` for
@@ -30,9 +30,9 @@
// they have no destination.
//
// Other static-friendly props:
// * `coverSrc` overrides the cover URL chain — used for preview blobs
// * `coverSrc` overrides the cover URL chain: used for preview blobs
// (edit dialog) and external provider URLs (match dialog).
// * `showTitle` toggles the label below the cover — defaults true.
// * `showTitle` toggles the label below the cover: defaults true.
// * `selected` paints a brand-coloured outline on the cover art for
// pickers (match-flow source variants, multi-select galleries).
// * `#overlay` slot renders content on top of the cover for badges
@@ -70,11 +70,11 @@ interface Props {
/** Card scale tier. Drives the cover art width/height via the shared
* `--r-card-art-w/h` tokens, and the hero variant's `--r-hero-w/h`
* when `hero` is true.
* xs (48 × 64) — list-row avatars
* sm (120 × 162) — dense pickers
* md (158 × 213) — gallery default (no class — keeps the global token)
* lg (200 × 270) — edit-dialog preview
* xl (240 × 324) — detail page cover
* xs (48 × 64) , list-row avatars
* sm (120 × 162), dense pickers
* md (158 × 213), gallery default (no class, keeps the global token)
* lg (200 × 270), edit-dialog preview
* xl (240 × 324), detail page cover
* Hero scales linearly with size (it's just an aspect-ratio change,
* not a separate scale), so `hero` + any `size` paints the 16:9
* shape at that tier's footprint. */
@@ -82,12 +82,12 @@ interface Props {
focused?: boolean;
webp?: boolean;
showPlatformIcon?: boolean;
/** Passive mode — root is `<article role="button">` instead of a
/** Passive mode: root is `<article role="button">` instead of a
* router-link; action overlay / rating / status / platform-icon /
* bg-art highlight are all suppressed. Click emits `@click` for the
* consumer to handle. Hover scale stays unless `noHover` is set. */
static?: boolean;
/** Fully decorative — root is a plain `<div>` with no role, no
/** Fully decorative: root is a plain `<div>` with no role, no
* tabindex, no router wiring, no own click handlers. The whole
* card is visual content; the parent (typically an `<a>` or row
* button) owns interactivity and navigation. Avoids the nested-
@@ -112,7 +112,7 @@ interface Props {
coverPip?: boolean;
/** Paint a brand-coloured outline to mark the card as selected
* (cover-variant picker, multi-select gallery). When `selectable`
* is true this prop is ignored — the card subscribes directly to
* is true this prop is ignored: the card subscribes directly to
* `gallerySelection` so a single source of truth (the store)
* drives every selected card across the gallery. */
selected?: boolean;
@@ -125,7 +125,7 @@ interface Props {
* gallery consumers (pickers, edit-dialog previews) keep their
* prop-driven behaviour. */
selectable?: boolean;
/** Sparse position of the rom inside the gallery — the anchor that
/** Sparse position of the rom inside the gallery: the anchor that
* shift-range selection uses. Provided by `GalleryShell`. Required
* when `selectable` is true (the composable needs a position to
* store/restore the range anchor). */
@@ -159,11 +159,11 @@ const art = useCoverArt(() => props.rom, {
const coverUrl = art.coverUrl;
const fallbackUrl = art.fallbackUrl;
const coverAspectRatio = art.ratio;
// Cover-art PIP — only when an override image (a screenshot) is actually
// Cover-art PIP: only when an override image (a screenshot) is actually
// covering the rom's own art, and the consumer opted in.
const showCoverPip = computed(() => props.coverPip && !!props.coverSrc);
// Alt-art styles (box3d / physical / miximage) drop the card frame so the
// artwork floats — but only while a real image renders; with no cover the
// artwork floats: but only while a real image renders; with no cover the
// placeholder keeps its grey box so the title stays readable.
const isAltStyle = computed(
() =>
@@ -172,7 +172,7 @@ const isAltStyle = computed(
// Forward view-transition morph targets the GameCover's box element.
const coverRef = ref<InstanceType<typeof GameCover> | null>(null);
// Synthetic roms (id = 0 / null / undefined) have no destination — skip
// Synthetic roms (id = 0 / null / undefined) have no destination: skip
// the entire view-transition wiring so we never try to morph to a
// non-existent /rom/0 detail page.
const isSynthetic = computed(() => !props.rom.id);
@@ -187,7 +187,7 @@ const href = computed(() => props.to ?? `/rom/${props.rom.id}`);
const setBgArt = useBackgroundArt();
// Same handler fires on hover AND focus so keyboard/gamepad users get
// the background cross-fade to the focused cover — mirrors what mouse
// the background cross-fade to the focused cover: mirrors what mouse
// users see when they rest a pointer on the card. Suppressed in static
// mode (the card isn't part of a gallery surface where the background
// reads as the focused cover).
@@ -239,7 +239,7 @@ const { morphTransition } = useViewTransition();
// thumbnails, dialog tiles) render no overlay, so creating the composable for
// them is pure waste. When present, share this single instance with every
// GameActionBtn in the overlay (play / download / collection / favorite /
// status / more) instead of each button spinning up its own — the difference
// status / more) instead of each button spinning up its own: the difference
// between ~1 and ~7 live `useGameActions` per card, which dominates a
// virtualised grid's cost. Safe to call conditionally (no lifecycle hooks).
const actions =
@@ -261,12 +261,12 @@ function onPlatformClick(e: MouseEvent) {
const emit = defineEmits<{
(e: "click", event: MouseEvent): void;
/** The cover's natural aspect ratio (width / height) once its image
* loads — forwarded from GameCover so the gallery can flow-pack cards
* loads: forwarded from GameCover so the gallery can flow-pack cards
* at their true shape. Carries the rom id so the consumer can key it. */
(e: "ratio", payload: { romId: number; ratio: number }): void;
}>();
// Gallery selection — only wired when the consumer opts in via
// Gallery selection: only wired when the consumer opts in via
// `selectable`. Outside the gallery (pickers, previews) the store
// stays untouched so a stray match-dialog selection can't leak into
// the gallery's selection bar.
@@ -293,7 +293,7 @@ const checkboxAlwaysOn = computed(
function onCheckboxClick(e: MouseEvent) {
// The checkbox itself is a deliberate selection gesture. We bind
// RCheckbox in "controlled" mode (only `:model-value` — no v-model),
// RCheckbox in "controlled" mode (only `:model-value`: no v-model),
// so the native input change is purely visual; `preventDefault`
// here cancels the label → input click default so the input never
// toggles itself, and our store mutation drives the next render.
@@ -313,10 +313,10 @@ function onCardClickCapture(e: MouseEvent) {
function onCardClick(e: MouseEvent) {
// Decorative mode: the card has no behaviour of its own. Let the
// event bubble untouched so the parent (typically an anchor or row
// button) handles navigation / selection — no emit, no morph.
// button) handles navigation / selection: no emit, no morph.
if (props.decorative) return;
// Static mode: consumer owns the click. No router push, no forward
// morph — just hand the event off.
// morph: just hand the event off.
if (props.static) {
emit("click", e);
return;
@@ -409,7 +409,7 @@ function onStaticKeydown(e: KeyboardEvent) {
:morph-id="isSynthetic ? null : rom.id"
@ratio="emit('ratio', { romId: rom.id, ratio: $event })"
>
<!-- Selection checkbox — top-left, drawn over the cover. Hidden
<!-- Selection checkbox: top-left, drawn over the cover. Hidden
at rest; appears on hover for discoverability, and stays
pinned whenever the gallery is in selection mode so the
user always knows which cards are picked. Uses RCheckbox
@@ -430,19 +430,19 @@ function onStaticKeydown(e: KeyboardEvent) {
@click="onCheckboxClick"
/>
<!-- Consumer-driven overlay slot — sits above the cover, below
<!-- Consumer-driven overlay slot: sits above the cover, below
the gallery chrome. Use for badges that aren't part of the
default overlay set (provider logos, custom status pills). -->
<div v-if="$slots.overlay" class="r-gc__overlay-slot">
<slot name="overlay" />
</div>
<!-- Cover-art PIP — small box-art thumbnail floated bottom-right while a
<!-- Cover-art PIP: small box-art thumbnail floated bottom-right while a
screenshot covers the rom's own art. Fades out on hover (below) so
it never collides with the action overlay. -->
<CoverArtPip v-if="showCoverPip" :rom="rom" :title="title" :webp="webp" />
<!-- Gallery chrome — all suppressed in static / decorative mode. -->
<!-- Gallery chrome: all suppressed in static / decorative mode. -->
<template v-if="!static && !decorative">
<div v-if="ratingLabel" class="r-gc__rating">★ {{ ratingLabel }}</div>
@@ -474,11 +474,11 @@ function onStaticKeydown(e: KeyboardEvent) {
<SiblingBadge :rom="rom" />
<!-- Region / language flags — bottom-left, gated by the
<!-- Region / language flags: bottom-left, gated by the
showRegions / showLanguages UI settings. -->
<CardFlags :rom="rom" />
<!-- Hover overlay — action buttons are the shared GameActionBtn. -->
<!-- Hover overlay: action buttons are the shared GameActionBtn. -->
<div class="r-gc__overlay">
<div class="r-gc__overlay-center">
<GameActionBtn
@@ -512,7 +512,7 @@ function onStaticKeydown(e: KeyboardEvent) {
<div v-if="showTitle" class="r-gc__label">
{{ title }}
</div>
<!-- Full-name tooltip — the label below the cover truncates at one
<!-- Full-name tooltip: the label below the cover truncates at one
line (two on hero variant), so a hover reveal is the only way
to see the full title without navigating in. `activator="parent"`
attaches to the root card element so the tooltip fires no
@@ -537,7 +537,7 @@ function onStaticKeydown(e: KeyboardEvent) {
position: relative;
outline: none;
width: var(--r-card-art-w);
/* Width is the card-art token exactly — never let a stray border /
/* Width is the card-art token exactly: never let a stray border /
padding push the rendered box past it (which would eat the grid gap
and make neighbouring covers appear to touch). */
box-sizing: border-box;
@@ -545,7 +545,7 @@ function onStaticKeydown(e: KeyboardEvent) {
color: var(--r-color-fg);
}
/* Non-hero cards render at a FIXED HEIGHT with NATURAL WIDTH — same height,
/* Non-hero cards render at a FIXED HEIGHT with NATURAL WIDTH: same height,
varying widths, never cropped. `size` just picks the height (the default
derives it from the reference width; tiers set it directly). Only `hero`
(16:9) keeps a fixed w×h footprint. */
@@ -575,7 +575,7 @@ function onStaticKeydown(e: KeyboardEvent) {
/* The art box IS the shared <GameCover> (this class lands on its root).
GameCover owns the radius / overflow / background / image; the card
keeps only the gallery-level box concerns: footprint (height — width
keeps only the gallery-level box concerns: footprint (height, width
comes from `.r-gc`), the focus outline, and the hover/focus transition. */
.r-gc__art {
height: var(--r-card-art-h);
@@ -591,14 +591,14 @@ function onStaticKeydown(e: KeyboardEvent) {
artwork floats: GameCover already makes its own background transparent;
here we kill the hover / focus / selected DROP SHADOW so a transparent
disc doesn't get a rectangular shadow around empty space (most obvious
on the wide hero — Home "continue playing"). The hover scale + the
on the wide hero: Home "continue playing"). The hover scale + the
focus/selected brand OUTLINE (the `outline` property, not the shadow)
stay. `!important` beats the multi-state shadow selectors without
duplicating their whole list. */
.r-gc--alt-art .r-gc__art {
box-shadow: none !important;
}
/* …and no darkening scrim on hover — the gradient would paint a dark
/* …and no darkening scrim on hover: the gradient would paint a dark
rectangle around a floating disc / cartridge. The overlay's controls
still appear (the emphasized Play CTA carries its own background). */
.r-gc--alt-art .r-gc__overlay {
@@ -648,7 +648,7 @@ function onStaticKeydown(e: KeyboardEvent) {
right: 7px;
z-index: 2;
/* Shrink-wrap around the platform icon. RBtn's size classes try to
impose a fixed `height` / `width: var(--r-btn-rest-h)` — we have
impose a fixed `height` / `width: var(--r-btn-rest-h)`, we have
to defeat those so the badge always fits whatever `:size` the
PlatformIcon was given (icon + 3px padding all around). */
width: auto !important;
@@ -661,7 +661,7 @@ function onStaticKeydown(e: KeyboardEvent) {
border: 1px solid var(--r-color-overlay-border) !important;
color: var(--r-color-overlay-fg) !important;
/* Override RBtn's at-rest opacity (0.7) so the 78% scrim reads at
full strength — without this the bg modulates down to ~55% and
full strength: without this the bg modulates down to ~55% and
reads faint over busy cover art. */
opacity: 1 !important;
transition:
@@ -720,7 +720,7 @@ function onStaticKeydown(e: KeyboardEvent) {
transition: opacity 0.12s ease;
}
/* Hover-driven art scale gated to mouse/touch — a cursor parked from a
/* Hover-driven art scale gated to mouse/touch: a cursor parked from a
previous mouse session shouldn't compete with the focused card when
the user is on a gamepad. Focus / pinned / sibling-pinned states
trigger the same effect in every modality. */
@@ -752,7 +752,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__overlay,
opacity: 1;
}
/* Consumer-driven `#overlay` slot — top-right anchor by default so
/* Consumer-driven `#overlay` slot: top-right anchor by default so
provider logos / status pills sit out of the way of the rating
centred above. `pointer-events: none` lets clicks pass through to
the card so the consumer doesn't have to opt out per-element. */
@@ -767,7 +767,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__overlay,
pointer-events: none;
}
/* Cover-art PIP (CoverArtPip) — fades out under the hover overlay so it never
/* Cover-art PIP (CoverArtPip): fades out under the hover overlay so it never
overlaps the action row. The footprint / chrome live in the component. */
html:not([data-input="pad"]) .r-gc:hover :deep(.cover-art-pip),
.r-gc:focus-visible :deep(.cover-art-pip),
@@ -795,7 +795,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__rating,
opacity: 1;
}
/* Status badge — corner of the cover that mirrors the platform
/* Status badge: corner of the cover that mirrors the platform
icon. The actual <button> lives inside GameActionBtn's RMenu
activator slot, so we reach it via `:deep` matching the action
class. Default position (no platform icon shown): top-left.
@@ -829,7 +829,7 @@ html:not([data-input="pad"]) .r-gc:hover :deep(.r-v2-game-btn--action-status),
opacity: 1;
}
/* Sibling-versions badge — right-side stack with the platform icon
/* Sibling-versions badge: right-side stack with the platform icon
and status badge. With a platform icon present (the normal gallery
layout) the chip drops below it; without one it takes the top-right
corner. When the sibling badge IS present, the status badge slides
@@ -852,7 +852,7 @@ html:not([data-input="pad"]) .r-gc:hover :deep(.r-v2-game-btn--action-status),
top: 80px;
}
/* Keyboard / gamepad focus — paint the outline in the brand colour and
/* Keyboard / gamepad focus: paint the outline in the brand colour and
stack a drop-shadow + outer bloom on top so the focused card reads
distinctly from hover. Mirrors the v1 console GameCard pattern. */
.r-gc:focus-visible {
@@ -866,7 +866,7 @@ html:not([data-input="pad"]) .r-gc:hover :deep(.r-v2-game-btn--action-status),
0 0 18px color-mix(in srgb, var(--r-color-brand-primary) 60%, transparent);
}
/* `selected` — same brand-outline language as focus, but persistent.
/* `selected`: same brand-outline language as focus, but persistent.
Used by cover-variant pickers and multi-select galleries to mark the
currently-picked card without relying on focus state. No flush `0 0 0 Npx`
ring here: the `outline` above already draws that border, and stacking a
@@ -923,7 +923,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__label,
/* ── Selection checkbox ────────────────────────────────────
Top-left affordance. The visible chrome (box, fill, draw-in
tick, press squash) all comes from RCheckbox — this rule
tick, press squash) all comes from RCheckbox: this rule
only owns the positioning and the visibility fade.
At rest the checkbox is invisible; it fades in on hover or
focus, and stays pinned whenever the gallery is in selection
@@ -947,7 +947,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__check,
}
/* Glassy scrim under the unchecked box so the outline reads over
busy cover art — RCheckbox's default border colour assumes a
busy cover art: RCheckbox's default border colour assumes a
plain page background, here it sits on photos. Once checked the
primitive's brand fill takes over and we step out of the way. */
.r-gc__check :deep(.r-checkbox__box) {
@@ -956,7 +956,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__check,
}
.r-gc__check.r-checkbox--checked :deep(.r-checkbox__box),
.r-gc__check.r-checkbox--indeterminate :deep(.r-checkbox__box) {
/* Let RCheckbox's checked styles win — clear our scrim overrides
/* Let RCheckbox's checked styles win: clear our scrim overrides
so the brand fill + glow apply unmodified. */
background: var(--r-color-brand-primary);
border-color: var(--r-color-brand-primary);
@@ -967,7 +967,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__check,
locally on the card, so every downstream rule that already reads
those vars (`.r-gc { width }`, `.r-gc__art { width / height }`,
`.r-gc--hero .r-gc__art`) picks up the new values without
duplication. "md" is the default — no class — so the gallery
duplication. "md" is the default, no class, so the gallery
grid (which reads the global `--r-card-art-w`) stays in lock-step
with the un-tiered card. */
.r-gc--size-xs {
@@ -997,7 +997,7 @@ html:not([data-input="pad"]) .r-gc:hover .r-gc__check,
/* ── Mobile ───────────────────────────────────────────────────
Forces a tighter footprint on phones. Only applies to the
default (md) card — explicit size tiers are intentional choices
default (md) card: explicit size tiers are intentional choices
from the consumer (an `xs` list-row avatar should stay 48px even
on mobile). Sets the vars instead of `width` directly so the
inner `.r-gc__art` rule picks them up too. */
@@ -11,10 +11,10 @@ import { RSkeletonBlock } from "@v2/lib";
import { computed } from "vue";
interface Props {
/** Hero variant — 16:9 art with a larger label, matching `GameCard`'s
/** Hero variant: 16:9 art with a larger label, matching `GameCard`'s
* `hero` prop. Scales with `size` (same as GameCard). */
hero?: boolean;
/** Size tier — mirrors `GameCard`'s `size` prop so a skeleton swapped
/** Size tier: mirrors `GameCard`'s `size` prop so a skeleton swapped
* into a tiered card slot keeps the same footprint and the
* virtualiser's measurements stay stable. */
size?: "xs" | "sm" | "md" | "lg" | "xl";
@@ -72,7 +72,7 @@ const artHeight = computed(() =>
--r-card-art-h: calc(var(--r-card-art-w) / var(--r-cover-ratio, 0.6667));
}
/* Hero (16:9) variant — mirrors `GameCard`'s `.r-gc--hero` block. The
/* Hero (16:9) variant: mirrors `GameCard`'s `.r-gc--hero` block. The
art skeleton picks up its own larger size via the prop-bound CSS
vars; here we just widen the container so the centred label sits at
the right horizontal width, and bump the label's reserved height to
@@ -85,7 +85,7 @@ const artHeight = computed(() =>
height: 18px;
}
/* Size tiers — mirror GameCard's `.r-gc--size-*` overrides exactly so
/* Size tiers: mirror GameCard's `.r-gc--size-*` overrides exactly so
skeleton + real card occupy the same footprint when one swaps for
the other in a virtualiser slot. */
.r-gcs--size-xs {
@@ -1,5 +1,5 @@
<script setup lang="ts">
// SiblingBadge — gallery overlay that exposes a ROM's grouped sibling
// SiblingBadge: gallery overlay that exposes a ROM's grouped sibling
// versions. Visible only when `groupRoms` is on (the gallery is showing
// one card per sibling group); with grouping off every version is its
// own card and the chip would be redundant noise.
@@ -10,7 +10,7 @@
// a router-link to that sibling's detail page. The current rom is
// marked as the active row (radio-like) so the user can see which
// one they're looking at.
// * Click on the chip itself doesn't navigate — the rom's card
// * Click on the chip itself doesn't navigate: the rom's card
// already handles its own click; the chip is a popover trigger only.
import { RIcon, RMenu, RMenuItem } from "@v2/lib";
import { computed, ref } from "vue";
@@ -25,7 +25,7 @@ const props = withDefaults(
rom: SimpleRom;
/** Layout direction. Mirrors GameActionBtn's `orientation` prop:
* `vertical` stacks icon over count (the GameCard cover-overlay
* default — fits the right-side column of badges), `horizontal`
* default: fits the right-side column of badges), `horizontal`
* places them side-by-side (used inline in list rows where the
* badge sits next to the title). */
orientation?: "horizontal" | "vertical";
@@ -36,7 +36,7 @@ const props = withDefaults(
const { t } = useI18n();
const { groupRoms } = useUISettings();
// Menu-open state — exposed to the parent surface via the
// Menu-open state: exposed to the parent surface via the
// `.sibling-badge--pinned` class on the chip, so the GameCard can keep
// its hover chrome painted while the version list is open (same
// "pinned" pattern GameActionBtn uses for `more` / `status` menus).
@@ -44,7 +44,7 @@ const menuOpen = ref(false);
// The scan socket (`scan:scanning_rom`) emits roms with `sibling_roms`
// stripped from the payload, so it can be undefined here even though the
// type marks it required. Default to an empty list — otherwise reading
// type marks it required. Default to an empty list: otherwise reading
// `.length`/`.map` throws while a scan streams roms and `groupRoms` is on.
// REST payloads always include it (possibly empty).
const siblings = computed(() => props.rom.sibling_roms ?? []);
@@ -62,7 +62,7 @@ const tooltipText = computed(() =>
// `[rom, ...siblings]` mirrors the order v1's VersionSwitcher uses. The
// current rom always leads so the user immediately reads "the one I'm
// looking at" before the alternates. `main` flags the user-marked
// default version — read off `rom_user` for this rom and off the
// default version: read off `rom_user` for this rom and off the
// per-sibling `is_main_sibling` field for the rest (the backend
// resolves it from the request user's RomUser).
const versions = computed(() => [
@@ -83,7 +83,7 @@ const versions = computed(() => [
const mainTooltip = computed(() => t("rom.default-version"));
// Prevent the parent card's `router-link` from intercepting clicks on
// the chip / menu — without this the gallery would navigate to the
// the chip / menu: without this the gallery would navigate to the
// current rom instead of opening the menu.
function stopCard(e: Event) {
e.stopPropagation();
@@ -138,7 +138,7 @@ function stopCard(e: Event) {
</template>
<style scoped>
/* Vertical pill — same visual idiom as `GameActionBtn` with
/* Vertical pill: same visual idiom as `GameActionBtn` with
`orientation="vertical"` (icon stacked over the count) so the
sibling badge reads as a sibling of the status badge in the
right-side column. Positioning lives on the consuming surface
@@ -1,5 +1,5 @@
<script setup lang="ts">
// AchievementsTab — RetroAchievements summary + filter row + per-achievement
// AchievementsTab: RetroAchievements summary + filter row + per-achievement
// list. The "earned" set comes from the parent (computed off
// auth.user.ra_progression so it stays reactive); rows look up by
// `badge_id` against the set in O(1).
@@ -1,5 +1,5 @@
<script setup lang="ts">
// AgeRatingBadges — renders the ROM's age ratings as visual badges.
// AgeRatingBadges: renders the ROM's age ratings as visual badges.
// Mirrors v1's matching logic (Details/Info/GameInfo.vue): the merged
// list of rating strings on `metadatum.age_ratings` is cross-referenced
// with `igdb_metadata.age_ratings` and `ss_metadata.age_ratings` to
@@ -13,7 +13,7 @@ import { computed, reactive } from "vue";
import type { DetailedRom } from "@/stores/roms";
// IGDB hosts every rating icon at a conventional URL, but not every
// `category_rating` combo is actually populated — old/regional ratings
// `category_rating` combo is actually populated: old/regional ratings
// 404 even though `igdbIconUrl` can build the URL. We track every URL
// that 404s in this Set so the matching badge swaps to the text chip
// fallback on the next paint, matching GameCard's "load image →
@@ -68,7 +68,7 @@ const badges = computed<Badge[]>(() => {
);
return ratings.map<Badge>((entry) => {
// Manually entered "CATEGORY:RATING" — reconstruct the icon URL
// Manually entered "CATEGORY:RATING": reconstruct the icon URL
// by convention since there's no provider object to look up.
if (entry.includes(":")) {
const [categoryRaw, ratingRaw] = entry.split(":");
@@ -1,15 +1,15 @@
<script setup lang="ts">
// CoverColumn — fixed-width left column wrapping the shared GameCover. The
// CoverColumn: fixed-width left column wrapping the shared GameCover. The
// cover honours the details-page boxart style (falling back to the
// gallery-wide one), animates on hover, paints the procedural placeholder
// when empty, and is the destination of the shared-element morph from the
// GameCard the user clicked through from — all of that lives in GameCover
// GameCard the user clicked through from: all of that lives in GameCover
// now; this just sizes the column.
//
// When the resolved boxart style is the 3D box AND the rom has the full set
// of flat scans (front + back + spine, from ScreenScraper), the hero
// upgrades to the interactive RBox3D the user can spin. Anything missing —
// a different style, an incomplete set, or a failed image — falls straight
// upgrades to the interactive RBox3D the user can spin. Anything missing:
// a different style, an incomplete set, or a failed image: falls straight
// back to the flat GameCover.
import { RBox3D } from "@v2/lib";
import { computed, ref } from "vue";
@@ -1,5 +1,5 @@
<script setup lang="ts">
// FileRow — a single file entry inside the FilesTab list.
// FileRow: a single file entry inside the FilesTab list.
//
// Owns the row layout: leading selection checkbox, filename block
// (icon + path), meta line (category chip + size + audio metadata),
@@ -9,7 +9,7 @@
// API calls.
//
// The parent also decides whether to render the category-icon at the
// row's leading edge and the category chip in the meta line — both
// row's leading edge and the category chip in the meta line: both
// are redundant inside folder subtabs where every row shares the same
// category, so we toggle them off there.
import { RBtn, RCheckbox, RChip, RIcon } from "@v2/lib";
@@ -31,7 +31,7 @@ const props = defineProps<{
/** Filename rendered in the row; the parent strips the folder
* prefix inside folder subtabs and passes the result here. */
displayPath: string;
/** Full relative path — used for native title / aria-label so the
/** Full relative path: used for native title / aria-label so the
* context isn't lost when the displayed name is short. */
relativePath: string;
selected: boolean;
@@ -52,7 +52,7 @@ const emit = defineEmits<{
(e: "delete"): void;
}>();
// Shared category metadata — kept inline (instead of importing from
// Shared category metadata: kept inline (instead of importing from
// FilesTab) so this row is self-contained. The chip / icon are only
// rendered when the parent says so, so the lookup stays cheap.
const CATEGORY_META = computed<
@@ -294,7 +294,7 @@ const hasAnyHash = computed(
.r-v2-file-row__check {
/* Mirror the filename's line box so the checkbox aligns with the
filename's optical centre instead of the row's top edge. The
filename uses `font-size: 13px` (see __row-path) — multiplying
filename uses `font-size: 13px` (see __row-path), multiplying
by the inherited body line-height (~1.5) yields ~19.5px, which
we round to 20px here. `align-items: center` then drops the
16px sm-checkbox box on the line centre. */
@@ -1,5 +1,5 @@
<script setup lang="ts">
// FilesSummary — top-of-FilesTab card showing the ROM's name + total
// FilesSummary: top-of-FilesTab card showing the ROM's name + total
// size + revision + ROM-level hashes (click-to-copy via HashChip).
// Carries the missing-from-fs flag when applicable.
import { RIcon } from "@v2/lib";
@@ -24,7 +24,7 @@ interface RomHash {
value: string | null;
}
const hashes = computed<RomHash[]>(() => {
// CHD SHA-1 lives on the file, not the ROM — surface it at ROM level
// CHD SHA-1 lives on the file, not the ROM: surface it at ROM level
// only when the ROM is a single CHD file (mirrors v1's FileInfo).
const chdSha1 = props.rom.has_simple_single_file
? (props.rom.files[0]?.chd_sha1_hash ?? null)
@@ -1,9 +1,9 @@
<script setup lang="ts">
// GameHeader — right-column header for the details view.
// GameHeader: right-column header for the details view.
// Four rows, top to bottom:
// 1. Title (+ previous / next game arrows on the right, desktop only)
// 2. Meta (year · platform-icon + platform · verified RTag)
// 3. Tags (regions + languages + custom tags) — RTag primitive,
// 3. Tags (regions + languages + custom tags): RTag primitive,
// each a `searchLocation` pivot into the filtered search
// 4. GameActions (Play · Download · Favorite · Share · More)
//
@@ -1,5 +1,5 @@
<script setup lang="ts">
// InfoGrid — two-column section grid used in the Overview tab.
// InfoGrid: two-column section grid used in the Overview tab.
// Each section is `icon + label` on the header line and a row of
// small chips below (genres, companies, franchises, collections, …).
// The icon gives each section a semantic anchor instead of relying
@@ -19,7 +19,7 @@ defineOptions({ inheritAttrs: false });
export type InfoGridSection = {
label: string;
items: string[];
/** Leading icon for the section header — gives each category a
/** Leading icon for the section header: gives each category a
* semantic cue (e.g. tags for genres, building for companies). */
icon?: string;
/** Gallery filter these items map to. Set it to make the chips
@@ -65,7 +65,7 @@ const visible = () => props.sections.filter((s) => s.items.length > 0);
</template>
<style scoped>
/* Auto-fill grid spanning the whole details body — sections reflow to a
/* Auto-fill grid spanning the whole details body: sections reflow to a
new row when the column would shrink below 240px. Mirrors the
responsive pattern used by ProviderGrid in the Metadata tab so both
surfaces feel like siblings. */
@@ -89,7 +89,7 @@ const visible = () => props.sections.filter((s) => s.items.length > 0);
}
.r-v2-det-infogrid__label-icon {
/* Brand-primary keeps the section header readable as a "label" cue
against the muted eyebrow text — the icon is the focal point. */
against the muted eyebrow text: the icon is the focal point. */
color: var(--r-color-brand-primary);
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
// MainSiblingToggle — marks this rom as the "default version" of the
// MainSiblingToggle: marks this rom as the "default version" of the
// sibling group: when the gallery groups siblings, the main one is the
// card that's shown in the user's library. Per-user state — every user
// card that's shown in the user's library. Per-user state: every user
// picks their own preferred version.
//
// The backend enforces the exclusive constraint (setting this one to
@@ -1,5 +1,5 @@
<script setup lang="ts">
// MarkdownViewer (v2) — renders a Markdown (.md) manual with the same v2
// MarkdownViewer (v2): renders a Markdown (.md) manual with the same v2
// chrome as PdfViewer. Manuals can be PDF or Markdown; MediaTab picks the
// viewer by extension. The file is fetched as text and handed to MdPreview
// (md-editor-v3), the same renderer used by NotesTab.
@@ -43,7 +43,7 @@ const mdTheme = computed<"light" | "dark">(() =>
isLight.value ? "light" : "dark",
);
// Filename for the download button — last path segment without the cache-bust
// Filename for the download button: last path segment without the cache-bust
// query, decoded back to its human form (e.g. `README.md`).
const fileName = computed(() => {
const path = props.url.split("?")[0];
@@ -187,7 +187,7 @@ watch(() => props.url, load, { immediate: true });
min-height: 0;
}
/* Toolbar inherits the parent's bg-elevated — no separate background or
/* Toolbar inherits the parent's bg-elevated: no separate background or
divider so the surface reads as one continuous panel (mirrors PdfViewer). */
.r-v2-mdv__toolbar {
display: flex;
@@ -258,7 +258,7 @@ watch(() => props.url, load, { immediate: true });
height: 100%;
}
/* MdPreview paints its own surface — blend it with the surrounding
/* MdPreview paints its own surface: blend it with the surrounding
bg-elevated container so there's no visible seam under the toolbar. */
.r-v2-mdv__preview {
background: transparent;
@@ -1,5 +1,5 @@
<script setup lang="ts">
// PlayerCountBadge — renders `metadatum.player_count` as a single
// PlayerCountBadge: renders `metadatum.player_count` as a single
// semantic badge. The icon swaps with the maximum number of players
// the string mentions:
// 1 → mdi-account-outline ("Single player")
@@ -7,7 +7,7 @@
// 3–4 → mdi-account-group-outline
// 5+ → mdi-account-multiple-plus-outline
// The string the API returns is free-form (the providers feed it as
// a bare number, a range, or a sentence — "1", "1-4", "Up to 4
// a bare number, a range, or a sentence: "1", "1-4", "Up to 4
// players", "Single Player", "Massively Multiplayer" …). We pull
// the largest integer out as the signal and fall back to a generic
// group icon when no digit is parseable, so non-numeric descriptors
@@ -8,7 +8,7 @@
//
// Two conditions gate the arrows, and both are needed. The ROM has to be
// in that list, and the user has to have arrived from the gallery that
// built it (see useGalleryProvenance) — the store keeps its index alive
// built it (see useGalleryProvenance): the store keeps its index alive
// after the gallery is left, so membership alone would hand a stale list
// to a ROM opened from Home, Activity or a scan result.
import { RBtn } from "@v2/lib";
@@ -1,5 +1,5 @@
<script setup lang="ts">
// ProviderCard — single metadata-source card. Provider name + logo
// ProviderCard: single metadata-source card. Provider name + logo
// + linked external ID (or "Not linked"), plus the provider's rating
// when it publishes one. When `url` is non-null and the ID is set, the
// card renders as an `<a target=_blank>`; otherwise it's a static div.
@@ -1,5 +1,5 @@
<script setup lang="ts">
// ProviderGrid — Metadata-tab grid showing every configured provider.
// ProviderGrid: Metadata-tab grid showing every configured provider.
// Linked providers come first, unlinked ones follow in a muted row so
// users can see what's missing. Card visuals are owned by ProviderCard.
import { computed } from "vue";
@@ -1,11 +1,11 @@
<script setup lang="ts">
// RelatedGamesGrid — one labelled grid of IGDBRelatedGame tiles.
// RelatedGamesGrid: one labelled grid of IGDBRelatedGame tiles.
//
// Used by both the "Additional content" tab (Expansions / DLC) and the
// "Related" tab (Remakes / Remasters / Similar). Multiple sections per
// tab? Render this component multiple times — one per section. Each
// tab? Render this component multiple times: one per section. Each
// card is a RelatedGameCard, which owns the per-card cross-reference
// against the local RomM library — so this grid stays a thin renderer.
// against the local RomM library: so this grid stays a thin renderer.
import type { IGDBRelatedGame } from "@/__generated__";
import RelatedGameCard from "@/v2/components/GameDetails/RelatedGameCard.vue";
@@ -38,7 +38,7 @@ defineProps<{
color: var(--r-color-fg-faint);
}
/* Flex-wrap (not auto-fill grid) — GameCard has a fixed 158px width
/* Flex-wrap (not auto-fill grid): GameCard has a fixed 158px width
and a hard-coded `flex-shrink: 0`, so a `1fr` track would tear the
layout: the column would compute below 158px on narrow viewports
and the cards would overflow into the next column. Flex-wrap lets
@@ -247,7 +247,7 @@ async function onStateUpload(files: File[], emulator: string | null) {
}
// ---------- Per-row actions ----------
// Both saves and states ship `download_path` from the backend — fire
// Both saves and states ship `download_path` from the backend: fire
// a synthesized anchor click rather than a window.open so the browser
// uses the right filename and skips the new-tab affordance.
function downloadAsset(asset: { download_path: string; file_name: string }) {
@@ -591,7 +591,7 @@ const labelSuggestions = computed(() =>
</aside>
<div class="r-v2-saves__content">
<!-- Saves subtab — vertical info list -->
<!-- Saves subtab: vertical info list -->
<section v-show="subTab === 'saves'" class="r-v2-saves__panel">
<!-- Mine -->
<div class="r-v2-saves__section">
@@ -699,7 +699,7 @@ const labelSuggestions = computed(() =>
</div>
</section>
<!-- States subtab — tile grid (screenshot is the point) -->
<!-- States subtab: tile grid (screenshot is the point) -->
<section v-show="subTab === 'states'" class="r-v2-saves__panel">
<!-- Mine -->
<div class="r-v2-saves__section">
@@ -1,5 +1,5 @@
<script setup lang="ts">
// TextViewer (v2) — renders a plain-text (.txt) or HTML (.html/.htm) document
// TextViewer (v2): renders a plain-text (.txt) or HTML (.html/.htm) document
// with the same v2 chrome as MarkdownViewer/PdfViewer. Used for manuals and
// walkthroughs. Plain text is fetched and shown in a <pre> (so reading
// progress can track its scroll); HTML is shown in a sandboxed iframe (the
@@ -1,5 +1,5 @@
<script setup lang="ts">
// VersionSwitcher — RBtn + RMenu pair that lets the user navigate
// VersionSwitcher: RBtn + RMenu pair that lets the user navigate
// between sibling ROMs of the same game (different region / revision /
// language). Renders nothing when the rom has no siblings.
//
@@ -8,7 +8,7 @@ export type Provider = {
/** Field on DetailedRom that holds the provider's external ID. */
key: keyof DetailedRom;
name: string;
/** CSS color value (token reference) — kept inline because tokens
/** CSS color value (token reference): kept inline because tokens
* resolve at the consumer's CSS context, not at this file. */
color: string;
/** Optional favicon for the provider's identity. */
@@ -1,5 +1,5 @@
<script setup lang="ts">
// LiveSessionCard — a multiplayer streaming session another user is hosting
// LiveSessionCard: a multiplayer streaming session another user is hosting
// right now, shown on the Home row. Clicking asks to join and then opens
// the stream as a viewer.
import { RChip, RIcon } from "@v2/lib";
@@ -1,9 +1,9 @@
<script setup lang="ts">
// LibraryStatsWidget — counts overview of what's in RomM.
// LibraryStatsWidget: counts overview of what's in RomM.
//
// Two display modes (driven by `libraryStatsMode` UI setting):
// • compact — games / platforms / favorites (3 rows, mock parity)
// • extended — adds saves / states / screenshots / disk size, the
// • compact - games / platforms / favorites (3 rows, mock parity)
// • extended: adds saves / states / screenshots / disk size, the
// full v1 "Home/Stats" surface, condensed into the
// widget vocabulary. The card grows wider (not taller)
// and renders the rows in a 2-column grid so every
@@ -143,7 +143,7 @@ onMounted(async () => {
min-width: 0;
}
/* Extended mode — same card height as compact, but the card itself
/* Extended mode: same card height as compact, but the card itself
grows wider and the rows lay out in a 3-column grid so all 7 stats
(3 base + 4 extended) fit in 3 rows inside the shared rail height.
Columns size to their content (`auto`, paired with the card's

Some files were not shown because too many files have changed in this diff Show More