- eslint no-restricted-syntax: a new text-[Npx] font size is now an error (matches
strings + template chunks; rem arbitraries like text-[0.75rem] don't match). Keeps
the rem system from silently rotting. Mutation-verified it fires on text-[Npx].
- index.css: document the token-vs-arbitrary split (name the two common small steps;
rare 8/9/12/13px stay text-[Nrem]; never text-xs for 12px — it carries a line-height).
The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider
while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs
(0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary
they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem
class (rare 8/9/12/13px -> rem arbitraries).
Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at
scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their
icon/badge px sizes go rem so the rails keep pace with their (now-scaling)
labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media
thumbnails, page-width caps and fixed container widths deliberately stay px.
Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header
clearance 72->88px, nav slot 246->302px — all scale proportionally.
- PlexBrowse PLAYABLE_TINT: transcode was collapsed onto remux's bg-warn; give it
bg-danger so direct/remux/transcode read as a distinct good->heavy green/amber/red
gradient again.
- Live indicators (VideoCard row + thumbnail badge, playlistColumns badge): revert to
fixed live-red + white instead of the --danger token. 'live' is a broadcast marker,
not an error state, and the badge sits over a thumbnail (no app-bg contrast need);
restores the conventional red/white LIVE look and decouples it from error retunes.
- ChannelPage exploring badge: drop the redundant dark:text-warn (no-op after the
token rename — --warn is already theme-scoped).
error/fatal toast shade merge left as-is (both are 'bad'; the 400/500 step was subtle).
- Add --info/--info-fg (blue; dark #60a5fa / light #2563eb) so info notifications
stop borrowing --accent, which collided with a status colour in schemes whose
accent shares its hue (YouTube red = error, Slate orange = warn, Matrix/Forest
green = success). Toaster info tone accent -> info.
- Restore hover feedback the codemod flattened: 'bg-X ... hover:bg-X' /
'text-X ... hover:text-X' collapsed to a no-op once both shades mapped to one
shadeless token. Reuse the house hover:opacity-{80,90} idiom (ConfirmProvider
danger button, DownloadCenter error link).
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.
A full cold sync crashed and left plex_people empty (cast/people missing in
every detail view). enrich_row_people added PlexPerson rows via the ORM with no
ON CONFLICT, and because SessionLocal is autoflush=False a person shared by two
items in the same enrich batch was queued twice -> the single batch commit
violated the unique index ix_plex_people_name and rolled the whole batch back.
Prod dodged it by building up incrementally; dev hits it on every reseed+sync.
Insert missing people via an immediately-executed pg_insert with
on_conflict_do_nothing (deduped within the cast by name, keeping the first
non-null thumb), mirroring app/sync/videos.py:_insert_stubs. The immediate
execute also lets a later row in the batch see the row via the existing-names
query and skip it; the conflict guard additionally covers the concurrent
info-page enrichment path.
Adds a DB-backed regression test: two items with overlapping cast now enrich
without a unique-violation, one row per name, and a prior-NULL headshot is
back-filled. Mutation-checked (reverting the fix reddens it).
The surface's onMouseLeave nulled hoverIndex, so moving the pointer off a menu snapped the highlight
back to activeIndex — the first item in a menu with a free-text input (ChannelTags), where the field
swallows arrow keys so there is no list-nav holding a keyboard position. The keyboard and typeahead
already reclaim the highlight by nulling hoverIndex in onNavigate/onMatch, so the mouseleave reset was
redundant; dropping it keeps the last hover. 0.60.3.
Root cause: html[data-backdrop="on"] and .glass-media shared ONE translucent tier, so a value tuned for
Plex art (meant to show its poster through) was forced onto the whole app — over the feed, the nav and
popovers read straight through it. Split them: the app-wide background-image tier is now near-opaque
(surface 88% / card 90% / menu 93% / blur 22px), while the Plex-art tier stays translucent (72/80/85/14).
Tuned live on the user's prod session (50% -> 72% was still too see-through; 88% reads clean). 0.60.2.
With a background image on, glass surfaces used surface-alpha 50% + 10px blur, so the feed read
straight THROUGH the nav/menus/popovers (user-reported: distractingly transparent). Raised to
72%/80%/85% + 14px blur — the backdrop image still shows as soft colour, but content behind no longer
punches through. Verified in-browser: nav bg alpha 0.5 -> 0.72, no inline override on a clean pref.
Cuts the v4 (Tailwind migration) + focus/a11y foundation work for release. User-facing note covers the
app-wide keyboard-focus ring, steadier menu highlights, the three previously-invisible focus inputs,
and keyboard-reachable saved-view row actions; the v4 migration and dead-style cleanup are listed as
chores.
- #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).
- Global :focus-visible fallback (@layer base): one accent outline for keyboard users on every
button/link/[role=button]/[role=tab]/summary that doesn't define its own. Non-input controls had no
visible keyboard focus app-wide; this closes the void with a single rule. More specific focus styles
(.selectable, an input's focus:ring, a menu item's data-active highlight) override it.
- PopoverItem now carries outline-hidden so menu items keep their data-active highlight without also
drawing the global outline (no double ring).
- Nav rail rows adopt .selectable (via rowBase); rail chrome buttons (logo/pin/about/sign-out) get a
focus-visible ring; the settings row already uses rowBase. Deduped the account trigger's selectable.
Deferred to R10: the chip/pill selected-PAINT unification (a .chip/.pill/.is-current repaint of the
~14 selection idioms) — the R10 token redefinition repaints those surfaces anyway, at less risk than a
hand migration now. R9's job (the focus/a11y foundation) is complete.
Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style confirms the global ring
renders (2px accent, offset 2px) on a plain button, .selectable overrides it with an inset ring, and
an outline-hidden menu button draws no global outline.
S1 — recipe foundation:
- Move .glass/.glass-card/.glass-menu into @layer components so a JSX shadow-* utility overrides the
glass box-shadow again; the ~9 dead shadow-* on glass surfaces (modals, menus, save-bar, chat) now
paint their intended drop-shadow. Perf-mode and :hover overrides stay unlayered so they still win.
- Add a .selectable recipe: the hover tint menu/list rows already use, PLUS the app-wide
focus-visible ring that was missing on non-input controls (the biggest a11y gap).
- Split the popover roving highlight from the selected state (AnchoredPopover): a selected/checked
PopoverItem now gets a ring ONLY, so its resting paint (bg-accent fill / text-accent / checkbox)
survives being highlighted instead of downgrading to a 35% tint or being recoloured. Fixes the 4
review findings deferred from R10-S0 generically; drops the leftover per-consumer data-active ring
in tableHeader.
S2 — worst offenders:
- Account-menu trigger + switch/add/sign-out rows adopt .selectable (visible keyboard focus).
- Saved-views rows: group-focus-within reveals the hover-only action buttons for keyboard users;
aria-current on the active row; focus-visible ring on every button.
- The 3 invisible-focus inputs get an accent focus ring: SearchBar + ShareDialog via focus-within on
the glass pill/row, AboutCell via focus-visible on the trigger.
Deferred to a later R9 sprint: the full unification of all ~14 selection idioms, a shared <NavItem>,
and .is-current/.chip recipes (no consumer yet in this pass).
Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style checks confirm the glass
shadow now wins, the focus outlines render (2px accent), and the split renders (selected ring-only,
unselected fill).
R10 S0 foundation: migrate Tailwind v3.4→v4 (roots-fixes the ~99 opacity-modifier
no-op sites via v4's color-mix opacity), drop 2 dead keyframes, and centralize +
strengthen the popover active-row highlight (single hover-follows highlight that
doesn't steal the create-input's caret). Gate green; browser-verified across 6
schemes × 2 themes. A user /code-review high surfaced 4 minor findings (the
centralized highlight collides with consumers' own selected states) — deliberately
deferred to R9, where the selected-vs-highlighted separation is solved generically.
The nas.lan CIFS share was too slow for dev playback, so localdev now binds a
small local Plex test tree (a few movies/shows mirroring the server's
/data/<lib>/… layout) at /plex-media. Driven by PLEX_MEDIA_HOST_PATH in .env
(gitignored) — default points at the Docker-Desktop host path for
D:\plex-test-media. The old CIFS/PLEX_SMB_* approach stays in git history.
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.
`siftlode seed-dev-from-prod` TRUNCATEs app_state (excluded from the data
allow-list as local/operational state), which dropped localdev back into the
first-run "Setup needed" gate and blocked login/UAT. e2e_seed runs at the end
of both `seed` and `seed-dev-from-prod` and always leaves a loginable admin, so
it now also flips app_state.configured=true (idempotent; the running api re-reads
and promotes its one-way cache, no restart needed).
R10 S0 audit finding: both are unreferenced — overlays and side panels
unmount with no exit animation, only their `…In` counterparts are used
(0 `animate-[…]` / `animation:` consumers). Dead code, removed.
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.
First DOM-capable vitest lane for the repo: default env stays node (pure lib helpers), a test opts
into jsdom per-file with a // @vitest-environment jsdom docblock; plugin-react wired for JSX/hook tests.
Adds jsdom + @testing-library/react devDeps and 3 back-ladder tests (LIFO peel, stacks over
useBackToClose, inactive layer is transparent) driven by direct popstate dispatch — deterministic,
no dependency on jsdom's async back(). Closes the deferred R2 component-test gap (useBackToExit).
The large-logical-iframe + CSS-scale-down trick to force a 1080p start did not
work behind the user's work VPN (YouTube caps quality by network there regardless
of viewport size), and the two-phase native switch could downgrade a latched
1080p to 480p on some streams. Not worth the complexity for an unreliable win —
revert to the original "opens at whatever resolution YouTube picks" behaviour;
the user sets 1080p manually via fullscreen + the gear as before.
Kept: the windowed maximise (fullscreen) button added to the player meta row.
Two-phase HD: phase 1 keeps the fixed 1920x1080 iframe scaled down to latch
1080p; once the quality poll sees hd1080, switch to native responsive sizing so
YouTube renders its own controls (the gear especially) at full size instead of
scaled-down. A latched pick survives the shrink on a normal VOD.
CONDITIONAL / under observation: on one was_live stream in dev, quality dropped
hd1080 -> 480p ~2s after going native (YouTube re-caps to the small viewport);
held 1080p elsewhere. Gated behind HD_DROP_TO_NATIVE so phase 2 can be turned
off in one line to fall back to the guaranteed-1080p permanent-scale phase 1.
YouTube picks an embed's quality at player-creation from the iframe's internal
viewport and latches it; a windowed-size embed was stuck ~360-480p, forcing a
manual fullscreen + gear trip to 1080p. Create the iframe at a fixed 1920x1080
logical size and CSS transform-scale it DOWN to fit (a CSS transform doesn't
shrink the internal viewport, so YouTube still serves 1080p, latched for the
session even while it paints small). A letterbox-contain fit via a
ResizeObserver handles both the windowed and big-view boxes. Gated behind
FORCE_HD_LOGICAL_SIZE for an easy revert if a restrictive network overrides it.
Also add a maximise (fullscreen) button to the windowed player's meta row: the
only fullscreen control reachable in the windowed video was YouTube's own, which
hides our chrome. It mirrors the F key and leads to the docked bar + our true
fullscreen. Corrects the stale code note that claimed the scale trick fails
(that was scaling a SMALL player up; scaling a LARGE player down works).
The whole R8 epic: one anchored-popover platform (AnchoredPopover, over @floating-ui/react)
replacing ~11 hand-rolled positioning/dismissal engines, a shared layerStack for topmost-only
Escape across popovers/modals/players, S4 migration of every remaining engine, the /code-review
max fixes, and S5 player follow-ups (download works in fullscreen, transport controls de-duped).
No migration (db_revision stays 0061).
Two R8 S5 (player big-view control-bar) follow-ups:
- Modal now portals into `document.fullscreenElement ?? document.body` (reactive), the
same fullscreen-aware root the popover platform uses. A node under <body> is painted
beneath whatever holds the fullscreen lock, so the download dialog — a Modal, opened
from the player's control bar — would open invisibly in true fullscreen. With that
fixed, the `!isFullscreen` guard on the player's DownloadButton is dropped, so download
joins add-to-playlist as working in true fullscreen. (Every Modal is now fullscreen-
aware; the fix is at the primitive, not the call site.)
- The queue transport cluster (prev / counter / next + auto-advance + loop toggles) was
duplicated inline in two places — the modal queue bar and the big-view docked control
bar — with identical logic and only theme + label-visibility differing. Extracted a
`<PlayerTransportControls variant="modal"|"docked">`; a mode/icon change now lands once.
Behaviour-preserving (the docked bar's trailing divider and the wrapper rows stay at the
call sites). Note the modal variant keeps its `bg-accent/15` active tint, which is a
latent no-op under Tailwind v3's hex --accent — left as-is for R10 S0's styling audit.
Gate green (tsc/eslint/76 vitest). Device-UAT'd in real Chrome: transport controls render
correctly in both the modal queue bar and the docked bar; the download button now shows in
true fullscreen and its dialog portals into the fullscreen stage, fully visible and
centered. No console errors.
Correctness:
- Escape is now strictly topmost-only for EVERY mode. Hover popovers register in
the shared layerStack too (and floating-ui's own Escape is off for all modes), so
an open tooltip/hover-card no longer either swallows the Escape from a Modal/player
underneath (its document-level stopPropagation vs their window listeners) or
double-closes both. A player's own overlay hover-card now takes Escape before the
player, keeping it alive.
- An invisible (referenceHidden, scrolled-out) surface is no longer a dismissable
layer, so it can't silently eat an Escape aimed at what's actually on screen, nor
make a player defer.
- PopoverSurface splits into a wrapper that returns null before any hook and an inner
that holds them, so a mounted-but-closed popover (≈10 per table row) subscribes no
fullscreen listeners — was ~1800 document listeners on a 100-row table.
- The fullscreen portal no longer remounts on a key: one persistent container is
re-parented body↔fullscreen-element with appendChild (atomic move), so the open
panel's focus/caret/scroll/animation survive a transition and an element SWAP is
handled by the element-identity dependency.
- AddToPlaylist drops `disabled={busy}` on its items (it blurred the focused item to
<body> and permanently killed roving focus); re-entrancy is already guarded in
toggle(), and aria-busy conveys the pending state.
- New `hostsInput` option turns off BOTH typeahead and focus-item-on-hover for the two
menus with an inline text field, so a pointer passing over a list item no longer
steals the field's caret and mis-routes the next Enter. Replaces their `typeahead:false`.
- ChannelTags' single-pass fit loop gains a whole-set fast path, so it stops collapsing
chips into "+N" when they would all have fitted.
- `initialFocus={-1}` is applied only when a `selectedIndex` is set (the sync-focus race
it works around); a plain nav menu now lets the focus manager focus normally, so an
item-less menu (first-run tag editor) still has a keyboard entry point.
a11y:
- `ariaLabel` now wins on menu-role panels — the surface strips useRole's competing
`aria-labelledby` when an explicit label is given (verified: the filter/View menus
are named by their column/label, not the trigger).
- PopoverItem routes caller props THROUGH getItemProps and applies its own
tabIndex/disabled/data-active/className last, so a caller handler or tabIndex can no
longer be silently dropped or clobber the roving model.
Regressions / consistency:
- Tooltip shows/hides on contact again (explicit 0/0 hover delay) instead of inheriting
the platform's 120/140 ms intent delay.
- ChannelTags' editor list regains its useScrollFade overflow mask.
- ChannelTagsControl takes a `bounded` prop; the channel detail page passes false so
tags show inline in the wide header instead of collapsing to "+N".
- ChannelPage's tag-catalog query is gated on subscribed && !demo (matches the only
consumer's render condition); the manager's tag toggle now also invalidates
qk.channel(id) so the detail page's cached tags stay in step.
- DetailCustomizeMenu + PrefToggle revert off the platform to the pre-R8 inline
`absolute` menu: in the player's overlay it renders inside a transform:scale(1.25)
subtree, and portalling out to <body> painted it at 1×. Same reason the player's own
menus stay hand-rolled; this also restores PrefToggle to plain buttons (the rows are
independent toggles, not a radio group). The stale PlayerModal fullscreen comment is
corrected too.
Gate green (tsc/eslint/76 vitest). Self-UAT in-browser: hover-over-item no longer
steals the tag input's caret; text filter keeps caret while narrowing; menu aria-label
is the label not the trigger; selectedIndex lands focus on the checked item; roving +
Escape-close+refocus intact; add-to-playlist items never disabled. NOT verified here
(needs a device / playing media): the fullscreen portal reparent and the player-overlay
paths — owed device UAT, as they already were.
The description hover card loses its two hand-managed timers and its fixed `bottom`
anchor. It now opens above the title through the platform, which means `flip` can rescue
it when there is no room up there either — the old fixed anchor could only grow upward.
Its width moves from a px clamp to a rem one, so it follows the font-scale slider like
the rest of the app.
Add-to-playlist is no longer disabled in the player's true fullscreen: its menu rides
the platform, which portals into the fullscreen element rather than <body>. The download
button stays hidden there on purpose — it opens a Modal, and Modal still portals to
<body>, so its dialog would open invisibly. Lifting that needs the same fullscreen-aware
root inside Modal.
NOT migrated, deliberately: PlexPlayer's gear and tracks panels. They are the one place
where portalling would be a visual regression — the control bar sits inside the player's
`transform: scale(1.25)` windowed subtree, so a panel portaled out of it renders at 80%
of the surrounding UI's size. Positioning would still be correct (floating-ui reads
post-transform viewport rects); the panel's own type and padding would not. Doing this
properly means either portalling into that scaled wrapper or dropping the scale, and
either way it needs a UAT with a playing video that this environment cannot run.
Verified in-browser: the description card opens after its hover-intent delay, anchored
8px above the title and left-aligned to it, with the description loaded.
The column filter panel, the nav's account panel and the header's sync read-out are
mixed content with their own tab order rather than lists of choices, so they migrate as
`dialog` popovers — trapping and returning focus, dismissing through the shared layer
stack instead of their own document listeners.
The column filter needed a structural change: its open state lived in the header row
while the panel rendered per column, which cannot work when the platform anchors a panel
to its own trigger. Funnel and panel are now one module-level component (module-level
still matters — declaring it inline once remounted the popover on every keystroke, which
broke the caret and IME composition). That retires three hand-rolled focus chores at
once: capturing the trigger element at open time because a per-column ref nulled out
before focus could return, the effect that stopped Escape stranding focus on <body>, and
the useDismiss registration that had to include the trigger so its own mousedown did not
close-then-reopen the panel.
The filter also stops being one undifferentiated panel: a text filter is a dialog whose
input takes initial focus, while the select and multi filters are real menus with roving
focus — select as radios, multi as checkboxes.
`menuitemcheckbox` is new to the platform, and the two existing multi-select toggle lists
(channel tags, add-to-playlist) move onto it as well: several tags or playlists can be on
at once, so they were never one exclusive choice. Role-only change, no interaction
difference.
Verified in-browser: all three anchor exactly where their hand-rolled placement did,
Escape closes and returns focus to the trigger, the text filter keeps focus and caret
position while typing narrows the table, and the multi filter opens as a checkbox menu
with roving focus.