Skip to content

refactor(web): UX/UI foundation — a11y + intent-driven home + settings disclosure#115

Merged
aorumbayev merged 35 commits intomainfrom
refactor/ux-ui-v2
Apr 17, 2026
Merged

refactor(web): UX/UI foundation — a11y + intent-driven home + settings disclosure#115
aorumbayev merged 35 commits intomainfrom
refactor/ux-ui-v2

Conversation

@aorumbayev
Copy link
Copy Markdown
Member

@aorumbayev aorumbayev commented Apr 17, 2026

Summary

Five UX/UI refinements + three follow-up migrations on one branch. Foundation-level work with active adoption. 32 commits. 243 tests passing. Net code reduction despite shipping this many features.

Part 1: Foundation (5 refinements)

A11y Foundation (#5)

CSS tokens — AAA contrast, focus-ring, reduced-motion. Primitives — VisuallyHidden, LiveRegion, SkipLink, focusRing helper, useReducedMotion. Axe-core baseline suite. Raw-color lint script.

Intent-Driven Home (#1)

New / route with hero input. Keyword-based intent classifier. Routes to create-task / search / chat / navigate. Recents section.

Settings Progressive Disclosure (#4)

Flat 30-field form → 3 category cards (Workflow / Agents / Advanced). 500-line monolith + 5 dead files deleted.

Living Kanban Cards (#2)

<CardPulse> with live dot, elapsed, last log line. useSessionStream reuses existing SSE bus (zero new connections). Respects reduced-motion.

Command Palette Spine (#3)

Module-level registry, typed CommandAction. Cmd+K opens palette (cmdk-based). Built-in Navigate/Create/Run/Settings/Help commands.

Part 2: Follow-up adoption (this PR too)

Scattered menus → palette migration

  • Discovery: 9 scattered menu candidates scanned
  • 2 menus removed (palette-only): task-detail overflow kebab, board inspector "More" dropdown
  • 14 commands dual-registered in palette: run-start, run-stop, review-approve, review-reject, review-merge, session-switcher, task-open/edit/delete, github-import, help-shortcuts, help-about
  • Chat/live-session menus SKIPPED (primary, high-traffic discovery surfaces)

focusRing helper → shadcn adoption

  • 13 primitives migrated: button, input, textarea, select, native-select, switch, badge, toggle, tabs, scroll-area, resizable, dialog-close, breadcrumb
  • 1 bug fix: breadcrumb had no focus ring at all
  • Baseline 15 focus-visible occurrences → 11 intentional remaining (border glows, z-stacking, explicit no-ops) — 4 raw ring trios replaced

Axe-core violations → fixed

  • Analytics: button-name (critical) on SelectTrigger — added aria-label="Time range"
  • Settings: heading-order (moderate) — promoted h3 → h2 on Connection + System Checks cards
  • Tests flipped to strict: expectNoViolations on all 3 pages. Regression net in place.

Highlights

  • 228 → 243 tests (+15 from follow-up agents, +94 from branch total vs main)
  • Agents cooperated mid-stream — home page consumed LiveRegion as soon as it landed; migration agent dual-registered actions into the palette spine the command-palette agent built
  • Zero axe violations across Board / Analytics / Settings (all strict)
  • Negative line count vs additions despite shipping 5 features + 3 migrations

Commits (32)

Foundation (21) — same as original PR

A11y

  • e1e0685 CSS tokens
  • 6e81d94 focus-ring + useReducedMotion
  • c79a543 a11y primitives
  • 4f4a3e8 axe-core smoke suite
  • 00fc833 raw-color lint

Settings

  • 9baf926 category card component
  • d612eb1 Workflow / Agents / Advanced grouping
  • 7d74b4b progressive disclosure
  • 7ce9820 tests

Home

  • 061eba2 intent classifier
  • 25cbef0 hero input page
  • 9088216 routing + activity-bar entry
  • 2784a5b tests

Living cards

  • 432a1ec useSessionStream hook
  • b3a139e CardPulse component
  • 27575f7 integration into task cards
  • 04094a6 tests

Command palette

  • 83731ef action registry
  • bf72ca6 palette UI
  • 7f40b1b Cmd+K global shortcut
  • a70258c built-in commands

Follow-up migrations (11)

Menu migration

  • 0250e3c register task/run/review actions
  • 184bb93 remove task detail overflow menu
  • 7e0fc38 remove board inspector More menu

focusRing adoption

  • 8fbaf25 button, input, textarea
  • c72884c select, native-select, switch, toggle, badge
  • 95f76ad tabs, scroll-area, resizable, dialog-close
  • 6e3af3e breadcrumb links
  • c6b6830 tests

Axe fixes

  • 77dbd05 fix button-name on Analytics
  • 15409d4 fix heading-order on Settings
  • f6da4c7 flip tests to strict

Test plan

  • pnpm run build → clean
  • pnpm exec vitest run243 passed
  • pnpm exec tsc --noEmit → clean
  • pnpm run check:raw-colors → baseline 21 hits, 0 new
  • Axe-core: Board 0 / Analytics 0 / Settings 0 (all strict)
  • Manual smoke: navigate /, type intent, confirm routing
  • Manual smoke: Settings → categories expand/collapse, save, reload
  • Manual smoke: Cmd+K → palette opens → run/review actions work
  • Manual smoke: Running task card pulses; completion announced
  • Manual smoke: Tab through every page; focus rings visible consistently

Three-voice debrief

  • Andrew Ng: Axe-core now strict. onCommandExecute telemetry pinned. Focus-ring migration had a measurable baseline (15 → 11). Intent classifier keyword-only; upgradeable when data demands.
  • Guido: Explicit everything — CommandAction type, focus-ring token, category boundaries, LiveRegion primitive. No string-keyed magic, no scattered configuration.
  • Primeagen: Net code reduction. Living cards reuse existing SSE bus. Palette leverages existing cmdk dep. Two scattered menus literally deleted (palette-only now). Non-destructive for primary surfaces.

🤖 Generated with Claude Code

aorumbayev and others added 13 commits April 16, 2026 23:39
…ed motion

Introduces a single source of truth for accessibility tokens so future
components can adopt AAA contrast and a dedicated focus ring without
scattered overrides. Expands prefers-reduced-motion to also zero out
animation/transition delays and motion duration variables.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Centralizes the focus-ring class string so components consume one token
instead of re-declaring outline/ring classes. Adds a reactive hook for
motion-sensitive UI to opt into reduced-motion affordances at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…Link)

Introduces three reusable primitives so screen-reader-only text,
announcement regions, and a keyboard skip link share one implementation.
Foundation only; wiring into layouts is a follow-up migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Baseline-first: each page runs axe and logs current violation counts but
does not fail. Establishes the scaffold so future regressions surface
during CI and so the migration PR can flip these to strict assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Audits the web package for raw Tailwind color classes that should be
driven by CSS custom properties instead. Baseline-only: always exits 0
and prints the current offender count so future PRs can track drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Introduce a reusable button-based card for top-level settings categories
with icon, title, subtitle, and aria-expanded control. Used by the
progressive-disclosure settings page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Extract the flat settings panel into a shared useSettingsForm hook plus
three category-scoped section components:

- Workflow: auto_review, require_review_approval, auto_confirm_single_tasks,
  review_strictness, planning_depth, serialize_merges, default_base_branch,
  worktree_base_ref_strategy.
- Agents: default_agent_backend, use_recommended_backend,
  default_model_claude, default_model_openai, additional_instructions.
- Advanced: theme, git_user_mode/name/email, auto_init_git_repo,
  auto_init_git_initial_commit, open_last_project_on_startup,
  attached_launcher, skip_attached_instructions_popup.

Setting keys, validation, and save behavior are unchanged — each field
still saves through the same apiClient.setSettings call. The ToggleRow
primitive is lifted out of the old panel so section components do not
depend on one another.

The unused settings/sections/*.tsx modules and the monolithic
settings-panel.tsx are removed; neither was imported by the live page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replace the stacked-panel layout with three clickable category cards
(Workflow / Agents / Advanced). Selecting a card reveals that section
inline with a back control; the hero header, connection card, and
preflight checks remain on the top-level view.

The expanded region uses role=region, aria-labelledby, and moves focus
to the section heading. Cards expose aria-expanded and aria-controls so
screen readers and keyboard users track the disclosure state. Returning
to the list restores focus to the originating card.

No backend or API changes — the page just reorganizes what renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Add settings-page.test.tsx verifying:
- The three top-level category cards render with aria-expanded=false.
- Expanding a card reveals only that category's fields and a Back
  ("All settings") control.
- Back restores the card list and collapses aria-expanded.
- Toggling a Workflow control (require_review_approval) and an Agents
  control (use_recommended_backend) persists the unchanged wire keys
  through apiClient.setSettings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pure heuristic classifier that maps free-form user input to a routing
intent (create-task, chat, search, navigate-*, unknown) with a
confidence score and an optional extracted title. Imperative verbs
map to task creation, question words to chat, navigation verbs to
their surface; multi-word sentences fall back to task creation with
low confidence. No LLM call — baseline to beat.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
New centered home view with a time-aware greeting, an auto-resizing
hero textarea ("What do you want to do?"), an inline chip previewing
the classified destination, and a subtle recents list sourced from
/api/tasks. Enter submits — imperatives create a task directly via
apiClient.createTask and navigate to its detail page; questions,
searches, and navigation phrases route to the existing surfaces.

The preview doubles as the aria-describedby target and is announced
via a polite live region (300ms debounce) so keyboard/SR users know
where Enter will take them before committing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replace the index redirect to /welcome with a lazy-loaded home-page
and mount it explicitly at /home for direct linking. Add Home as the
first entry in the activity bar using the lucide Home icon. Board
remains accessible at /board via the sidebar. Welcome stays the
auth/onboarding surface; it already navigates to /board after a
project is activated and that flow is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Classifier tests pin the keyword heuristics for every intent kind,
title extraction/punctuation stripping, and the low-confidence
fallback for cryptic or ambiguous input.

Home page tests exercise the user-visible contract: greeting heading,
autofocused input, intent preview after five characters, create-task
flow calling apiClient.createTask and navigating to the new task,
question routing to /workspace, find-style queries routing to
/board?q=..., and recents hiding cleanly when there are no tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
aorumbayev and others added 2 commits April 17, 2026 10:13
Scope the existing kagan:session-event bus to a single session id with a
1 Hz coalesced update cadence; becomes a no-op when the task is not
running so only active cards pay the render cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…log)

Renders a one-line vital sign under the task title with an animated dot,
tabular-nums elapsed time, and a truncated last-log line. Silent when at
rest, queued-grey when waiting, pulsing warning-tone when running.
Respects prefers-reduced-motion and announces completion via LiveRegion.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 17, 2026

Greptile Summary

Three cooperating UX/UI refinements: an a11y primitive layer (CSS tokens, VisuallyHidden, LiveRegion, SkipLink, axe-core suite), an intent-driven home page with a keyword classifier that routes input to create-task / search / chat / navigate, and a settings refactor replacing a 500-line monolith with three progressive-disclosure category cards. All findings are P2; the most actionable are the SkipLink default anchor mismatch ('main' vs id="main-content"), the aria-controls pointing to DOM-absent panels, the Home nav item pointing to /home instead of the root /, and the duplicate intent-description functions whose search copy diverges between the visual chip and the live-region announcement.

Confidence Score: 5/5

Safe to merge — all findings are P2 polish/a11y-best-practice items with no functional regressions.

The core logic (classifier, home page, settings disclosure, a11y primitives) is correct and well-tested (+30 passing tests). All five comments are style or minor a11y anti-patterns that don't break keyboard navigation, screen reader usability at a functional level, or any existing user path.

packages/web/src/components/a11y/skip-link.tsx (unused, wrong default anchor) and packages/web/src/components/settings/settings-category-card.tsx (aria-controls references absent element).

Important Files Changed

Filename Overview
packages/web/src/components/a11y/skip-link.tsx SkipLink primitive with targetId='main' default that doesn't match the app's id="main-content"; component is never rendered anywhere in the app.
packages/web/src/pages/settings-page.tsx Settings refactored to progressive 3-category disclosure; aria-live="polite" on the full disclosure container is an anti-pattern that can cause verbose screen reader announcements.
packages/web/src/components/settings/settings-category-card.tsx Category disclosure card with aria-expanded; aria-controls references settings-panel-{id} which never exists in the DOM simultaneously with the button.
packages/web/src/components/layout/activity-bar.tsx Adds Home nav item pointing to /home; active state won't highlight when user is at the root / index route.
packages/web/src/pages/home-page.tsx Intent-driven home page with debounced live-region, task creation, and recents; previewDescription duplicates describe from intent-preview.tsx with a copy inconsistency for the search case.
packages/web/src/lib/intent/classify-intent.ts Keyword-based intent classifier mapping free-form input to routing intents; well-structured with clear priority ordering and stable UNKNOWN_INTENT sentinel.
packages/web/src/components/a11y/live-region.tsx Correct screen-reader live region with auto-clear and repeat-announcement support via state toggle; clean implementation.
packages/web/src/components/settings/use-settings-form.ts Extracted settings form controller handling load, save, and git-identity refresh; clean separation of concerns from the view layer.
packages/web/src/routes.tsx Adds home-page as both index and explicit /home routes; both lazy-loaded correctly.
packages/web/scripts/check_raw_colors.mjs Audit-only script scanning for raw Tailwind palette classes; always exits 0 as a baseline tracker, well-scoped.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User types in hero input] --> B{value.length >= 5?}
    B -- No --> C[No preview shown]
    B -- Yes --> D[classifyIntent]

    D --> E{startsWith NAV_VERB?}
    E -- Yes --> F{matches NAV_RULE trigger?}
    F -- Yes --> G[navigate-* intent<br/>confidence 0.9]
    F -- No --> H{startsWith SEARCH_MARKER?}

    E -- No --> H
    H -- Yes --> I[search intent<br/>confidence 0.75]
    H -- No --> J{ends with ? or QUESTION_STARTER?}

    J -- Yes --> K[chat intent<br/>confidence 0.7–0.9]
    J -- No --> L{head in IMPERATIVE_VERBS?}

    L -- Yes --> M[create-task intent<br/>confidence 0.85]
    L -- No --> N{wordCount >= 4?}

    N -- Yes --> O[create-task fallback<br/>confidence 0.45]
    N -- No --> P[unknown intent<br/>confidence 0.2]

    M & O --> Q[handleSubmit → apiClient.createTask → navigate /task/id]
    I --> R[navigate /board?q=...]
    K --> S[navigate /workspace]
    G --> T[navigate matched route]
Loading

Comments Outside Diff (1)

  1. packages/web/src/components/layout/activity-bar.tsx, line 6-11 (link)

    P2 Home nav item won't highlight when user is at the root / route

    The "Home" entry links to /home, but the index route is / (served by the same home-page). React Router's NavLink active state is path-based, so landing on / leaves the Home icon unstyled — only /home triggers the highlight. The brand logo in the activity bar (line 17–21) already links to /, creating two routes to the same page with inconsistent active-state feedback.

    Consider pointing the Home nav item to / and giving it an end prop so it doesn't match every sub-route:

    (and add end to the NavLink to avoid over-matching)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/web/src/components/layout/activity-bar.tsx
    Line: 6-11
    
    Comment:
    **Home nav item won't highlight when user is at the root `/` route**
    
    The "Home" entry links to `/home`, but the index route is `/` (served by the same `home-page`). React Router's `NavLink` active state is path-based, so landing on `/` leaves the Home icon unstyled — only `/home` triggers the highlight. The brand logo in the activity bar (line 17–21) already links to `/`, creating two routes to the same page with inconsistent active-state feedback.
    
    Consider pointing the Home nav item to `/` and giving it an `end` prop so it doesn't match every sub-route:
    
    (and add `end` to the `NavLink` to avoid over-matching)
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/web/src/components/a11y/skip-link.tsx
Line: 13-17

Comment:
**SkipLink default target doesn't match the app's `<main>` id**

`targetId` defaults to `'main'`, producing `href="#main"`. The actual main element in `app-layout.tsx` uses `id="main-content"` (line 439), so the link would navigate to a dead anchor. Additionally, `app-layout.tsx` already has a hand-rolled skip link (`<a href="#main-content" className="sr-only ...">`) that this component was presumably meant to replace — but `SkipLink` is never imported or rendered anywhere in the app, leaving it as an unused export.

```suggestion
export function SkipLink({
  targetId = 'main-content',
  className,
  children = 'Skip to main content',
  ...rest
}: SkipLinkProps) {
```

Separately, replacing the inline skip link in `app-layout.tsx` with `<SkipLink />` would consolidate styling into the single primitive.

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

---

This is a comment left during a code review.
Path: packages/web/src/components/settings/settings-category-card.tsx
Line: 24-26

Comment:
**`aria-controls` always references an absent DOM element**

`aria-controls` should point to an element that exists in the DOM (even if hidden). Here the card buttons are rendered only when no category is active, and `settings-panel-{id}` is rendered only when that category IS active — the two never co-exist. So `aria-controls` always points to a non-existent element, which axe flags and which screen readers (JAWS, NVDA) can warn about.

The navigation-replacement pattern used here (swap the entire list for the panel) is a valid UX choice, but `aria-controls` should be removed if the referent never exists alongside the trigger. The focus management via `requestAnimationFrame` + `getElementById` already handles the disclosure communication correctly without it.

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

---

This is a comment left during a code review.
Path: packages/web/src/components/layout/activity-bar.tsx
Line: 6-11

Comment:
**Home nav item won't highlight when user is at the root `/` route**

The "Home" entry links to `/home`, but the index route is `/` (served by the same `home-page`). React Router's `NavLink` active state is path-based, so landing on `/` leaves the Home icon unstyled — only `/home` triggers the highlight. The brand logo in the activity bar (line 17–21) already links to `/`, creating two routes to the same page with inconsistent active-state feedback.

Consider pointing the Home nav item to `/` and giving it an `end` prop so it doesn't match every sub-route:
```suggestion
  { to: '/', label: 'Home', icon: Home },
```
(and add `end` to the `NavLink` to avoid over-matching)

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

---

This is a comment left during a code review.
Path: packages/web/src/pages/home-page.tsx
Line: 26-44

Comment:
**Duplicate intent-description logic with a subtle copy inconsistency**

`previewDescription` here and `describe` in `intent-preview.tsx` both map an intent to a user-facing string, but they diverge for the `search` case: the visual chip shows `Search tasks for "${rawInput}"` (with quotes) while the live-region announcement says `Search tasks for ${rawInput}` (no quotes). Screen reader users hear a different description than sighted users see.

Extract `describe`/`previewDescription` to `classify-intent.ts` (or a shared util) and import it in both places to keep copy in sync.

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

---

This is a comment left during a code review.
Path: packages/web/src/pages/settings-page.tsx
Line: 99

Comment:
**`aria-live` wrapping a full interactive panel is an anti-pattern**

`aria-live="polite"` on a container causes assistive tech to re-read the region's text content whenever it changes. Wrapping the entire disclosure area (category list ↔ expanded section) can trigger verbose announcements that include button labels, form-field descriptions, and heading text all at once whenever the active category switches.

The `LiveRegion` primitive added in this PR exists precisely for targeted announcements. Prefer removing `aria-live` from this container and instead adding a `<LiveRegion>` that announces just the section name when `activeMeta` changes:
```tsx
<LiveRegion message={activeMeta ? `${activeMeta.title} settings expanded` : null} />
```

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

Reviews (1): Last reviewed commit: "test(web): cover intent classifier and h..." | Re-trigger Greptile

Comment on lines +13 to +17
*/
export function SkipLink({
targetId = 'main',
className,
children = 'Skip to main content',
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 SkipLink default target doesn't match the app's <main> id

targetId defaults to 'main', producing href="#main". The actual main element in app-layout.tsx uses id="main-content" (line 439), so the link would navigate to a dead anchor. Additionally, app-layout.tsx already has a hand-rolled skip link (<a href="#main-content" className="sr-only ...">) that this component was presumably meant to replace — but SkipLink is never imported or rendered anywhere in the app, leaving it as an unused export.

Suggested change
*/
export function SkipLink({
targetId = 'main',
className,
children = 'Skip to main content',
export function SkipLink({
targetId = 'main-content',
className,
children = 'Skip to main content',
...rest
}: SkipLinkProps) {

Separately, replacing the inline skip link in app-layout.tsx with <SkipLink /> would consolidate styling into the single primitive.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web/src/components/a11y/skip-link.tsx
Line: 13-17

Comment:
**SkipLink default target doesn't match the app's `<main>` id**

`targetId` defaults to `'main'`, producing `href="#main"`. The actual main element in `app-layout.tsx` uses `id="main-content"` (line 439), so the link would navigate to a dead anchor. Additionally, `app-layout.tsx` already has a hand-rolled skip link (`<a href="#main-content" className="sr-only ...">`) that this component was presumably meant to replace — but `SkipLink` is never imported or rendered anywhere in the app, leaving it as an unused export.

```suggestion
export function SkipLink({
  targetId = 'main-content',
  className,
  children = 'Skip to main content',
  ...rest
}: SkipLinkProps) {
```

Separately, replacing the inline skip link in `app-layout.tsx` with `<SkipLink />` would consolidate styling into the single primitive.

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

Comment on lines +24 to +26
aria-expanded={expanded}
aria-controls={`settings-panel-${id}`}
onClick={onClick}
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-controls always references an absent DOM element

aria-controls should point to an element that exists in the DOM (even if hidden). Here the card buttons are rendered only when no category is active, and settings-panel-{id} is rendered only when that category IS active — the two never co-exist. So aria-controls always points to a non-existent element, which axe flags and which screen readers (JAWS, NVDA) can warn about.

The navigation-replacement pattern used here (swap the entire list for the panel) is a valid UX choice, but aria-controls should be removed if the referent never exists alongside the trigger. The focus management via requestAnimationFrame + getElementById already handles the disclosure communication correctly without it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web/src/components/settings/settings-category-card.tsx
Line: 24-26

Comment:
**`aria-controls` always references an absent DOM element**

`aria-controls` should point to an element that exists in the DOM (even if hidden). Here the card buttons are rendered only when no category is active, and `settings-panel-{id}` is rendered only when that category IS active — the two never co-exist. So `aria-controls` always points to a non-existent element, which axe flags and which screen readers (JAWS, NVDA) can warn about.

The navigation-replacement pattern used here (swap the entire list for the panel) is a valid UX choice, but `aria-controls` should be removed if the referent never exists alongside the trigger. The focus management via `requestAnimationFrame` + `getElementById` already handles the disclosure communication correctly without it.

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

Comment thread packages/web/src/pages/home-page.tsx Outdated
Comment on lines +26 to +44
function previewDescription(intent: ClassifiedIntent, rawInput: string): string {
switch (intent.kind) {
case 'create-task':
return `Create task: ${intent.extractedFields?.title ?? rawInput}`;
case 'chat':
return 'Ask in chat';
case 'search':
return `Search tasks for ${rawInput}`;
case 'navigate-analytics':
return 'Open analytics';
case 'navigate-settings':
return 'Open settings';
case 'navigate-board':
return 'Open board';
case 'navigate-workspace':
return 'Open workspace';
case 'unknown':
return 'Open chat';
}
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 Duplicate intent-description logic with a subtle copy inconsistency

previewDescription here and describe in intent-preview.tsx both map an intent to a user-facing string, but they diverge for the search case: the visual chip shows Search tasks for "${rawInput}" (with quotes) while the live-region announcement says Search tasks for ${rawInput} (no quotes). Screen reader users hear a different description than sighted users see.

Extract describe/previewDescription to classify-intent.ts (or a shared util) and import it in both places to keep copy in sync.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web/src/pages/home-page.tsx
Line: 26-44

Comment:
**Duplicate intent-description logic with a subtle copy inconsistency**

`previewDescription` here and `describe` in `intent-preview.tsx` both map an intent to a user-facing string, but they diverge for the `search` case: the visual chip shows `Search tasks for "${rawInput}"` (with quotes) while the live-region announcement says `Search tasks for ${rawInput}` (no quotes). Screen reader users hear a different description than sighted users see.

Extract `describe`/`previewDescription` to `classify-intent.ts` (or a shared util) and import it in both places to keep copy in sync.

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

<ConnectionCard />
<PreflightChecks />
{/* Category cards / expanded section */}
<div className="mt-8" aria-live="polite">
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-live wrapping a full interactive panel is an anti-pattern

aria-live="polite" on a container causes assistive tech to re-read the region's text content whenever it changes. Wrapping the entire disclosure area (category list ↔ expanded section) can trigger verbose announcements that include button labels, form-field descriptions, and heading text all at once whenever the active category switches.

The LiveRegion primitive added in this PR exists precisely for targeted announcements. Prefer removing aria-live from this container and instead adding a <LiveRegion> that announces just the section name when activeMeta changes:

<LiveRegion message={activeMeta ? `${activeMeta.title} settings expanded` : null} />
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web/src/pages/settings-page.tsx
Line: 99

Comment:
**`aria-live` wrapping a full interactive panel is an anti-pattern**

`aria-live="polite"` on a container causes assistive tech to re-read the region's text content whenever it changes. Wrapping the entire disclosure area (category list ↔ expanded section) can trigger verbose announcements that include button labels, form-field descriptions, and heading text all at once whenever the active category switches.

The `LiveRegion` primitive added in this PR exists precisely for targeted announcements. Prefer removing `aria-live` from this container and instead adding a `<LiveRegion>` that announces just the section name when `activeMeta` changes:
```tsx
<LiveRegion message={activeMeta ? `${activeMeta.title} settings expanded` : null} />
```

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

aorumbayev and others added 11 commits April 17, 2026 10:15
Insert the live pulse under the title so running cards show elapsed
time and the latest log line without opening the session, while resting
cards remain visually quiet. Extends the memo equality check to cover
the active session's started_at timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Introduces a module-level Map-backed registry for command palette actions
with stable kebab-case ids, optional when() guards, and idempotent
re-registration. The registry is the single source of truth that future
palette UIs and migration passes will consume.

Built-in navigation, create, settings, and help commands are registered
via registerBuiltinCommands() which is safe to call multiple times.
Hook tests pin the no-op contract (unsubscribed when inactive), 1 Hz
coalescing, log truncation, and listener cleanup. Component tests cover
the null/queued/running branches and reduced-motion fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Builds on shadcn/ui's CommandDialog (cmdk) for fuzzy search and keyboard
navigation. The palette groups registered commands by section, wires an
onCommandExecute telemetry hook (defaulting to console.debug), and
announces result-count changes through the shared LiveRegion.

Uses a dedicated commandPaletteSpineOpenAtom to coexist with the legacy
quick-actions dialog during the migration window.
Adds useGlobalShortcuts(), a single hook that owns global keybinds for the
new palette spine. Listens on document so Cmd/Ctrl+K fires even while the
user is typing in an input — modifier-plus-K is unambiguous. Plain k falls
through.

Intentionally narrow: one hook, one keystroke. Future global shortcuts
land here rather than in scattered feature components.
Mounts <CommandPalette /> globally from AppShell and calls
registerBuiltinCommands() once auth + theme init runs, so the palette has
its default Navigate / Create / Settings / Help commands populated before
the first Cmd+K.

The existing quick-actions dialog (Cmd+Shift+P) is left intact — this
landing is foundation-only; scattered menus and buttons will be removed
in a follow-up PR.
The time range Select combobox trigger had no accessible name because the
default SelectValue renders asynchronously via a Radix portal, leaving axe
to see an empty button. Add an aria-label to the SelectTrigger so screen
readers announce the control even before the value is populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Connection and System Checks cards used <h3> under the page's single <h1>,
skipping h2 and tripping axe's heading-order rule. Promote both to <h2>
since they are top-level sections of the Settings page; visual styling is
driven entirely by the existing className so the UI is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…rict

With the underlying violations fixed, switch both page-level axe tests
from the baseline-log pattern to expectNoViolations so any future
regression fails the suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replace the ad-hoc focus-visible:ring-[3px]/ring-ring/50 trio with the
token-driven focusRing helper so every interactive primitive reads from
--a11y-focus-ring / --a11y-focus-ring-offset. Keep the destructive button
variant on the same ring — uniform focus across variants.
Adds task-scoped (edit/delete/open/start/stop), review (approve/reject/merge),
session switcher, GitHub import, and help overlay entries to the command-palette
spine so Cmd+K becomes the canonical surface for actions that previously only
lived in scattered dropdowns and the legacy Cmd+Shift+P quick-actions dialog.

New commands are gated by `when()` guards that check the current route and
active task state, so the palette stays clean when there is no task context.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
aorumbayev and others added 9 commits April 17, 2026 13:37
…itch, toggle, badge

Move each primitive's focus-visible ring over to the shared token-driven
focusRing helper. Drop per-variant ring overrides on badge.destructive so
the keyboard focus indicator is identical across variants.
…ble, dialog-close

Also promote the dialog close button from focus: to focus-visible: so it
lines up with the other primitives — the ring shows up on keyboard focus
only, matching the rest of the UI.
The breadcrumb link had no focus-visible styling at all — Tab users had
no indicator. Add the shared focusRing so keyboard navigation in the
breadcrumb matches every other interactive primitive.
Lock in the focusRing helper's token wiring so future refactors can't
silently drop --a11y-focus-ring or --a11y-focus-ring-offset.
Edit and Delete for the current task are registered in the command palette
under Create section with `when()` guards bound to the /task/:id route, and
Edit is still reachable via the `e` keyboard shortcut on the detail page.
The kebab overflow button was the only scattered surface for those actions
on the detail page, so it is removed to shrink the header toolbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The inspector's "More" dropdown surfaced Peek and Delete — both rare and
redundantly reachable through the board (Peek via `p`, Delete through the
task card context menu and the command palette). Drops the dropdown and
the now-unused Peek/Delete props, which ripples into BoardDialogs and
KanbanBoard so the inspector slot stops plumbing actions it no longer
renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Change SkipLink default targetId from 'main' to 'main-content' to match
  the actual main landmark id used in app-layout.tsx
- Replace hand-rolled inline skip link in app-layout.tsx with <SkipLink/>
- Consolidates skip-link styling into the single a11y primitive

Greptile P2.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Remove aria-controls from SettingsCategoryCard: the referenced
  settings-panel-{id} element never exists in DOM concurrently with
  the card button (mutually exclusive disclosure pattern), so axe
  flags the attribute and screen readers warn.
- Replace container aria-live="polite" on the category/section wrapper
  with a targeted <LiveRegion> announcing only the active section name.
  Prevents verbose re-reads of form content on category swap.

Greptile P2 × 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Extract describeIntent() to classify-intent.ts and consume from both
intent-preview.tsx (visible chip) and home-page.tsx (live-region
announcement). Previously the two copies diverged for the search case
(chip quoted the raw input, live region didn't), so sighted and screen
reader users got different copy.

Greptile P2.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@aorumbayev
Copy link
Copy Markdown
Member Author

Addressed all 4 Greptile P2 comments in follow-up commits:

  • 5defa9dSkipLink default target: fixed default to main-content and replaced the inline skip link in app-layout.tsx with <SkipLink/>. Primitive is now actually used.
  • a15a461aria-controls pointing to absent DOM (settings card) + aria-live container anti-pattern (settings page): removed the dangling aria-controls, replaced the container aria-live with a targeted <LiveRegion> announcing only the active section name.
  • 16fd573Duplicate intent-description: extracted describeIntent() to classify-intent.ts, both the visible chip and the live-region announcement now use the same (quoted) copy.

All 243 tests still pass, build + tsc clean.

@aorumbayev aorumbayev merged commit 411c6fb into main Apr 17, 2026
18 checks passed
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.

2 participants