- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
Enter/Escape (the playlist and Plex title renames, the priority number);
the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.
- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.
npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.
Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.
No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
The earlier bump measured the view-switcher's BUTTON (16px, inherited) instead of its visible
label span (text-sm = 14px), and 14px is the size the user actually finds comfortable. Walk the
over-sized triggers back to text-sm: FilterSelect, SortControl, ViewSwitcher, and the Combobox
input (which was 14px before the bump anyway). The secondary text already at text-sm/text-xs
(count, chips, widget headers, section labels) was correct and stays.
The user prefers FilterSelect's rounded-full glass pill over the ViewSwitcher's squarer
rounded-lg trigger. Give the ViewSwitcher trigger the same look — glass-card/glass-hover,
rounded-full, px-3 py-1, open→border-accent, and a 16px label (was text-sm) — so the two read
as one control family. Applies to every ViewSwitcher consumer (feed, channel manager, Plex
playlists).
Native <select>s, the Combobox input box, and playlist/list cards are a different control class
(form fields / variable-height inputs / cards) and keep their own shape.
Second UAT finding on the tag popover: the keyboard-active row stayed put while
a fainter CSS :hover chased the pointer — two competing highlights. Root cause:
floating-ui's focusItemOnHover is (correctly) OFF for input-hosting menus because
setting its activeIndex on hover drags DOM focus onto the item and blurs the
create-tag input mid-typing; the only pointer feedback left was the item's own
`hover:bg-*`.
Fix, applied in the shared PopoverItem so it's general:
- Drive the visible highlight off a NEW `hoverIndex` state (highlightIndex =
hoverIndex ?? activeIndex), decoupled from floating-ui's activeIndex — hovering
sets only hoverIndex, never DOM focus, so one highlight follows the pointer AND
the input keeps its caret. Keyboard nav (onNavigate/onMatch) clears hoverIndex
so it takes the highlight back; leaving the menu clears it too.
- Split PopoverItem's `active` into focusActive (roving tabindex, keyboard) vs
visualActive (data-active highlight).
- Remove the now-redundant `hover:bg-*` from the 5 consumers so data-active is the
sole highlight (it was also a same-specificity tie with data-active).
Verified in-browser: hovering moves the single highlight; typing "testtag" in the
create field then hovering items keeps focus+caret on the input (caretPos intact);
mouse-leave returns the highlight to the keyboard row. Gate: tsc/eslint/prettier/
vitest 79 green.
The roving-focus row in every popover menu was barely distinguishable from the
menu surface — an accent fill at only 24%, and the recipe was copy-pasted across
5 consumers. Centralize it in PopoverItem (ACTIVE_HIGHLIGHT, applied to every
item) and strengthen it: accent/35 fill + a 1px inset accent ring for a defined
edge that reads across all 6 schemes in both themes. Consumers no longer carry
the recipe.
Uses the now-working `bg-accent/NN` / `ring-accent/NN` utilities (v4 does the
opacity modifier via color-mix) instead of the old hand-written color-mix
arbitrary value the v3 no-op forced. Verified in-browser: fill + inset ring
render on the active item, dark and light.
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.
AddToPlaylist, LanguageSwitcher, the Plex DetailCustomizeMenu and ViewSwitcher each
had their own anchoring, dismissal and (for ViewSwitcher) keyboard engine. All four now
use the platform, so their items are PopoverItems with real menuitemradio/aria-checked,
roving focus, typeahead and flip/shift anchoring.
Two platform additions this needed:
- `selectedIndex`: opening a single-choice menu must land on the choice already in
effect, which is what ViewSwitcher's hand-rolled engine did by seeding its roving
index to the current option.
- `ariaLabel` on PopoverSurface: an icon-only trigger leaves the panel with no name.
Fixed while wiring `selectedIndex` up: it stranded focus on the menu container. That
path focuses its item synchronously in a layout effect, and FloatingFocusManager's own
initial focus then ran afterwards and took focus back; the no-selectedIndex path only
escaped this because it defers focus to a microtask while waiting for the item list to
populate. On a roving list the list navigation owns initial focus, so the manager is now
told to take none (initialFocus -1).
Behaviour changes worth naming:
- The header LanguageSwitcher no longer opens on hover. The platform focuses the first
item on open, so a hover-open would yank focus off whatever the user was doing. Both
variants are now click-only, like every other menu in the app.
- PrefToggle became a PopoverItem, so the rows callers inject through the menu's `extra`
prop join the same roving list as the built-in ones.
- AddToPlaylist fetches its list on open, so on the first paint there are no items to
focus and focus stays on the trigger (as it did before the migration); arrows move
into the list once it lands.
Verified in-browser: ViewSwitcher opens on the checked mode, arrows rove, Escape closes
and refocuses the trigger; the ChannelTags pilot is unregressed; AddToPlaylist anchors
bottom-start without the click reaching the feed card underneath; the rail language menu
keeps its right-edge, bottom-aligned anchoring.
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
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.
The xl breakpoint was a fact about the FEED's toolbar, hard-coded into
the switcher that E4's managers are meant to reuse — their rows are far
emptier and would have hidden the label for no reason. It's a prop now,
defaulting to icon-only.
The two views spans collapse into one builder, which also fixes a
regression I'd just added: splitting them put the exact-count tooltip on
the number alone, so hovering the word gave nothing.
Three from UAT: the row's cap goes 1280 -> 1472 (+15%), since with the
rail and filters hidden there was a lot of page left over and the fixed
columns mean the extra width can only go to the title; the views column
drops the word and keeps it in the tooltip (a table doesn't repeat its
unit every line), taking that column 144 -> 64; and the view switcher
names the current mode where the toolbar has room, falling back to the
icon alone below xl. Title: 431 -> 511 at 1440, 706 at 1900.
Seeding the roving index from an effect only scheduled the update, so the
focus effect still read the previous index and focused the wrong item for
one render — a screen reader announced both. Seed it where the menu opens
instead. The modes are one exclusive choice, so they also need a group to
scope their checked-ness to.
Arrows/Home/End move a roving focus and Escape hands focus back to the
trigger — the menu unmounts under the focused item, which stranded focus
on <body>. Outside-click deliberately does not refocus: that would steal
focus from whatever was just clicked.
The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.
lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.