Files
siftlode/CLAUDE.md
T
peter 80dea4f307 fix(e10): review round 1, part 2 — the docs the epic invalidated, and three contracts made real
Finishes round 1. Part 1 took the runtime bugs; this takes the prose that
had gone false and the three "the comment says so" gaps behind it.

CLAUDE.md is auto-loaded into every agent session and ui/README.md is
not, so an architecture paragraph that still described a flat components
bag was the most expensive stale sentence in the repo: the next agent
adding a popover reads it, sees no platform layer, and writes the generic
component into components/ — the invisible-layer failure this epic exists
to end. It now names components/ui/ and says the boundary is lint-enforced.
The canonical-patterns table gained the three rows the epic established:
ui/Button over a hand-rolled <button>, a keymap row over a private keydown
listener, and disabled-when-busy vs inert-when-inapplicable.

ui/README.md's inventory was written in this branch's first commit and
never learned about CommandPalette, ContextMenu/useContextMenu,
KeyboardHelp or gridColumns — in a section whose own closing rule is that
a second component painting the same thing is how the last inconsistency
happened. It also claimed "everything is rem-derived" while VirtualGrid's
minCol/GRID_GAP are deliberately px; that is now stated as the exception
it is, and a new section records which promises here are tests rather
than sentences.

Three contracts stopped being prose:

- A text-less Button must carry a name. "Required for iconOnly" was a doc
  comment, which is why the migration shipped two nameless icon buttons
  through eight green lanes. It is a union now, covering both spellings
  (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or
  title. It caught one more on the way in: Banner's dismiss took an
  optional dismissTitle, so it could render unnamed — it falls back to
  common.close now.
- gridColumns' gutter arithmetic. Every existing assertion survived
  replacing floor((w + GAP) / (minCol + GAP)) with the naive
  floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are
  cap-bound, and the loops compare columnsFor against itself. Pinned at
  the exact-fit boundaries where the two first disagree (536 and 828).
- ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem
  button between two 1.75rem ones — and on a coarse pointer .touch-target
  grew only the migrated one to 2.75rem while the destructive Revoke
  stayed small.

Also: the maxCols docstring said 8 columns of 384px where the test it
shipped with asserts 369.
2026-09-06 01:30:40 +02:00

8.0 KiB

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 download center). It is a modular monolith: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process (yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation provider.

The gate — never merge or ship red

The local gate is ruff (check + format --check) + typecheck (3 tsc projects) + eslint + prettier --check + knip + vitest + pytest. It runs on every publish, which refuses to build on failure — there is deliberately no --skip flag. Run it and get it green before any merge, release, or handing work to review.

  • Full gate (agents with the private ops dispatcher): siftlode check.
  • Directly, without the dispatcher: cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test, plus backend ruff check backend + ruff format --check backend and pytest (the DB-lane needs Postgres).
  • 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.
  • 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.
  • 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/, youtube/ (shared by routes + scheduler + worker) → flat core (auth, quota, sysconfig, state, audit, notifications, userscope, realtime, ratelimit, progress, security) → models/db/config. Dependency direction is one-way: core never imports routes. Frontend: main.tsx provider tree → App.tsx shell → moduleRegistry route-chunks → module components → the platform layer components/ui/ (domain-free primitives: the popover engine and its menus, Modal/Overlay, Button/form, DataTable/VirtualGrid, QueryState, …). That boundary is one-way and lint-enforced, not just described: a platform component may not import a feature component or a domain API slice (no-restricted-imports in eslint.config.js, with its own test). A new generic component goes in ui/; see frontend/src/components/ui/README.md. Data via react-query (qk factory), per-account localStorage, 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
Ownership check owned_or_404 / get_or_404 ad-hoc _own_playlist/_owned/_own_job
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}) ad-hoc {"unsubscribed"}/{"cleared": n}/echo
Transaction commit the handler/job-wrapper commits; helpers flush helpers that commit (only quota owns its session)
Admin knob storage sysconfig (values) env / app_state / scheduler_settings scatter
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 per-account state usePref raw readAccount/writeAccount or bespoke hooks
FE action button ui/Button — pick a SIZES rung a hand-rolled <button className="px-3 py-2 …">
FE keyboard shortcut a row in lib/keymap.ts + dispatch() a private keydown listener with its own key literals
FE menu row that is off disabled only when BUSY; inert when it does not apply disabled + a title nobody can reach
FE control the chip/segmented primitives (Select only for >6 options) a native <select>
FE loading/empty/error QueryState / StateMessage ad-hoc conditionals

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.

Run-it-locally and the full gate commands live in docs/development.md; backend code conventions in backend/app/README.md. This file stays the source of truth for the canonical-patterns table and the gate/workflow/collaboration rules above.