refactor(web): UX/UI foundation — a11y + intent-driven home + settings disclosure#115
refactor(web): UX/UI foundation — a11y + intent-driven home + settings disclosure#115aorumbayev merged 35 commits intomainfrom
Conversation
…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]>
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 SummaryThree cooperating UX/UI refinements: an a11y primitive layer (CSS tokens, Confidence Score: 5/5Safe 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).
|
| 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]
Comments Outside Diff (1)
-
packages/web/src/components/layout/activity-bar.tsx, line 6-11 (link)Home nav item won't highlight when user is at the root
/routeThe "Home" entry links to
/home, but the index route is/(served by the samehome-page). React Router'sNavLinkactive state is path-based, so landing on/leaves the Home icon unstyled — only/hometriggers 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 anendprop so it doesn't match every sub-route:(and add
endto theNavLinkto 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
| */ | ||
| export function SkipLink({ | ||
| targetId = 'main', | ||
| className, | ||
| children = 'Skip to main content', |
There was a problem hiding this 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.
| */ | |
| 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.| aria-expanded={expanded} | ||
| aria-controls={`settings-panel-${id}`} | ||
| onClick={onClick} |
There was a problem hiding this 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.
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.| 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'; | ||
| } |
There was a problem hiding this 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.
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"> |
There was a problem hiding this 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:
<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.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]>
…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]>
|
Addressed all 4 Greptile P2 comments in follow-up commits:
All 243 tests still pass, build + tsc clean. |
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,focusRinghelper,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.useSessionStreamreuses 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
focusRing helper → shadcn adoption
Axe-core violations → fixed
button-name(critical) on SelectTrigger — addedaria-label="Time range"heading-order(moderate) — promotedh3 → h2on Connection + System Checks cardsexpectNoViolationson all 3 pages. Regression net in place.Highlights
LiveRegionas soon as it landed; migration agent dual-registered actions into the palette spine the command-palette agent builtCommits (32)
Foundation (21) — same as original PR
A11y
e1e0685CSS tokens6e81d94focus-ring + useReducedMotionc79a543a11y primitives4f4a3e8axe-core smoke suite00fc833raw-color lintSettings
9baf926category card componentd612eb1Workflow / Agents / Advanced grouping7d74b4bprogressive disclosure7ce9820testsHome
061eba2intent classifier25cbef0hero input page9088216routing + activity-bar entry2784a5btestsLiving cards
432a1ecuseSessionStream hookb3a139eCardPulse component27575f7integration into task cards04094a6testsCommand palette
83731efaction registrybf72ca6palette UI7f40b1bCmd+K global shortcuta70258cbuilt-in commandsFollow-up migrations (11)
Menu migration
0250e3cregister task/run/review actions184bb93remove task detail overflow menu7e0fc38remove board inspector More menufocusRing adoption
8fbaf25button, input, textareac72884cselect, native-select, switch, toggle, badge95f76adtabs, scroll-area, resizable, dialog-close6e3af3ebreadcrumb linksc6b6830testsAxe fixes
77dbd05fix button-name on Analytics15409d4fix heading-order on Settingsf6da4c7flip tests to strictTest plan
pnpm run build→ cleanpnpm exec vitest run→ 243 passedpnpm exec tsc --noEmit→ cleanpnpm run check:raw-colors→ baseline 21 hits, 0 new/, type intent, confirm routingThree-voice debrief
onCommandExecutetelemetry pinned. Focus-ring migration had a measurable baseline (15 → 11). Intent classifier keyword-only; upgradeable when data demands.🤖 Generated with Claude Code