keyboard-operable ClideTappable + palette nav (T-100)
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m5s

Plug widgets into the keymap layer landed in T-117.

ClideTappable:
  - Wrap in `Actions(ActivateIntent → onTap)` outside a `Focus` so
    dispatch from the focused context walks up and hits the action.
  - Add a focus ring via `tokens.globalFocus` (DecoratedBox foreground
    overlay, transparent border when unfocused, no layout shift).
  - Disabled (`onTap == null`) skips focus traversal and shows the
    forbidden cursor.

ClidePalette:
  - Register Actions for the four palette intents
    (selectNext / selectPrev / accept / dismiss).
  - Publish `palette.open` scope flag via `KeymapService.setScopeFlag`
    so when-clauses can scope future bindings to "palette only".
  - Highlight the selected row with `listItemSelectedBackground`;
    scroll it into view on nav.
  - `PaletteController` grows `selectedIndex` + `selectNext` /
    `selectPrevious` / `acceptSelected`; index resets on open /
    filter change.

Intents.dart drops the `ClideIntent` base — `ActivateIntent` and
`DismissIntent` come from Flutter; clide owns the palette and text-
scale and command-bridge subclasses. `parseIntentId('activate')` →
Flutter's class; same for dismiss. Widget code uses the canonical
Flutter Intent types where they fit.

App root grows a PaletteOpenIntent action that calls
`services.palette.open()`, completing the ctrl/cmd+shift+p path
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:48:03 +02:00
co-authored by Claude Opus 4.7
parent 798ba524f1
commit 12e0509fa3
15 changed files with 531 additions and 160 deletions
@@ -1797,3 +1797,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
2. `KeymapService.setPreset("jetbrains")` activates the preset and all asserted bindings resolve.
3. A regression test exercises a representative subset (e.g. shift+shift → quick-open command — see Q-9 if the search-everywhere overlay needs its own intent).', NULL, '2026-05-17 19:39:37', '2026-05-17 19:39:37', '2026-05-17 19:39:37', NULL, 'c1129f4cb3454b99e1cbf645ca56bea5', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-117', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:39:40', '2026-05-17 19:39:40', '2026-05-17 19:39:40', NULL, '75b2fd14ceb7daf28645ccf84cb041f1', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'status', 'in_progress', 'in_progress', NULL, '2026-05-17 19:40:44', '2026-05-17 19:40:44', '2026-05-17 19:40:44', NULL, '65600862f5787501dcd057756cdb4313', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'status', 'in_progress', 'done', NULL, '2026-05-17 19:47:50', '2026-05-17 19:47:50', '2026-05-17 19:47:50', NULL, '7d3c0ad4c47d3271ac35c5159e6e9d0e', 1) ON CONFLICT(hash) DO NOTHING;
+10
View File
@@ -1761,3 +1761,13 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
- ClideTappable/Palette widget integration (T-100 — comes immediately after this lands).
Source: 2026-05-17 design conversation; supersedes T-110.', 'done', 'high', NULL, NULL, NULL, '2026-05-17 19:28:28', '2026-05-17 19:39:40', NULL, '92d68025f0ee7c4d133dca65e97c97b8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-100', 'story', 'T-97', 'make ClideTappable keyboard-operable; add palette arrow-keys + Escape', '`ClideTappable` (`lib/widgets/src/clide_tappable.dart`) is the base of nearly every interactive widget — `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, most builtin list items — and it is `MouseRegion` + `GestureDetector` only. No `Focus`, no Enter/Space handler, no focus ring. None of these widgets can be reached by Tab or activated from the keyboard. The keyboard-traversal test passes only because it externally wraps in a `Focus` node — it tests non-blocking, not operability.
`ClidePalette` (`lib/widgets/src/clide_palette.dart`) is similarly broken: `onSubmitted` only ever invokes `filtered.first`; no up/down handling, no selected index, no selection highlight, no Escape handler.
**Fix:**
1. Wrap `ClideTappable`''s child in `Focus` + `Shortcuts`/`Actions` so Tab focuses it and Enter/Space invokes `onTap`. Render a focus ring via the token system.
2. Add arrow-key navigation + selected-index + Escape + Enter-on-selected to `ClidePalette` (model after `_ProjectSwitcherDropdown.onKeyEvent` at `app.dart:446-452`).
3. Extend the a11y test layer to assert operability (Tab + Enter actually invokes), not just Semantics presence.
Source: consultants.md "UX — Findings — [Critical]".', 'done', 'high', NULL, NULL, NULL, '2026-05-17 18:47:23', '2026-05-17 19:47:50', NULL, 'f6f0c34a700b133bc84c6d8d7ea450a3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);