- Button: `loading` always disables (|| not ??) — stops a double-submit
when a call-site passes an explicit boolean `disabled` (VideoEditor).
- playlists: track un-ingestable-but-live YouTube ids in the sync baseline
so a linked playlist containing a private/deleted video converges instead
of re-classifying it as a pending pull forever; re-attempt ingestion each
sync so a restored (private->public) video heals, and prune ids that
leave YouTube.
- SettingsPanel: download-auth section renders an honest error/loading
state (StateMessage + retry) instead of a bare empty section on a failed
credentials fetch; HU+EN loadError string.
- tests: reorder-order, partial-failure baseline hold, un-ingestable
converge/heal/prune, Button loading-disables, download-auth error/retry.
Sweep the rounded-lg solid/ghost/outline action-button family onto the shared primitive:
ConfirmProvider, ErrorDialog, DownloadDialog, DownloadCenter, downloadModals, downloadAdmin,
ProfileEditor, PersonalViewsModal, SettingsPanel, WatchPage, DraftSaveBar. Deliberately left
on their own styles per the agreed scope: login/onboarding rounded-xl CTAs (Welcome/Onboarding/
Setup/KeyGate), glass Plex surfaces, segmented pills/toggles/switches/chips, dropdown triggers,
data-driven Banner/tone maps, text-xs dense buttons, and tiny inline p-1/p-1.5 icon controls —
none map 1:1 to a variant+size without a visual change, and the repo has no tailwind-merge.
Introduce frontend/src/components/ui/Button.tsx — the one action-button primitive
(variants primary|danger|outline|ghost|glass|danger-outline; sizes xs|sm|md|lg|icon;
icon/iconOnly/label/loading/fullWidth/forwardRef). No focus-visible (already centralized
in index.css @layer base). Migrate the cleanest proof sites off btnCls, and flip the
channel Subscribe button to the outline variant so it matches Full history (the user's ask).
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).
Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.
Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).
The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
Add theme-scoped --danger/--warn/--success (+ -fg) tokens wired into the v4
@theme inline, replacing ~110 raw red/amber/emerald/green palette refs across
34 files. Fixes light-theme contrast failures (fixed Tailwind shades fell to
~1.7-3:1 on white). Status colors are scheme-independent — recognised by hue,
shifting only by theme to hold contrast (bright on dark bg, darkened on light).
Solid fills use the paired -fg text; @source inline guarantees the full -fg set
exists. IMDb rating gold kept as a deliberate decorative non-status exception.
- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
(has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.
Gate green (tsc/eslint/prettier/knip/vitest 79).
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.
- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
"rounded-sm" inside a releaseNotes.ts prose sentence.
Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
Replace ~48 magic z-index values (z-10..z-[9999] + 2 inline zIndex) with named
tailwind zIndex tokens (base/menu/chrome/paneltab/panel/rail/overlay/popover/
tooltip/tuner). Values are UNCHANGED — a rename, not a renumber — so layering is
identical; the names give one source of truth for the stack.
Locks the five flows the refactor must not break (feed, channel, player,
settings, nav) so later sprints can prove they changed nothing. Adds a thin
data-testid layer and a dedicated seeded non-demo account; the suite runs on
the baked :8080 stack (authoritative) or :5173. No product-behaviour change.
The player is `fixed left-0 top-0 z-50` but rendered from inside the page
scroller, so it only ever covered the viewport because that scroller happened
not to be a stacking context. Giving it the edge-fade made it one, and the mask
scoped the player's z-50 inside it: the rail, the side panel and the header
painted straight over the player, and its edges faded. Measured both ways, then
fixed where every other full-screen overlay here already sits — <body>.
Same for the Suspense fallback that stands in while the player chunk loads, and
for the floating draft save bar on the Configuration page. Swept the rest: every
other `fixed` element is either App-level or already portaled.
The standard input style was copy-pasted verbatim into 5 components
(DownloadCenter/DownloadDialog/ProfileEditor/ShareDialog/VideoEditor) and the
text-button style into 2 (ShareDialog/VideoEditor). Export both from ui/form.tsx
and import them. Byte-identical strings → no visual change.
The settings-family panels (Settings/Config/Setup/Welcome) use a DIFFERENT input
style (bg-card/rounded-xl/focus:border-accent); unifying the two is a design call
left to the glass-consistency epic, not this DRY extract.
Machine-baseline harvest (ruff + knip), all tsc/parse-green, no runtime change:
- backend: remove 3 unused imports (channels/playlists/youtube) via ruff; drop the
unused `job` binding in unshare_download (keep the _own_job ownership guard call).
- frontend: remove `export` from 23 internally-used-only symbols (knip "unused
exports") to shrink the public surface; delete 2 genuinely-dead declarations
(PlexBrowseResult — leftover from the removed /browse route; WIDGET_TITLES —
hardcoded English titles superseded by i18n).
Held back for a decision (unused here = possibly-unwired, NOT dead — flagged, not
removed): e2ee.lock()/clearDevice() (security primitives never wired to logout / a
"forget device" feature) and loadDefaultViewFilters (App reimplements it inline — a
DRY issue). See siftlode-ops/CODE-HYGIENE.md.
The Plex watch import inserted plex_states via per-row ORM add against a pre-read 'existing' set,
which is not safe when the enable call fires more than once while the multi-second import runs (the
toggle wasn't disabled during it): overlapping imports raced to INSERT the same (user_id, item_id)
rows and all but the first hit a UniqueViolation -> 500 (seen on prod). Rewrite the write path as a
chunked PostgreSQL UPSERT (on_conflict_do_update, rows de-duped by item_id) — idempotent and
concurrency-safe, Plex still wins on the intersection and Siftlode-only states are untouched.
Also disable the Settings toggle (and ignore its onChange) while a sync mutation is pending, so it
can't be fired repeatedly; adds a reusable 'disabled' prop to the Switch primitive.
Lighthouse a11y across the authenticated module pages flagged: the clickable
Siftlode logo had aria-label='Feed' (accessible name didn't include its visible
text); the shared Switch, theme swatches, sort selects and several number/range
inputs had no accessible name/label. Give Switch role='switch'+aria-checked and
an optional label (passed at every call site), aria-label the swatches, selects
(feed/playlists sort) and the settings/config/scheduler inputs, and drop the
mismatched logo aria-label so its visible text is the name. All 11 module pages
now score 100 accessibility (settings 86->100, playlists 89->100, others 92-95->100).
One Save/Discard bar with an inline (Settings card) and floating (Config page)
variant, replacing the two near-identical hand-rolled bars + their state machine.
components/ui/form.tsx replaces the per-panel copies:
- Switch — was duplicated in SettingsPanel (Switch) + ConfigPanel (Toggle) +
Sidebar (inline labeled toggle).
- Section (with a card variant) — was in SettingsPanel + Stats (plain) and
AdminUsers (glass card).
- SettingRow + HintLabel — the label+hint+control row was identical in SettingsPanel + Stats.
No visual change intended (Sidebar's toggle gains the standard knob shadow).