Consolidate agent instructions into AGENTS.md and add a shared dev environment setup (#4869)

This commit is contained in:
Sam Dornan
2026-09-28 09:33:32 -04:00
committed by GitHub
parent f515b69612
commit bf6f6667ef
14 changed files with 264 additions and 103 deletions
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# SessionStart hook for cloud sessions: runs dev-setup.sh, which also restarts
# MariaDB since environment snapshots keep files but not running processes.
if [[ ${CLAUDE_CODE_REMOTE-} != "true" ]]; then
exit 0
fi
if [[ -n ${CLAUDE_ENV_FILE-} ]]; then
echo "export PATH=\"${HOME}/.local/bin:\$PATH\"" >>"${CLAUDE_ENV_FILE}"
fi
LOG="${TMPDIR:-/tmp}/romm-dev-setup.log"
if bash "${CLAUDE_PROJECT_DIR}/scripts/dev-setup.sh" --db >"${LOG}" 2>&1; then
tail -n 1 "${LOG}"
else
echo "scripts/dev-setup.sh --db failed; see ${LOG}"
fi
+9
View File
@@ -8,6 +8,15 @@
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/setup-code-signing.sh\""
}
]
},
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/cloud-setup.sh\""
}
]
}
]
},
+1 -1
View File
@@ -89,7 +89,7 @@ Give each finding one verdict, backed by evidence you checked yourself:
Weigh each claim against what the PR deliberately does. A finding that would
undo a behaviour the maintainer asked for is a decline, with that intent as the
reason. Repo rules are a valid source: Greptile enforces the comment discipline
from `CLAUDE.md` and `review-polish`, and those findings stand.
from `AGENTS.md` and `review-polish`, and those findings stand.
A valid finding that is pattern-shaped is a missing check: name one as a
follow-up in the reply (see `review-polish`, "Encode what you fixed twice").
+1 -1
View File
@@ -19,7 +19,7 @@ reading code, not as a substitute for reading it.
Skip it when you already know the file or symbol (read that directly), and when
the question is about work on the current branch, since the server cannot see
the checkout. For the rules on how to write code, `CLAUDE.md`, the focused
the checkout. For the rules on how to write code, `AGENTS.md`, the focused
skills and `docs/*_ARCHITECTURE.md` are authoritative.
## Tools
+1 -1
View File
@@ -21,7 +21,7 @@ fmt && trunk check` comes last of all, so nothing lands unformatted. If
## A. Comments and docstrings: the most-corrected thing in this repo
`CLAUDE.md` already says keep comments short, don't restate the code, don't
`AGENTS.md` already says keep comments short, don't restate the code, don't
explain a change. In practice contributions still ship multi-paragraph
rationale, and it gets cut. Cut it yourself.
+67 -2
View File
@@ -1,6 +1,55 @@
# Repository Instructions
# RomM - Repository Guide for Contributors & Agents
Read and follow [CLAUDE.md](CLAUDE.md) for all repository instructions.
RomM is a self-hosted ROM manager and player: scan a game library off disk, enrich it with metadata from 10+ providers, browse it in a web UI, and play in the browser.
---
The frontend talks to the backend over `/api/*` (REST) and `/ws` (Socket.IO). TypeScript types are **generated** from the backend's OpenAPI schema into `frontend/src/__generated__/` - the backend is the single source of truth for API shapes.
## Deep-dive references
- **`docs/BACKEND_ARCHITECTURE.md`** - directory map, ER diagram, every endpoint, auth/scopes, tasks.
- **`docs/FRONTEND_ARCHITECTURE.md`** - routing, stores, services, theming, build tooling.
- **`DEVELOPER_SETUP.md`** - Docker and manual local setup (mock library, `.env`, services).
- **`CONTRIBUTING.md`** - contribution flow, **AI-assistance disclosure**, translations.
---
## Stack-specific guidance
`frontend/AGENTS.md` (v1 vs v2, frontend commands) and `backend/AGENTS.md` (backend commands) load when you work in those directories. New frontend work goes in v2 (`frontend/src/v2/`); v1 is frozen.
---
## Skills - load the focused guide for your task
These live in `.claude/skills/` and carry the detailed rules. Invoke the one that matches what you're doing. `pr-ready`, `address-bot-reviews`, and `draft-announcement` are run by the user, not auto-invoked.
---
## Repo-wide rules
**Disclose AI assistance in the PR.** RomM requires it (see `CONTRIBUTING.md`): state that AI was used and to what extent. This is mandatory and non-negotiable for agent-written contributions.
**Branch off `master`; open PRs against `master`.** Fork → feature branch → PR. Don't push to `master`.
**Linting is via [Trunk](https://trunk.io)** (`trunk fmt && trunk check`), which wraps ruff, black, isort, ESLint, Prettier, and more, and runs in CI on every PR. **Never commit with `--no-verify`.**
**The backend owns the API contract.** Changed a response schema or route? Regenerate frontend types (`npm run generate`) and re-typecheck.
**Tests travel with code.** New logic gets a test; new endpoints get endpoint tests; new v2 primitives get a Storybook story (+ `play()` if interactive).
**Verify before handoff.** Don't say "done" on UI work without testing it in the browser in both themes and all input modalities. See `review-polish`.
**English first.** Outside of language files, all code, comments, identifiers, `.md` files, and commit/PR messages are in English.
**No em-dashes.** Never use em-dashes (U+2014) when writing comments or text. Use commas, parentheses, or separate sentences instead. An em-dash string literal is code (an empty-value glyph) and stays allowed.
**Prefer a check to a model review.** A mechanical rule (an import, a character, a CSS pattern, an SFC shape) belongs in ESLint, `tsconfig`, or Trunk. When a review finding repeats, propose a check as a named follow-up; see `review-polish`.
**Keep comments short.** Comments should be concise, and focus on the "why" rather than the "what" (the code itself is the "what"). A comment is one or two lines; a docstring is one sentence plus `Args:`/`Returns:` when the signature needs it. Three or more lines of prose means you are explaining, not commenting. See `review-polish` for what to cut.
**Don't restate the code.** The code is self-documenting, so skip comments that narrate what a reader can already see (which button sits where, that something is disabled when empty, obvious ordering). Comment only non-obvious "why" that the code can't convey on its own.
**One home per value.** Before declaring a constant, type, limit, regex, or store getter, search for an existing one and import it. Validation limits live on the model and are imported by endpoints; a type lives in the component that owns it and is exported. Don't add a near-duplicate that differs only by sorting, and don't invent an identity scheme where a foreign key already exists.
**Never commit secrets.** Never commit secrets (API keys, passwords, tokens, etc.) to the repo. Use environment variables or secret management tools instead.
**Don't explain a change.** Avoid comments that explain why a change was made to the code, or that record what the code used to do. Focus instead on the current behaviour of the code and how it works. Reasons for a change go in the commit message and the PR body.
**Python tools live in `backend/tools/`.** Standalone dev/test utilities and scripts (not part of the app runtime) go in `backend/tools/`, not scattered across `backend/`.
**Link PRs to issues.** In the PR description, use `Fixes #XXXX` for issue/bug fixes and `Closes #XXXX` for feature implementations.
**Use the PR template.** Base every PR description on `.github/PULL_REQUEST_TEMPLATE.md`.
**Show UI changes in the PR.** A PR that touches anything visible ships with at least one screenshot under the template's `Screenshots` heading, captured from the running app during the `review-polish` browser pass. Enough shots to convey what changed, not a catalogue of every state. Save the files outside the repo and never commit them, and upload them with `gh pr create --attach` (or `gh pr edit --attach` on an existing PR) rather than handing the paths to the user.
**Diagram architectural changes in the PR.** When a change moves a boundary (a new service, task, or provider in a path, a model relationship, a different call path across layers, an auth or socket flow), the PR description carries a `mermaid` block showing it. GitHub renders them. Draw what changed, not the whole system. See `review-polish`.
---
## Never run the full backend test suite locally
@@ -23,3 +72,19 @@ callers of anything whose signature or behavior you changed (`grep` for the symb
CI runs the whole suite on the PR (`pytest.yml`, MariaDB + PostgreSQL). That is the
place for full-suite coverage; local runs stay scoped.
---
## Environment setup
Each tool version is pinned in one file: Python in `.python-version`, uv as `ARG UV_VERSION` in `docker/Dockerfile`, and Node in `frontend/.nvmrc`. Don't install these by hand. Run `scripts/dev-setup.sh`, which installs the pinned toolchain, the backend and frontend dependencies, and the Trunk launcher, and skips anything already in place. Add `--db` to also install and start MariaDB with the pytest database (Debian/Ubuntu only). Rerun it whenever a version is wrong or a dependency is missing.
In Claude Code cloud sessions, `.claude/hooks/cloud-setup.sh` runs `scripts/dev-setup.sh --db` at session start. Setting the environment's setup script to `bash scripts/dev-setup.sh --db` gets the install cached in the environment snapshot, so the hook only has to restart MariaDB. Other agents should use the same command as their setup step.
---
## Quick command reference
**Setup:** see `DEVELOPER_SETUP.md`. Docker path is `cp env.template .env` → `docker compose build` → `docker compose up -d` (app at `http://localhost:3000`).
**Lint (both stacks):** `trunk fmt && trunk check`.
-58
View File
@@ -1,58 +0,0 @@
# RomM - Repository Guide for Contributors & Agents
RomM is a self-hosted ROM manager and player: scan a game library off disk, enrich it with metadata from 10+ providers, browse it in a web UI, and play in the browser.
---
The frontend talks to the backend over `/api/*` (REST) and `/ws` (Socket.IO). TypeScript types are **generated** from the backend's OpenAPI schema into `frontend/src/__generated__/` - the backend is the single source of truth for API shapes.
## Deep-dive references
- **`docs/BACKEND_ARCHITECTURE.md`** - directory map, ER diagram, every endpoint, auth/scopes, tasks.
- **`docs/FRONTEND_ARCHITECTURE.md`** - routing, stores, services, theming, build tooling.
- **`DEVELOPER_SETUP.md`** - Docker and manual local setup (mock library, `.env`, services).
- **`CONTRIBUTING.md`** - contribution flow, **AI-assistance disclosure**, translations.
---
## Stack-specific guidance
`frontend/CLAUDE.md` (v1 vs v2, frontend commands) and `backend/CLAUDE.md` (backend commands) load when you work in those directories. New frontend work goes in v2 (`frontend/src/v2/`); v1 is frozen.
---
## Skills - load the focused guide for your task
These live in `.claude/skills/` and carry the detailed rules. Invoke the one that matches what you're doing. `pr-ready`, `address-bot-reviews`, and `draft-announcement` are run by the user, not auto-invoked.
---
## Repo-wide rules
**Disclose AI assistance in the PR.** RomM requires it (see `CONTRIBUTING.md`): state that AI was used and to what extent. This is mandatory and non-negotiable for agent-written contributions.
**Branch off `master`; open PRs against `master`.** Fork → feature branch → PR. Don't push to `master`.
**Linting is via [Trunk](https://trunk.io)** (`trunk fmt && trunk check`), which wraps ruff, black, isort, ESLint, Prettier, and more, and runs in CI on every PR. **Never commit with `--no-verify`.**
**The backend owns the API contract.** Changed a response schema or route? Regenerate frontend types (`npm run generate`) and re-typecheck.
**Tests travel with code.** New logic gets a test; new endpoints get endpoint tests; new v2 primitives get a Storybook story (+ `play()` if interactive).
**Verify before handoff.** Don't say "done" on UI work without testing it in the browser in both themes and all input modalities. See `review-polish`.
**English first.** Outside of language files, all code, comments, identifiers, `.md` files, and commit/PR messages are in English.
**No em-dashes.** Never use em-dashes (U+2014) when writing comments or text. Use commas, parentheses, or separate sentences instead. An em-dash string literal is code (an empty-value glyph) and stays allowed.
**Prefer a check to a model review.** A mechanical rule (an import, a character, a CSS pattern, an SFC shape) belongs in ESLint, `tsconfig`, or Trunk. When a review finding repeats, propose a check as a named follow-up; see `review-polish`.
**Keep comments short.** Comments should be concise, and focus on the "why" rather than the "what" (the code itself is the "what"). A comment is one or two lines; a docstring is one sentence plus `Args:`/`Returns:` when the signature needs it. Three or more lines of prose means you are explaining, not commenting. See `review-polish` for what to cut.
**Don't restate the code.** The code is self-documenting, so skip comments that narrate what a reader can already see (which button sits where, that something is disabled when empty, obvious ordering). Comment only non-obvious "why" that the code can't convey on its own.
**One home per value.** Before declaring a constant, type, limit, regex, or store getter, search for an existing one and import it. Validation limits live on the model and are imported by endpoints; a type lives in the component that owns it and is exported. Don't add a near-duplicate that differs only by sorting, and don't invent an identity scheme where a foreign key already exists.
**Never commit secrets.** Never commit secrets (API keys, passwords, tokens, etc.) to the repo. Use environment variables or secret management tools instead.
**Don't explain a change.** Avoid comments that explain why a change was made to the code, or that record what the code used to do. Focus instead on the current behaviour of the code and how it works. Reasons for a change go in the commit message and the PR body.
**Python tools live in `backend/tools/`.** Standalone dev/test utilities and scripts (not part of the app runtime) go in `backend/tools/`, not scattered across `backend/`.
**Link PRs to issues.** In the PR description, use `Fixes #XXXX` for issue/bug fixes and `Closes #XXXX` for feature implementations.
**Use the PR template.** Base every PR description on `.github/PULL_REQUEST_TEMPLATE.md`.
**Show UI changes in the PR.** A PR that touches anything visible ships with at least one screenshot under the template's `Screenshots` heading, captured from the running app during the `review-polish` browser pass. Enough shots to convey what changed, not a catalogue of every state. Save the files outside the repo and never commit them, and upload them with `gh pr create --attach` (or `gh pr edit --attach` on an existing PR) rather than handing the paths to the user.
**Diagram architectural changes in the PR.** When a change moves a boundary (a new service, task, or provider in a path, a model relationship, a different call path across layers, an auth or socket flow), the PR description carries a `mermaid` block showing it. GitHub renders them. Draw what changed, not the whole system. See `review-polish`.
---
## Quick command reference
**Setup:** see `DEVELOPER_SETUP.md`. Docker path is `cp env.template .env` → `docker compose build` → `docker compose up -d` (app at `http://localhost:3000`).
**Lint (both stacks):** `trunk fmt && trunk check`.
+2
View File
@@ -95,6 +95,8 @@ cp ./bin64/RAHasher /usr/bin/RAHasher
#### - Install python dependencies
`scripts/dev-setup.sh` installs the pinned uv, Python, and Node versions plus the backend and frontend dependencies in one step, and is safe to rerun. On systems without `apt-get`, install the MariaDB and PostgreSQL client headers yourself first. To do it by hand instead, follow the steps below.
You'll need uv installed
<https://docs.astral.sh/uv/getting-started/installation/>
@@ -1,15 +1,15 @@
<script setup lang="ts">
// UserMenu — the avatar pill in the navbar that opens the v2 quick
// UserMenu: the avatar pill in the navbar that opens the v2 quick
// navigator. The dropdown mirrors the SettingsSidebar's information
// architecture so the user has the same mental model in both places:
//
// • Account — Notifications, Profile, User interface
// • Library — Library management, Scan settings, Metadata sources,
// • Account: Notifications, Profile, User interface
// • Library: Library management, Scan settings, Metadata sources,
// Client API tokens
// • System — Administration, Server stats
// • Tools — Jukebox, Controller debug
// • Actions — Scan, Upload (librarian actions, not settings)
// • About / Changelog — kept as dialogs (no dedicated views)
// • System: Administration, Server stats
// • Tools: Jukebox, Controller debug
// • Actions: Scan, Upload (librarian actions, not settings)
// • About / Changelog: kept as dialogs (no dedicated views)
// • Log out
//
// Items inherit the same scope/role gates as their target views so
@@ -62,7 +62,7 @@ const avatarSrc = computed(() =>
const isAdmin = useCan("app.admin");
// `library.scan` is an editor-up capability, so it stands in for
// "editor or admin" without an inline role check (see CLAUDE.md §VI.G).
// "editor or admin" without an inline role check (see the `frontend-v2-patterns` skill).
const canSeeChangelog = useCan("library.scan");
const canSeeProfile = computed(
@@ -155,7 +155,7 @@ async function onLogout() {
</RBtn>
</template>
<!-- User header — inlined; the layout is feature-specific so it
<!-- User header, inlined. The layout is feature-specific so it
lives at the call site, not in the lib. -->
<div class="r-v2-user-menu__header">
<RAvatar :image="avatarSrc" size="30" />
@@ -323,7 +323,7 @@ async function onLogout() {
<!-- About is visible to everyone; Changelog is gated to editor+ (it
surfaces librarian-oriented release notes). Both remain dialogs
(no dedicated views) — see CLAUDE.md. -->
(no dedicated views). -->
<RMenuItem
icon="mdi-help-circle-outline"
:label="t('common.about')"
@@ -383,7 +383,7 @@ html[data-bp~="xs"] .r-v2-user__chevron {
display: none;
}
/* Group section inside the dropdown — small uppercase label above each
/* Group section inside the dropdown: a small uppercase label above each
cluster so the IA mirrors SettingsSidebar exactly. The label is omitted
for the trailing Actions/About/Logout regions where dividers already
communicate the boundary. */
@@ -403,7 +403,7 @@ html[data-bp~="xs"] .r-v2-user__chevron {
padding: 4px 12px 2px;
}
/* Inlined header — was RMenuHeader before. Identity card at the top
/* Inlined header. Identity card at the top
of the dropdown: avatar + username + role pill. */
.r-v2-user-menu__header {
display: flex;
@@ -53,7 +53,7 @@ const coverFileInput = ref<HTMLInputElement | null>(null);
// the user sees the action is running. v1 leaned on a global
// `showLoadingDialog` event for the same feedback, but v2 has no
// listener for it (intentionally, inline `:loading` on the control
// itself is the v2 pattern, see CLAUDE.md §VI.B), so the emit was a
// itself is the v2 pattern, see the `frontend-v2-patterns` skill), so the emit was a
// no-op and the dialog appeared frozen during slow uploads / SGDB
// fetches.
const saving = ref(false);
@@ -1,12 +1,12 @@
// useGalleryVirtualItems — turns the gallery's structural state (mode,
// useGalleryVirtualItems turns the gallery's structural state (mode,
// total, charIndex, columns) into a flat list of body `GalleryItem`s
// that an RVirtualScroller can render with one slot per kind.
//
// Header and toolbar are NOT in this list — they live in the
// Header and toolbar are NOT in this list. They live in the
// scroller's `#prepend` and `#sticky` slots so `position: sticky`
// handles pinning natively (no JS scroll tracking, no jitter).
//
// Performance contract: this composable is STRUCTURAL — it only
// Performance contract: this composable is STRUCTURAL; it only
// depends on layout / groupBy / total / charIndex / columns /
// loadingInitial. It does NOT read the loaded ROMs (`byPosition`).
// Per-row slot data is resolved by the view at render time via
@@ -18,11 +18,11 @@
// Re-pack handling: a measured cover ratio change bumps `ratioVersion`, which
// re-runs the build. Two things keep that cheap so a fast scroll through a
// huge library doesn't thrash:
// * Structural sharing — unchanged item objects are reused from the previous
// * Structural sharing: unchanged item objects are reused from the previous
// build (keyed by their stable `key`), so a bump only allocates the rows
// that actually reflowed (paired with the scroller's `get-item-key`, Vue
// then moves the rest instead of remounting their cards).
// * Linear letter assignment — flat rows get their letters in one merge pass
// * Linear letter assignment: flat rows get their letters in one merge pass
// over the sorted ranges (`assignFlatRowLetters`), not a per-row scan.
//
// AlphaStrip integration is index-based: `letterToIndex` maps each
@@ -53,12 +53,12 @@ const ROW_CHROME_PX = 41;
// Reference card-art width the row height is derived from when the caller
// doesn't pass one (md gallery card). Height = width / ratio + chrome.
const REFERENCE_COVER_WIDTH_PX = 158;
// Box-art default — matches v1's `getAspectRatio` for `cover_path`.
// Box-art default, matching v1's `getAspectRatio` for `cover_path`.
const DEFAULT_COVER_RATIO = 2 / 3;
// Fixed heights for the item kinds that DON'T depend on the boxart
// aspect ratio. Row / skeleton-row are computed per-style — see
// `getItemHeight`. Values match the rendered geometry in the gallery
// aspect ratio. Row / skeleton-row are computed per-style (see
// `getItemHeight`). Values match the rendered geometry in the gallery
// views' CSS:
// * letter-header: 20px top + 16px text + 12px bottom = 48 (→ 56 for air).
// * load-more / empty: rendered button / centered text.
@@ -120,7 +120,7 @@ interface Options {
/** Total count of ROMs in the active gallery (server-provided). */
total: Ref<number> | ComputedRef<number>;
/** Letter → first-position map from the server. Backend ships letters
* lowercase / digits — `availableLetters` and the row letter sets
* lowercase / digits: `availableLetters` and the row letter sets
* normalise to AlphaStrip's bucket shape. */
charIndex: Ref<Record<string, number>> | ComputedRef<Record<string, number>>;
/** Current column count for grid modes (responsive). */
@@ -129,7 +129,7 @@ interface Options {
loadingInitial: ComputedRef<boolean>;
/** Empty-state message used when the page resolves with zero results. */
emptyMessage: Ref<string> | ComputedRef<string>;
/** "Not found" — overrides every body kind with a single empty row. */
/** "Not found" overrides every body kind with a single empty row. */
notFound?: Ref<boolean> | ComputedRef<boolean>;
/** Override the not-found message (defaults to emptyMessage). */
notFoundMessage?: Ref<string> | ComputedRef<string>;
@@ -146,7 +146,7 @@ interface Options {
* `cardHeight * ratioAt(p)`). Falls back to `fallbackRatio` until the
* image is measured. */
ratioAt?: (position: number) => number;
/** Ratio to pack an unmeasured position at — must be the ratio its card
/** Ratio to pack an unmeasured position at. Must be the ratio its card
* actually paints, which is the active boxart style's box ratio: a card
* with no artwork paints its placeholder at that ratio and never
* measures, and one waiting on its image paints there until it loads.
@@ -187,7 +187,7 @@ function buildLetterRanges(
for (let i = 0; i < entries.length; i++) {
const [letter, start] = entries[i];
const end = entries[i + 1]?.[1] ?? total;
// Backend may collapse multiple raw letters into "#" (digits, etc.) —
// Backend may collapse multiple raw letters into "#" (digits, etc.);
// merge into the previous range rather than emit duplicates.
const last = ranges[ranges.length - 1];
if (last && last.letter === letter) {
@@ -243,7 +243,7 @@ export function useGalleryVirtualItems(opts: Options) {
return r > 0 ? r : DEFAULT_COVER_RATIO;
};
/** Per-build ratio lookup — resolves the fallback once so the packer's
/** Per-build ratio lookup. Resolves the fallback once so the packer's
* hot path (one call per position) stays a single map read. */
const makeRatioAt = () => {
const fallback = fallbackRatio();
@@ -268,8 +268,8 @@ export function useGalleryVirtualItems(opts: Options) {
// Only the run of rows from the first re-measured cover onward actually
// reflows; everything before stays identical. So we REUSE the previous
// build's item objects whenever their content matches (looked up by stable
// key). A reused object keeps its reference, so — paired with the scroller's
// content-derived `get-item-key` — Vue moves the untouched rows instead of
// key). A reused object keeps its reference, so (paired with the scroller's
// content-derived `get-item-key`) Vue moves the untouched rows instead of
// re-rendering them, and only the reflowed rows allocate. This turns a
// per-bump O(total) re-allocation into O(reflowed rows). `prevByKey` holds
// the last build and survives across evaluations as closure state; if it
@@ -304,7 +304,7 @@ export function useGalleryVirtualItems(opts: Options) {
};
}
function shareHeader(letter: string): GalleryItem {
// A header's whole content is its letter, which the key encodes — same key
// A header's whole content is its letter, which the key encodes. Same key
// ⇒ identical, always reusable.
const prev = prevByKey.get(`lh-${letter}`);
if (prev?.kind === "letter-header") return prev;
@@ -332,13 +332,13 @@ export function useGalleryVirtualItems(opts: Options) {
return items;
}
// List layout — one virtual item per ROM position. The view template
// List layout: one virtual item per ROM position. The view template
// resolves each row via `getRomAt(position)` and renders skeleton vs real
// inside the same kind. Group-by-letter in list mode is deferred (see
// CLAUDE.md §X — list-mode MVP first).
// inside the same kind. Group-by-letter in list mode is deferred
// (list-mode MVP first).
if (opts.layout.value === "list") {
if (opts.loadingInitial.value && opts.total.value === 0) {
// Bootstrap phase — placeholder rows give the scroller a shape while
// Bootstrap phase: placeholder rows give the scroller a shape while
// metadata is in flight. Enough to fill a typical viewport.
const skeletonListRows = Math.max(skeletonRows * 4, 12);
for (let i = 0; i < skeletonListRows; i++) {
@@ -375,7 +375,7 @@ export function useGalleryVirtualItems(opts: Options) {
return items;
}
// Grid + first-window-loading — skeleton rows until the server returns
// Grid + first-window-loading: skeleton rows until the server returns
// `total` and `charIndex`.
if (opts.loadingInitial.value && opts.total.value === 0) {
for (let i = 0; i < skeletonRows; i++) {
@@ -424,7 +424,7 @@ export function useGalleryVirtualItems(opts: Options) {
}
}
} else {
// Flat — flow-pack the whole list, then tag every row with its
// Flat: flow-pack the whole list, then tag every row with its
// overlapping letters in one linear pass (not a per-row scan).
const rows = packFlowRows(0, total, rowWidth, cardHeight, gap, ratioAt);
const lettersPerRow = assignFlatRowLetters(rows, ranges);
@@ -438,7 +438,7 @@ export function useGalleryVirtualItems(opts: Options) {
const virtualItems = computed<GalleryItem[]>(() => {
const items = buildItems();
// Refresh the reuse cache from this build for the next re-pack — cheap
// Refresh the reuse cache from this build for the next re-pack. Cheap
// O(items) bookkeeping; the win is the avoided re-allocation in buildItems.
const nextByKey = new Map<string, GalleryItem>();
for (const it of items) nextByKey.set(it.key, it);
@@ -446,7 +446,7 @@ export function useGalleryVirtualItems(opts: Options) {
return items;
});
// AlphaStrip available letters — every letter in the server's charIndex,
// AlphaStrip available letters: every letter in the server's charIndex,
// independent of which windows are currently loaded.
const availableLetters = computed<Set<string>>(() => {
const set = new Set<string>();
@@ -459,10 +459,10 @@ export function useGalleryVirtualItems(opts: Options) {
const map = new Map<string, number>();
const items = virtualItems.value;
// List layout — one virtual item per position. Walk once and pin
// List layout: one virtual item per position. Walk once and pin
// each letter to its first list-row index. Bootstrap phase paints
// skeleton-list-rows that aren't tied to a letter, so the map is
// empty until `total` resolves — same pattern as grid.
// empty until `total` resolves, same pattern as grid.
if (opts.layout.value === "list") {
for (let i = 0; i < items.length; i++) {
const it = items[i];
@@ -473,7 +473,7 @@ export function useGalleryVirtualItems(opts: Options) {
return map;
}
// Pass 1 — grouped mode: letter-header anchors are exact.
// Pass 1, grouped mode: letter-header anchors are exact.
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === "letter-header" && !map.has(it.letter)) {
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Installs the pinned toolchain and project dependencies, skipping satisfied steps.
# --db also installs and starts MariaDB with the pytest database (Debian/Ubuntu).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_BIN="${HOME}/.local/bin"
export PATH="${LOCAL_BIN}:${PATH}:/usr/sbin:/sbin"
with_db=false
for arg in "$@"; do
case "${arg}" in
--db) with_db=true ;;
*)
echo "usage: scripts/dev-setup.sh [--db]" >&2
exit 2
;;
esac
done
log() { printf '==> %s\n' "$*"; }
as_root() {
if [[ ${EUID} -eq 0 ]]; then "$@"; else sudo "$@"; fi
}
apt_install() {
local missing=() pkg
for pkg in "$@"; do
dpkg-query -W -f='${Status}' "${pkg}" 2>/dev/null | grep -q "ok installed" ||
missing+=("${pkg}")
done
[[ ${#missing[@]} -eq 0 ]] && return 0
if ! command -v apt-get >/dev/null; then
log "Skipping ${missing[*]}: install them with your package manager"
return 0
fi
log "Installing ${missing[*]}"
as_root apt-get update -qq >/dev/null
as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" >/dev/null
}
setup_uv() {
local want have
want="$(sed -n 's/^ARG UV_VERSION=//p' "${ROOT}/docker/Dockerfile")"
have="$(uv --version 2>/dev/null || true)"
have="${have#uv }"
have="${have%% *}"
if [[ ${have} == "${want}" ]]; then return 0; fi
log "Installing uv ${want} (found ${have:-none})"
curl -LsSf "https://astral.sh/uv/${want}/install.sh" |
env UV_UNMANAGED_INSTALL="${LOCAL_BIN}" sh >/dev/null
hash -r
}
setup_backend() {
# Build headers for the mariadb and psycopg[c] drivers.
apt_install libmariadb-dev libpq-dev
log "Syncing backend dependencies"
(cd "${ROOT}" && uv python install --quiet && uv sync --frozen --all-extras --dev --quiet)
}
setup_node() {
local want have full os arch dest
want="$(tr -d '[:space:]' <"${ROOT}/frontend/.nvmrc")"
have="$(node --version 2>/dev/null || true)"
if [[ ${have} == "v${want}."* ]]; then return 0; fi
full="$(curl -fsSL https://nodejs.org/dist/index.json | grep -o "\"v${want}\.[0-9]*\"" | head -1 | tr -d '"')"
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "${arch}" in
x86_64) arch=x64 ;;
aarch64) arch=arm64 ;;
*) ;;
esac
dest="${HOME}/.local/share/node/${full}"
log "Installing Node ${full} (found ${have:-none})"
mkdir -p "${dest}" "${LOCAL_BIN}"
curl -fsSL "https://nodejs.org/dist/${full}/node-${full}-${os}-${arch}.tar.xz" |
tar -xJ -C "${dest}" --strip-components=1
ln -sf "${dest}/bin/node" "${dest}/bin/npm" "${dest}/bin/npx" "${LOCAL_BIN}/"
hash -r
}
setup_frontend() {
local stamp="${ROOT}/frontend/node_modules/.dev-setup-stamp" node_version lock_sum
node_version="$(node --version)"
lock_sum="$(cksum <"${ROOT}/frontend/package-lock.json")"
if [[ -f ${stamp} && "$(<"${stamp}")" == "${node_version} ${lock_sum}" ]]; then return 0; fi
log "Installing frontend dependencies"
(cd "${ROOT}/frontend" && npm ci --no-audit --no-fund --no-update-notifier --loglevel=error)
echo "${node_version} ${lock_sum}" >"${stamp}"
}
setup_trunk() {
command -v trunk >/dev/null && return 0
log "Installing Trunk and its linters"
mkdir -p "${LOCAL_BIN}"
curl -fsSL https://trunk.io/releases/trunk -o "${LOCAL_BIN}/trunk"
chmod +x "${LOCAL_BIN}/trunk"
hash -r
(cd "${ROOT}" && trunk install --ci >/dev/null 2>&1) ||
log "Trunk could not download its linters (network policy?); trunk check will retry on first use"
}
setup_db() {
apt_install mariadb-server
if ! mariadb-admin ping --silent >/dev/null 2>&1; then
log "Starting MariaDB"
as_root service mariadb start >/dev/null
fi
as_root mariadb <"${ROOT}/backend/romm_test/setup.sql"
}
setup_uv
setup_backend
setup_node
setup_frontend
setup_trunk
if [[ ${with_db} == true ]]; then setup_db; fi
uv_version="$(uv --version)"
python_version="$(cd "${ROOT}" && uv run --quiet python --version)"
node_version="$(node --version)"
log "Toolchain ready: ${uv_version%% (*}, ${python_version}, Node ${node_version}"