CLAUDE.md gains the header-content, module-store and saved-views rows. docs/development.md says what a new page is and which tests hold it. components/ui/README.md names every file in the directory, and a contract test keeps it that way: seventeen had slipped in unlisted. copyItems' comment still claimed it was a feature file.
159 lines
26 KiB
Markdown
159 lines
26 KiB
Markdown
# CLAUDE.md — working agreement for AI agents on Siftlode
|
|
|
|
This repository is **fully AI-authored** — every line was written by an AI agent, and it continues to
|
|
be developed that way. This file is the rules of the road: read it before you touch anything. It is
|
|
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
|
worse than no rule — if you change how the code works, change this file in the same commit.
|
|
|
|
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a files library with downloads). It is a
|
|
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
|
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
|
|
router-free navigation provider.
|
|
|
|
## The gate — never merge or ship red
|
|
|
|
The local gate is **ruff (`check` + `format --check`) + shellcheck + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
|
|
eslint + prettier --check + knip + vitest + vite build + pytest + pip-audit + npm audit**. It runs
|
|
before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
|
|
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
|
|
green **before** any merge, release, or handing work to review.
|
|
|
|
- Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's `siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate.
|
|
- **There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it.
|
|
- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every scope and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
|
|
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` (no burn-down mandate, but none was ever triaged: a `reportOptional*` entry in code you touch may be a real bug). A run that fixed some rewrites it — commit that.
|
|
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
|
|
|
## Workflow
|
|
|
|
- **Trunk model.** One long-lived branch, `main`. Branch off it (`feature/`, `bug/`, `improvement/`),
|
|
keep commits logical and Conventional-Commit-styled.
|
|
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
|
being AI-written is the intended, visible exception; commits are not.)
|
|
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
|
|
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
|
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
|
go-ahead first, then run it yourself.
|
|
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
|
should assume or leak them.
|
|
|
|
## Collaboration — how to work with the human
|
|
|
|
The human is the product owner: they bring ideas and requirements. You are the technical lead — you own
|
|
how it gets built. Concretely:
|
|
|
|
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
|
work) and get a go-ahead before writing code.
|
|
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
|
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
|
|
behind each option and the weight of the decision. These decision points are where the product owner
|
|
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
|
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
|
choose from before building. How the UI looks and behaves is decided in
|
|
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
|
|
and change it only through its decision log.
|
|
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
|
arguments, push back or propose an alternative — don't comply blindly.
|
|
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
|
bugs; do a real visual check of any UI change, and run DESIGN.md's Conformance check (a copy-only
|
|
change runs its step 4 alone: every state the new text renders in, in both languages).
|
|
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
|
implementation and debugging especially: no restating the same point reworded, no filler.
|
|
|
|
## Architecture shape (don't fight it)
|
|
|
|
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
|
`downloads/`, `reader/`, `discover/`, `catalog/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`, `jobruns`,
|
|
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`,
|
|
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
|
The domain is four **units**, each owning its tables: Capture (`downloads/`), Reader (`reader/`, `discover/`, the playlist
|
|
sync), Catalog (`catalog/`, the `sync/` ingests) and Plex (`plex/`). A unit's route does no database work: it reads through
|
|
the unit's `queries`, writes through its `service` (which flushes) and commits. A unit's code reads another unit's tables
|
|
only along a declared edge and writes them only through that unit's service; core reaches a unit's tables through its
|
|
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
|
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
|
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
|
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
|
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
|
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
|
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
|
|
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
|
|
`lib/urlState` and its entry there) →
|
|
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
|
|
**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`,
|
|
`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
|
|
…) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with
|
|
its own test): an import only goes down; another module is reached only through its **`index.ts`** (its
|
|
public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
|
|
lazily by `import()`; a platform component may not import a domain API slice, and `lib/` nothing above
|
|
it, not even a type. A lazily imported page, rail or dialog that a static import drags onto the boot
|
|
path fails the build (`chunkGuard.ts`). A new generic component goes in `ui/`; see
|
|
[`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
|
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
|
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
|
|
|
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
|
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
|
|
|
## Canonical patterns — when you have a choice, pick THIS
|
|
|
|
| Area | Use | Not |
|
|
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
|
| Request body | a Pydantic model | `payload: dict` |
|
|
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
|
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
|
|
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
|
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
|
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
|
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
|
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
|
| FE browser storage | a key in `storage.ts` with its kind (contract-held): a `pref` through `usePref` (the server's, follows the account); `device` / `view` / `cache` this browser's; `public` kept for the /watch page | a `"siftlode.x"` literal; a preference kept only in `localStorage` (it stays on one device) |
|
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
|
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
|
|
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
|
|
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
|
|
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
|
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
|
|
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
|
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
|
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
|
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
|
|
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
|
|
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
|
|
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
|
|
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
|
|
|
|
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
|
|
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
|
|
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
|
|
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), a box or font size (the spacing scale or rem,
|
|
never a px-frozen `w-[Npx]` / `text-[Npx]`: the text-size slider scales the root font), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
|
|
|
## House rules that bite (learned from real bugs)
|
|
|
|
- **A rule stated in a comment must also be enforced in code.** The worst bugs here came from a comment
|
|
and the code/migration drifting apart (a `cancelled`≠`canceled` literal killed a cancel path). Put the
|
|
invariant in an enum/constant/shared function, not in prose.
|
|
- **Fix a principle everywhere, in one sweep.** If you correct one owner/commit/serialization site,
|
|
`grep` for the siblings and fix them too — asymmetric fixes are a recurring smell here.
|
|
- **Honest states.** An error must not render as an empty state; a swallowed failure must not look like
|
|
"no results". Reads degrade honestly; writes are conditional (`UPDATE … WHERE status='running'`).
|
|
- **i18n parity.** HU/EN, zero key drift. Every user-facing string is translated.
|
|
- **Comment the "why", keep it current.** This codebase uses comments as its architecture doc; a stale
|
|
"why" is a liability, so update it when the behavior changes. Say the why itself, never the plan
|
|
step that produced it: no epic, sprint or review-round ids in the code (`planCodes.contract.test.ts`).
|
|
|
|
---
|
|
|
|
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
|
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
|
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
|