Skip to content

refactor(web): consolidate shape tokens and improve keyboard accessibility#1112

Open
PavluntiyJ wants to merge 2 commits into
4gray:masterfrom
PavluntiyJ:feature/ui-tokens-and-accessibility
Open

refactor(web): consolidate shape tokens and improve keyboard accessibility#1112
PavluntiyJ wants to merge 2 commits into
4gray:masterfrom
PavluntiyJ:feature/ui-tokens-and-accessibility

Conversation

@PavluntiyJ

@PavluntiyJ PavluntiyJ commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Introduced a shared shape-token scale (--app-radius-xs/sm/md/lg/xl/2xl/pill) in m3-theme.scss and migrated every hardcoded border-radius value across apps/web and libs/* (~80 stylesheets) to reference it.
  • Fixed keyboard accessibility: channel-list-item (used everywhere channels are listed) and season-container's episode/season cards were clickable elements with no tabindex/role, making them completely unreachable via keyboard. Added role="button", tabindex="0", Enter/Space activation, visible :focus-visible rings, and aria-label on icon-only action buttons that previously relied only on matTooltip (invisible to screen readers).
  • Fixed two genuinely theme-locked hardcoded colors in season-container (thumbnail/placeholder backgrounds) to use --app-content-bg / --app-widget-header-bg.

Verification

  • Channel list, both light and dark theme: Tab now lands on the row with a visible focus ring, Enter activates playback, a further Tab reaches the favorite button with its own ring and accessible label.
  • Remote-control page: confirmed it broke when pointed at --app-* tokens (undefined in that bundle), then confirmed it's fully restored after reverting to its self-contained palette, including visible focus rings on the touchpad/keypad/volume buttons.

Why remote-control keeps hardcoded colors

remote-control.component.scss renders inside apps/remote-control-web — a separate, standalone Angular app (served to a phone on the local network) that never imports apps/web/src/m3-theme.scss and has no --app-* custom properties defined anywhere. An earlier pass in this PR tried to theme it with --app-* tokens like the rest of the app; live verification showed this broke the component entirely (backgrounds/borders resolve to nothing when the referenced custom property doesn't exist in the cascade). It was reverted to its original self-contained dark palette — the only correct option for a bundle with no theme system — while keeping the new :focus-visible accessibility additions, which don't depend on that assumption.

What we deliberately didn't touch

  • box-shadow — left alone everywhere. The existing --app-shadow-soft/--app-shadow-raised tokens are low-contrast (0.04–0.16 opacity), designed for subtle card elevation. Most real box-shadow usages in the app are either semantic (selection glows, live-indicator glows, colored accent shadows) or much heavier modal/hero/player shadows (0.12–0.6 opacity). Forcing either category onto the two soft/raised tokens would have flattened intentional visual depth.
  • Small/off-scale radii (1–5, 7, 9, 11px) — left as fine-detail values distinct from the surface/card shape scale.
  • season-container's rgba(255,255,255,…) glass overlays — these already derive their contrast from --text-primary/--accent-color, a separate backdrop-adaptive system (tuned per movie/series poster luminance) that cascades from the parent detail-view, unrelated to the app's light/dark theme toggle.
  • Responsive/@media-query gaps and prefers-reduced-motion support, both flagged in the initial UI audit — left for a follow-up PR to keep this change focused on tokens + keyboard a11y.

Testing

  • pnpm nx test components — 13 suites / 72 tests pass, including 2 new regression tests (keyboard activation via Enter/Space, aria-label/role/tabindex on channel-list-item).
  • pnpm nx build web and pnpm nx build remote-control-web — both succeed.
  • pnpm nx lint components / lint remote-control — no new issues.
  • Manual verification via Electron + CDP: channel list keyboard navigation in both light and dark theme, remote-control page before/after the fix.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR consolidates ~80 stylesheets to reference a new shared radius token scale (--app-radius-xs/sm/md/lg/xl/2xl/pill) defined in m3-theme.scss, and adds keyboard accessibility (role="button", tabindex="0", Enter/Space handlers, :focus-visible rings, and aria-label on icon-only buttons) to channel-list-item and season-container.

  • Shape token migration — replaces every hardcoded border-radius pixel value across apps/web and libs/* with the new CSS custom property references; the remote-control component correctly retains self-contained tokens because it ships in a standalone bundle that never loads the main theme.
  • Keyboard accessibilitychannel-list-item and season-container episode/season cards gain role="button", tabindex, keyboard activation handlers, and :focus-visible focus rings; icon-only action buttons gain aria-label.
  • Theme-locked color fixseason-container thumbnail/placeholder backgrounds are migrated from hardcoded hex values to --app-content-bg / --app-widget-header-bg tokens.

Confidence Score: 3/5

The CSS token migration across 80+ files is safe and well-executed, but the keyboard accessibility addition introduces a real interaction defect that affects every keyboard user who interacts with action buttons inside channel rows and episode cards.

The onKeydownActivate handler on the outer .channel-list-item div has no target guard: Space or Enter pressed on the favorite, info, or aux-action button bubbles to the parent and fires clicked.emit() (channel selection) on top of the button's own action. The same pattern is reproduced in season-container's episode-card and episode-list-item. The accessibility fix itself introduces incorrect behaviour for keyboard users who interact with the action buttons — the opposite of the PR's intent. The radius token migration and remote-control refactor are clean and carry no risk.

libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts and both episode-card/episode-list-item blocks in libs/ui/components/src/lib/season-container/season-container.component.html

Important Files Changed

Filename Overview
libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts Adds onKeydownActivate for Enter/Space activation — missing event.target guard causes child action-button keypresses to bubble up and emit clicked unintentionally
libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html Adds role=button, tabindex=0, aria-label, aria-pressed, keydown handlers, and aria-label on icon buttons; aria-pressed is semantically debatable for active-item state
libs/ui/components/src/lib/season-container/season-container.component.html Adds role=button/tabindex/keydown to episode-card and episode-list-item articles; same event-bubbling issue as channel-list-item since action buttons live inside the same article element
apps/web/src/m3-theme.scss Introduces --app-radius-* scale (xs/sm/md/lg/xl/2xl/pill) as theme-independent custom properties, and adds --app-shadow-soft/raised elevation tokens; clean and well-structured
libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts Adds two new tests for keyboard activation and ARIA attributes; tests call the method directly and don't cover the event-bubbling regression path from child buttons
libs/ui/components/src/lib/season-container/season-container.component.scss Replaces hardcoded hex thumbnail/placeholder colors with --app-content-bg/--app-widget-header-bg tokens and adds :focus-visible rings throughout; style changes are clean
libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss Replaces hardcoded rgba values with color-mix() expressions using local self-contained tokens, adds :focus-visible rings for touchpad and keypad buttons; correctly avoids --app-* tokens unavailable in the standalone bundle

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User presses Space/Enter] --> B{Focus target?}
    B -->|channel-list-item div| C[onKeydownActivate fires]
    B -->|child button: favorite/info/aux| D[keydown bubbles to parent div]
    D --> C
    C --> E[event.preventDefault]
    C --> F[clicked.emit → channel selected]
    D --> G[keyup fires on child button]
    G --> H[button click handler fires]
    H --> I[onFavoriteClick / showProgramDescription / onAuxActionClick]
    F & I --> J[Double-action: channel selected AND button action triggered]

    style J fill:#ff6b6b,color:#fff
    style C fill:#ffd93d
    style D fill:#ff6b6b,color:#fff
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[User presses Space/Enter] --> B{Focus target?}
    B -->|channel-list-item div| C[onKeydownActivate fires]
    B -->|child button: favorite/info/aux| D[keydown bubbles to parent div]
    D --> C
    C --> E[event.preventDefault]
    C --> F[clicked.emit → channel selected]
    D --> G[keyup fires on child button]
    G --> H[button click handler fires]
    H --> I[onFavoriteClick / showProgramDescription / onAuxActionClick]
    F & I --> J[Double-action: channel selected AND button action triggered]

    style J fill:#ff6b6b,color:#fff
    style C fill:#ffd93d
    style D fill:#ff6b6b,color:#fff
Loading

Comments Outside Diff (1)

  1. libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts, line 131-143 (link)

    P2 Test calls method directly; the bubbling regression path is untested

    The test verifies that calling onKeydownActivate directly emits clicked. What it does not cover is the broken scenario: a native <button> child (e.g. the favorite button) having keyboard focus and the user pressing Space. Adding a test that dispatches new KeyboardEvent('keydown', { key: ' ', bubbles: true }) on the favorite button element and asserts that clicked was NOT emitted would lock in the correct behaviour as a regression guard.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts
    Line: 131-143
    
    Comment:
    **Test calls method directly; the bubbling regression path is untested**
    
    The test verifies that calling `onKeydownActivate` directly emits `clicked`. What it does not cover is the broken scenario: a native `<button>` child (e.g. the favorite button) having keyboard focus and the user pressing Space. Adding a test that dispatches `new KeyboardEvent('keydown', { key: ' ', bubbles: true })` on the favorite button element and asserts that `clicked` was NOT emitted would lock in the correct behaviour as a regression guard.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts:99-102
**Keydown bubbles from child buttons, causing double-fire**

`onKeydownActivate` is bound at the outer `<div>` level, so a Space/Enter keydown on any inner `<button>` (favorite, info, aux-action) bubbles up and triggers this handler. When focus is on the favorite button and the user presses Space: `clicked` emits (channel selection fires), *then* the button's own keyup fires its click → `onFavoriteClick` runs. The user gets an unintended channel-select event every time they interact with an action button via keyboard.

The fix is to bail out when the event originated from a descendant element — `event.target` is the element that received the keypress; `event.currentTarget` is the `.channel-list-item` div, and they are only equal when the div itself has focus.

```suggestion
    onKeydownActivate(event: Event): void {
        if ((event.target as HTMLElement) !== (event.currentTarget as HTMLElement)) return;
        event.preventDefault();
        this.clicked.emit();
    }
```

### Issue 2 of 4
libs/ui/components/src/lib/season-container/season-container.component.html:96-105
**Same event-bubbling issue for episode-card action buttons**

The download, play-local, and toggle-watched `<button>` elements live inside this `<article role="button">`. When any of those inner buttons has keyboard focus and the user presses Space or Enter, the `keydown` event bubbles to the article's `(keydown.enter)` / `(keydown.space)` handlers and `selectEpisode` fires in addition to the button's own click handler — pressing Space on "mark as watched" would simultaneously start episode playback. The same pattern applies to both the grid-view episode-card and the list-view episode-list-item. Both need a target guard or the inner buttons need `(keydown.enter)="$event.stopPropagation()"` / `(keydown.space)="$event.stopPropagation()"` guards.

### Issue 3 of 4
libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html:11
`aria-pressed` is the ARIA attribute for toggle buttons where the same button switches between two states. Here "selected" means "currently active channel" — the user cannot deactivate the channel by pressing the same row a second time. Screen readers will announce this as "pressed" on the active row, implying it can be un-pressed, which is misleading. `aria-current="true"` better matches the "active item in a navigation list" pattern without implying a toggle.

```suggestion
    [attr.aria-current]="selected() || null"
```

### Issue 4 of 4
libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts:131-143
**Test calls method directly; the bubbling regression path is untested**

The test verifies that calling `onKeydownActivate` directly emits `clicked`. What it does not cover is the broken scenario: a native `<button>` child (e.g. the favorite button) having keyboard focus and the user pressing Space. Adding a test that dispatches `new KeyboardEvent('keydown', { key: ' ', bubbles: true })` on the favorite button element and asserts that `clicked` was NOT emitted would lock in the correct behaviour as a regression guard.

Reviews (1): Last reviewed commit: "refactor(web): consolidate shape tokens ..." | Re-trigger Greptile

Comment on lines +99 to +102
onKeydownActivate(event: Event): void {
event.preventDefault();
this.clicked.emit();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keydown bubbles from child buttons, causing double-fire

onKeydownActivate is bound at the outer <div> level, so a Space/Enter keydown on any inner <button> (favorite, info, aux-action) bubbles up and triggers this handler. When focus is on the favorite button and the user presses Space: clicked emits (channel selection fires), then the button's own keyup fires its click → onFavoriteClick runs. The user gets an unintended channel-select event every time they interact with an action button via keyboard.

The fix is to bail out when the event originated from a descendant element — event.target is the element that received the keypress; event.currentTarget is the .channel-list-item div, and they are only equal when the div itself has focus.

Suggested change
onKeydownActivate(event: Event): void {
event.preventDefault();
this.clicked.emit();
}
onKeydownActivate(event: Event): void {
if ((event.target as HTMLElement) !== (event.currentTarget as HTMLElement)) return;
event.preventDefault();
this.clicked.emit();
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
Line: 99-102

Comment:
**Keydown bubbles from child buttons, causing double-fire**

`onKeydownActivate` is bound at the outer `<div>` level, so a Space/Enter keydown on any inner `<button>` (favorite, info, aux-action) bubbles up and triggers this handler. When focus is on the favorite button and the user presses Space: `clicked` emits (channel selection fires), *then* the button's own keyup fires its click → `onFavoriteClick` runs. The user gets an unintended channel-select event every time they interact with an action button via keyboard.

The fix is to bail out when the event originated from a descendant element — `event.target` is the element that received the keypress; `event.currentTarget` is the `.channel-list-item` div, and they are only equal when the div itself has focus.

```suggestion
    onKeydownActivate(event: Event): void {
        if ((event.target as HTMLElement) !== (event.currentTarget as HTMLElement)) return;
        event.preventDefault();
        this.clicked.emit();
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 96 to +105
<article
class="episode-card"
role="button"
tabindex="0"
(click)="selectEpisode(episode)"
(keydown.enter)="selectEpisode(episode)"
(keydown.space)="
$event.preventDefault();
selectEpisode(episode)
"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Same event-bubbling issue for episode-card action buttons

The download, play-local, and toggle-watched <button> elements live inside this <article role="button">. When any of those inner buttons has keyboard focus and the user presses Space or Enter, the keydown event bubbles to the article's (keydown.enter) / (keydown.space) handlers and selectEpisode fires in addition to the button's own click handler — pressing Space on "mark as watched" would simultaneously start episode playback. The same pattern applies to both the grid-view episode-card and the list-view episode-list-item. Both need a target guard or the inner buttons need (keydown.enter)="$event.stopPropagation()" / (keydown.space)="$event.stopPropagation()" guards.

Prompt To Fix With AI
This is a comment left during a code review.
Path: libs/ui/components/src/lib/season-container/season-container.component.html
Line: 96-105

Comment:
**Same event-bubbling issue for episode-card action buttons**

The download, play-local, and toggle-watched `<button>` elements live inside this `<article role="button">`. When any of those inner buttons has keyboard focus and the user presses Space or Enter, the `keydown` event bubbles to the article's `(keydown.enter)` / `(keydown.space)` handlers and `selectEpisode` fires in addition to the button's own click handler — pressing Space on "mark as watched" would simultaneously start episode playback. The same pattern applies to both the grid-view episode-card and the list-view episode-list-item. Both need a target guard or the inner buttons need `(keydown.enter)="$event.stopPropagation()"` / `(keydown.space)="$event.stopPropagation()"` guards.

How can I resolve this? If you propose a fix, please make it concise.

role="button"
tabindex="0"
[attr.aria-label]="name()"
[attr.aria-pressed]="selected()"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 aria-pressed is the ARIA attribute for toggle buttons where the same button switches between two states. Here "selected" means "currently active channel" — the user cannot deactivate the channel by pressing the same row a second time. Screen readers will announce this as "pressed" on the active row, implying it can be un-pressed, which is misleading. aria-current="true" better matches the "active item in a navigation list" pattern without implying a toggle.

Suggested change
[attr.aria-pressed]="selected()"
[attr.aria-current]="selected() || null"
Prompt To Fix With AI
This is a comment left during a code review.
Path: libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
Line: 11

Comment:
`aria-pressed` is the ARIA attribute for toggle buttons where the same button switches between two states. Here "selected" means "currently active channel" — the user cannot deactivate the channel by pressing the same row a second time. Screen readers will announce this as "pressed" on the active row, implying it can be un-pressed, which is misleading. `aria-current="true"` better matches the "active item in a navigation list" pattern without implying a toggle.

```suggestion
    [attr.aria-current]="selected() || null"
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Pavel added 2 commits July 1, 2026 23:03
…ility

- Migrated hardcoded border-radius values to --app-radius-* tokens
  across ~80 stylesheets in apps/web and libs/*, adding a new
  --app-radius-2xl tier where the existing scale had a gap
- Fixed the two genuinely theme-locked hardcoded colors in
  season-container (thumbnail/placeholder backgrounds) to use
  --app-content-bg / --app-widget-header-bg
- Reverted an incorrect attempt to theme remote-control via --app-*
  tokens: it ships inside apps/remote-control-web, a standalone
  bundle that never loads m3-theme.scss, so those tokens don't
  exist there. It keeps its original self-contained dark palette
  and literal px radii instead
- Added keyboard accessibility: channel-list-item, episode-card and
  episode-list-item were clickable elements with no tabindex/role,
  making them unreachable via keyboard. Added role="button",
  tabindex, Enter/Space activation, :focus-visible rings, and
  aria-label on icon-only buttons that previously relied solely on
  matTooltip
- Added :focus-visible states to remote-control's touchpad/keypad/
  volume buttons
- Added regression tests for the new keyboard activation and
  aria-label behavior on channel-list-item
Enter/Space on channel-list-item and season-container's episode
cards was bound at the row/article level, so the event also fired
when a descendant action button (favorite, info, aux, download,
toggle-watched) had keyboard focus — pressing Space to toggle a
favorite also selected/activated the row underneath it. Guard both
handlers on event.target === event.currentTarget so only the row
itself (not a bubbled child event) triggers activation.

Also switch channel-list-item's aria-pressed to aria-current: the
row isn't a toggle button, it's the current item in a list, and
aria-pressed misleadingly implies it can be un-pressed.

Found by Greptile review on the PR.
@PavluntiyJ PavluntiyJ force-pushed the feature/ui-tokens-and-accessibility branch from 43c4b69 to 9d641ac Compare July 1, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant