Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.
REVERTED, because the mechanism does not work:
- The `inert` menu item. Round 1 swapped a native `disabled` for
`aria-disabled` so a row's "why is this off" tooltip could be reached.
floating-ui's list navigation skips `aria-disabled="true"` exactly as it
skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
comment, written before this branch, already said so. The swap fixed
nothing and made the mouse worse: without native `disabled` the row took
pointer events, the full active highlight and DOM focus, then no-opped
on click. The underlying finding is logged where the prop was, unfixed
and honest: the reason has to go somewhere a disabled row is not.
- `useContextMenu` owning the focus restore. Turning floating-ui's
returnFocus off platform-wide traded one bug for two: Playlists spreads
triggerProps on a bare <tr>, so restoring to it is a spec no-op and
Escape stranded focus on <body>; and with dismissOnScroll now on, the
restore's plain focus() scrolled the row back into view, undoing the
scroll that closed the menu. Patching that needs three more guards on
one fix. The original bug — the rail's rename field dying on arrival —
is fixed at the field instead: it focuses from an effect, which is
scheduled after the microtask that was blurring it.
NARROWED, because the fix was right and the guard was not:
- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
gear and tracks menus register layers via useDismiss — so Backspace
stopped closing them and did nothing at all. Only the cheat sheet and
the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
navigation does NOT unmount (About and ReleaseNotes live outside the
page column), leaving one Back to both navigate and close them. The
entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
session and went stale across a close. `e.movementX/Y` asks the browser
directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
mounts two — so the cheat sheet rendered the chords twice under a
duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
variant's own colour class outranks under clsx, so Revoke hovered
neutral and a password-protected link looked unprotected. The colour
goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
scope, so `back` would have answered for the wrong player. Keyed on
scope + id, both literal.
And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.
The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
113 lines
9.0 KiB
Markdown
113 lines
9.0 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 (`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 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`](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._
|