zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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

AttemptWhat was doneWhy it was wrong
LayoutSwitcherDropdown initial implementationUsed text-accent bg-accent-subtle for the selected item, mirroring the ViewChangerDropdown that already existedbg-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 implementationSame text-accent bg-accent-subtle pair — likely copy-pasted from a code sample that used the "filled accent" patternThe 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 implementationUsed 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 statebg-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 elementWould 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-fg

  • Active / selected (currently chosen item): bg-active + text-active-fg

  • Filled accent (pill, toggle, draft-bar button): bg-accent + text-on-accent

  • Decorative 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 see bg-accent-subtle, text-accent, or bg-hover in the selected branch, it is wrong.

  • onMouseEnter without onMouseLeave — every component that uses onMouseEnter on list items to update a hover/focus index MUST also wire onMouseLeave on the list container to clear the index back to -1 (or null). Individual row onMouseLeave does 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-subtle in default-light is nearly invisible for text.

  • EmptyFrameNav dual-cursor pattern — EmptyFrameNav uses two separate state variables: highlight (keyboard cursor, always a valid index) and pointerHighlight (pointer over an item, null when pointer is outside). The visible selection is pointerHighlight !== 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/frameset/src/frame-chrome.tsx — LayoutSwitcherDropdown delegates selection rendering to packages/ui-components/src/dropdown-listbox.tsx, which pairs bg-active text-active-fg on selected rows.

  • packages/frameset/src/empty-frame-nav.tsx — EmptyFrameNav (dual-cursor pattern with pointerHighlight state).

  • packages/ui-components/src/tokens.css — canonical token definitions (--theme-active-bg, --theme-active-fg, --color-active, --color-active-fg).

  • packages/color-themes/src/color-settings.ts — per-theme values for activeBg / activeFg.

  • Guard tests:

    • packages/frameset/src/frame-chrome.test.tsx — "layout switcher active item tokens (#1637)"

    • packages/frameset/src/empty-frame-nav.test.tsx — hover-bleed + token tests

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).