l-lessons-dropdown-active-fg-bg
Lessons from dropdown active-item fg/bg regressions (#1637). The canonical token pair for a selected/active list item is bg-active + text-active-fg — NOT bg-accent-subtle + text-accent and NOT bg-hove...
Lessons: Dropdown Active Item fg/bg Token Pair
Summary
Three dropdown surfaces (LayoutSwitcherDropdown in frame-chrome.tsx, LayoutSwitcherDropdown in frame-chrome.tsx, and the row highlight in EmptyFrameNav) were using the wrong token pair for the "currently selected" state. The wrong pair bg-accent-subtle + text-accent is a decorative tint intended for animations and transient highlights, not for list-selection steady state. Per-theme contrast is only guaranteed on --theme-active-bg / --theme-active-fg.
The same dropdowns also had a hover-bleed bug: only onMouseEnter was wired to set the focused index, with no onMouseLeave on the list container to clear it. The last hovered item remained visually highlighted after the pointer left the menu.
Prior Attempts Table
| Attempt | What was done | Why it was wrong |
|---|---|---|
| LayoutSwitcherDropdown initial implementation | Used text-accent bg-accent-subtle for the selected item, mirroring the ViewChangerDropdown that already existed | bg-accent-subtle is documented as "decorative tint, not the selection token". Contrast is not tuned for it in default-light or tokyo-night. |
| ViewChangerDropdown initial implementation | Same text-accent bg-accent-subtle pair — likely copy-pasted from a code sample that used the "filled accent" pattern | The design system distinguishes "filled accent" (bg-accent + text-on-accent, for pills/buttons) from "list selection" (bg-active + text-active-fg). Neither pair maps to bg-accent-subtle. |
| EmptyFrameNav initial implementation | Used bg-hover text-hover-fg for the highlighted row, which is correct for hover but also used as the keyboard cursor, and had no mouseleave to clear hover state | bg-hover + text-hover-fg is the hover token pair, fine for transient pointer-over state, but it was retained as a permanent highlight after the pointer left. No onMouseLeave on the container → stale highlight. |
| First hover-bleed attempt (speculative) | Considered adding onMouseLeave to each individual row element | Would not work: the row's onMouseLeave fires when the pointer moves to an adjacent row (not just out of the list), causing the highlight to briefly disappear between rows. The fix must be on the list container, not on individual rows. |
Root Cause
Wrong token pair
The three-tier color architecture (see l-design-system) defines distinct token pairs for each interaction state:
Hover (pointer over, not selected):
bg-hover + text-hover-fgActive / selected (currently chosen item):
bg-active + text-active-fgFilled accent (pill, toggle, draft-bar button):
bg-accent + text-on-accentDecorative tint (animations, transient flash):
bg-accent-subtle
bg-accent-subtle + text-accent is neither a hover pair nor a selection pair. It is for decorative highlights that flash or animate — using it for steady-state selection produces low contrast in default-light (where --theme-accent-subtle is a very light tint) and an unrelated hue in tokyo-night (where the accent is a muted purple but the active-bg is tuned for readability).
Hover-bleed
onMouseEnter on each row sets focusedIndex (or pointerHighlight), but without a corresponding onMouseLeave on the list container the index is never cleared. When the pointer moves outside the menu, the last entered row stays permanently highlighted until the user moves the pointer back in or presses a keyboard key.
Watch For Next Time
Any new dropdown or listbox component — check whether the "selected" row uses
bg-active text-active-fg. If you seebg-accent-subtle,text-accent, orbg-hoverin the selected branch, it is wrong.onMouseEnterwithoutonMouseLeave— every component that usesonMouseEnteron list items to update a hover/focus index MUST also wireonMouseLeaveon the list container to clear the index back to -1 (or null). Individual rowonMouseLeavedoes not work because it fires on transitions between rows.The three themes — verify active state in all three themes before closing. default-light is the theme that most often exposes low-contrast regressions because its palette is lighter;
bg-accent-subtlein default-light is nearly invisible for text.EmptyFrameNav dual-cursor pattern — EmptyFrameNav uses two separate state variables:
highlight(keyboard cursor, always a valid index) andpointerHighlight(pointer over an item, null when pointer is outside). The visible selection ispointerHighlight !== null ? row.globalIndex === pointerHighlight : row.globalIndex === highlight. Do not collapse them into a single variable or the keyboard cursor will be lost when the pointer leaves.
The Correct Fix
Token pairing (copy-pasteable)
// Selected / active item in any dropdown or listbox
className={[
isSelected
? "bg-active text-active-fg"
: "text-fg",
isFocused && !isSelected ? "bg-hover text-hover-fg" : "",
]
.filter(Boolean)
.join(" ")}Hover-bleed fix (list container)
// On the <ul> or list container — NOT on individual <li> rows
onMouseLeave={() => setFocusedIndex(-1)}Dual-cursor pattern for EmptyFrameNav-style components
// Two separate state variables
const [highlight, setHighlight] = useState(0); // keyboard cursor
const [pointerHighlight, setPointerHighlight] = useState<number | null>(null); // pointer
// On the list container
onMouseLeave={() => setPointerHighlight(null)}
// On each row
onMouseEnter={() => setPointerHighlight(row.globalIndex)}
// Visible selection
const isActive =
pointerHighlight !== null
? row.globalIndex === pointerHighlight
: row.globalIndex === highlight;Reference Files
packages/— LayoutSwitcherDropdown delegates selection rendering toframeset/ src/ frame- chrome. tsx packages/, which pairsui- components/ src/ dropdown- listbox. tsx bg-active text-active-fgon selected rows.packages/— EmptyFrameNav (dual-cursor pattern withframeset/ src/ empty- frame- nav. tsx pointerHighlightstate).packages/— canonical token definitions (ui- components/ src/ tokens. css --theme-active-bg,--theme-active-fg,--color-active,--color-active-fg).packages/— per-theme values forcolor- themes/ src/ color- settings. ts activeBg/activeFg.Guard tests:
packages/— "layout switcher active item tokens (#1637)"frameset/ src/ frame- chrome. test. tsx packages/— hover-bleed + token testsframeset/ src/ empty- frame- nav. test. tsx
FrameChrome V2 provider actions
The title is static; provider replacement uses Empty frame → picker. The Layout dropdown contains only provider.layouts. Optional HeaderActions receives { frameId, narrow }; use FrameHeaderActionGroup for wide tooltip buttons and narrow overflow from the same action list. Header width (560 base px, display-scale aware) owns this choice. Keep the adapter element memoized by component identity and frameId, and never mount actions in collapsed strips or rails. Layout rows continue to pair bg-active with text-active-fg (regression #1637).