Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).
Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
it (the overlay stack's own push is the one exemption).
"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.
Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
117 lines
9.5 KiB
Markdown
117 lines
9.5 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 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, and one layer down `lib/` may not import from `components/` at
|
|
all, not even a type (`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`](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 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 control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
|
|
|
## 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`](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._
|