docs(base): add working plans for recent base feature work

This commit is contained in:
Philipinho
2026-04-19 02:05:34 +01:00
parent cac4774641
commit 4c0348e46a
12 changed files with 4348 additions and 0 deletions
@@ -0,0 +1,776 @@
# Base Cell Dropdown Keyboard Navigation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add keyboard navigation (ArrowUp/Down/Home/End/Enter) to all four base cell dropdowns — `CellPerson`, `CellSelect`, `CellMultiSelect`, `CellStatus` — so users can pick values without a mouse, matching Mantine `MultiSelect`'s keyboard UX.
**Architecture:** All four cells use the same custom `Popover` + HTML dropdown pattern (not `useCombobox`). Instead of editing each in isolation, factor the shared logic (activeIndex, arrow/Home/End handling, reset-on-filter, scroll-into-view, option ref tracking) into one hook `useListKeyboardNav`. Each cell flattens its visible items into a single linear list (including the "Add option" row for select/multi, and flattening across status categories), passes the count to the hook, and wires up Enter selection locally.
**Tech Stack:** React 18, TypeScript, Mantine v8 `Popover` / `TextInput`, CSS Modules.
---
## Scope
**In scope:**
- `cell-person.tsx` — members, tag input with Backspace-removes-tag behavior.
- `cell-select.tsx` — single choice, optional "Add option" row.
- `cell-multi-select.tsx` — multi choice, optional "Add option" row.
- `cell-status.tsx` — single choice, grouped by category (flattened for nav).
- Shared hook `use-list-keyboard-nav.ts`.
- One new CSS class for keyboard-active highlight.
**Out of scope:**
- Swapping any cell to Mantine `MultiSelect` / `useCombobox` — too disruptive; all four have deliberate custom UIs.
- Automated tests — this codebase has no existing unit tests for base cells, and a harness just for this is scope creep. Task 7 is a manual QUX walkthrough.
- Other editors in the base feature (e.g., toolbar pickers, filter UIs) — out of scope unless they hit the same bug.
---
## File Structure
**Create:**
- `apps/client/src/features/base/hooks/use-list-keyboard-nav.ts` — shared hook.
**Modify:**
- `apps/client/src/features/base/styles/cells.module.css` — add `.selectOptionKeyboardActive`.
- `apps/client/src/features/base/components/cells/cell-person.tsx`
- `apps/client/src/features/base/components/cells/cell-select.tsx`
- `apps/client/src/features/base/components/cells/cell-multi-select.tsx`
- `apps/client/src/features/base/components/cells/cell-status.tsx`
**No other files touched.**
---
## Design Notes (read before coding)
### Why a hook and not copy-paste per cell
Four cells would get the same 40-line block. C-9 ("SHOULD NOT extract unless reused") is satisfied here — the logic is reused in 4 places, and diverging across them later would be a bug farm. The hook owns only the *navigation* concern (activeIndex, arrow keys, scroll). Each cell still owns its own Enter semantics, Escape, Backspace, and filter computation, because those diverge.
### Why `activeIndex: -1` initially
No highlight on open. First ArrowDown moves to 0. Enter at `-1` is a no-op — we don't guess, we don't commit. This matches Mantine MultiSelect behavior.
### Why a new CSS class instead of reusing `selectOptionActive`
`selectOptionActive` means "this item is currently selected" (blue). Keyboard nav needs a *separate* "Enter will land here" state or users can't tell which unselected option is focused. Add `selectOptionKeyboardActive` and stack it with `selectOptionActive` when both apply (selected + keyboard-focused uses a slightly darker blue).
### Flattening the nav list
- **cell-person:** `filteredMembers` (already flat).
- **cell-select / cell-multi-select:** `filteredChoices` plus one trailing "Add option" virtual entry when `showAddOption`. Arrow nav must include it; Enter on that index calls `handleAddOption()`. Represent as a discriminated union so Enter can dispatch correctly.
- **cell-status:** flatten `groups.flatMap(g => g.choices)`. Build a `Map<choiceId, flatIndex>` once per render and use it to attach refs and compute highlighting inside the grouped render loop.
### Mouse + keyboard sync
Mouse hover on an option sets `activeIndex` to that index. Prevents the "mouse hover shows A, keyboard focus is B, Enter selects B" mismatch. Applies to all four cells.
### `onMouseDown.preventDefault` on options
Without it, clicking an option can blur the input before `onClick` fires; in some browsers, Mantine's `trapFocus` + popover close sequence then cancels the selection. Mantine's `useCombobox` hides this — we don't use it, so add the guard. Applies to all four cells.
### Reset triggers
Reset `activeIndex` to `-1` whenever:
- the search string changes (filter changed → stale index)
- `isEditing` flips (reopening the editor)
- for cell-select/multi: whether the "Add option" row is visible, since that also changes the list length
Pass all three as `resetDeps` to the hook.
### Scroll into view
Dropdowns are capped at `max-height: 240px` with `overflow-y: auto` ([cells.module.css:219-222](apps/client/src/features/base/styles/cells.module.css:219)). Use `scrollIntoView({ block: "nearest" })` on the active option when `activeIndex` changes.
### Preserve existing behaviors
- **cell-person:** Escape cancels, Backspace on empty search removes last tag — keep both.
- **cell-select:** Escape cancels, Enter with `showAddOption` and no active index adds — keep the Enter-adds-when-no-nav fallback. Priority: if `activeIndex >= 0`, Enter uses that (which may itself be the add-option virtual entry). Else fall through to the existing `handleAddOption()` call if `showAddOption`.
- **cell-multi-select:** same as cell-select.
- **cell-status:** Escape cancels. No Add-option row. Enter with `activeIndex >= 0` selects.
---
## Task 1: Add keyboard-active CSS class
**Files:**
- Modify: `apps/client/src/features/base/styles/cells.module.css`
- [ ] **Step 1: Append after the existing `.selectOptionActive` rule (ending at line 240)**
```css
.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.selectOptionActive.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8));
}
```
First rule: unselected + keyboard-focused (matches hover shade). Second: selected + keyboard-focused (slightly darker blue than plain selected, distinguishable).
- [ ] **Step 2: Commit**
```bash
git add apps/client/src/features/base/styles/cells.module.css
git commit -m "style(base): add keyboard-active option style for cell dropdowns"
```
---
## Task 2: Create the `useListKeyboardNav` hook
**Files:**
- Create: `apps/client/src/features/base/hooks/use-list-keyboard-nav.ts`
- [ ] **Step 1: Write the hook**
```tsx
import { useCallback, useEffect, useRef, useState } from "react";
type UseListKeyboardNavResult = {
activeIndex: number;
setActiveIndex: (idx: number) => void;
handleNavKey: (e: React.KeyboardEvent) => boolean;
setOptionRef: (idx: number) => (el: HTMLElement | null) => void;
};
export function useListKeyboardNav(
itemCount: number,
resetDeps: ReadonlyArray<unknown>,
): UseListKeyboardNavResult {
const [activeIndex, setActiveIndex] = useState(-1);
const optionRefs = useRef<Array<HTMLElement | null>>([]);
// Reset highlight when filter/open-state changes. resetDeps is intentional.
useEffect(() => {
setActiveIndex(-1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, resetDeps);
useEffect(() => {
if (activeIndex < 0) return;
const el = optionRefs.current[activeIndex];
if (el) el.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const setOptionRef = useCallback(
(idx: number) => (el: HTMLElement | null) => {
optionRefs.current[idx] = el;
},
[],
);
const handleNavKey = useCallback(
(e: React.KeyboardEvent): boolean => {
if (itemCount === 0) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0));
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1));
return true;
}
if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
return true;
}
if (e.key === "End") {
e.preventDefault();
setActiveIndex(itemCount - 1);
return true;
}
return false;
},
[itemCount],
);
return { activeIndex, setActiveIndex, handleNavKey, setOptionRef };
}
```
Notes:
- `handleNavKey` returns `true` if it handled the key, so callers can `if (nav.handleNavKey(e)) return;` before their own Enter/Escape/Backspace branches.
- Wrap-around on both ends.
- `resetDeps` uses an eslint-disable because it's a variadic dep array by design — the hook name and the `resetDeps` argument make the intent clear without a comment inside the body beyond the one-liner. This is the C-7-approved kind of caveat comment.
- [ ] **Step 2: Build verification**
Run: `pnpm nx run client:build`.
Expected: success, no TypeScript errors.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/hooks/use-list-keyboard-nav.ts
git commit -m "feat(base): add useListKeyboardNav hook for dropdown keyboard nav"
```
---
## Task 3: Wire keyboard nav into `CellPerson`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-person.tsx`
- [ ] **Step 1: Import the hook**
Add to the imports at the top:
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Instantiate the hook**
After the `filteredMembers` declaration (currently ending line 61), add:
```tsx
const nav = useListKeyboardNav(filteredMembers.length, [search, isEditing]);
```
- [ ] **Step 3: Extend `handleKeyDown`**
Replace the existing `handleKeyDown` `useCallback` (lines 97109) with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex < 0 || nav.activeIndex >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[nav.activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault();
handleRemove(personIds[personIds.length - 1]);
}
},
[onCancel, nav, filteredMembers, handleSelect, search, personIds, handleRemove],
);
```
- [ ] **Step 4: Render the keyboard-active highlight, ref, hover sync, and mousedown guard**
Replace the filtered-members map (currently lines 173191) with:
```tsx
{filteredMembers.map((member, idx) => {
const isSelected = selectedSet.has(member.id);
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={member.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={24}
radius="xl"
/>
<span className={cellClasses.personOptionName}>
{member.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-person.tsx
git commit -m "feat(base): keyboard navigation for person cell dropdown"
```
---
## Task 4: Wire keyboard nav into `CellSelect`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-select.tsx`
Recall: this cell has `filteredChoices` plus a conditional "Add option" row when `showAddOption === true`. Both must be navigable. Enter on a choice selects it; Enter on the add-option virtual entry calls `handleAddOption`.
- [ ] **Step 1: Import the hook**
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Build the nav item list and instantiate the hook**
After the `showAddOption` declaration (currently line 71), add:
```tsx
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
const navItems: NavItem[] = useMemo(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const nav = useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
```
- [ ] **Step 3: Replace `handleKeyDown`**
Replace the existing `handleKeyDown` `useCallback` (currently lines 98110) with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex >= 0 && nav.activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[nav.activeIndex];
if (item.kind === "choice") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, nav, navItems, handleSelect, handleAddOption, showAddOption],
);
```
- [ ] **Step 4: Update the choices render loop**
Replace the `filteredChoices.map` block (currently lines 146161) with:
```tsx
{filteredChoices.map((choice, idx) => {
const isSelected = choice.id === selectedId;
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={choice.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Update the "Add option" row**
Replace the `showAddOption && (...)` block (currently lines 162175) with:
```tsx
{showAddOption && (() => {
const idx = filteredChoices.length;
const isKeyboardActive = idx === nav.activeIndex;
return (
<div
ref={nav.setOptionRef(idx)}
className={`${cellClasses.addOptionRow} ${
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : ""
}`}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
);
})()}
```
The IIFE is the least-disruptive way to introduce a local `idx` binding without restructuring the parent JSX.
- [ ] **Step 6: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 7: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-select.tsx
git commit -m "feat(base): keyboard navigation for single-select cell dropdown"
```
---
## Task 5: Wire keyboard nav into `CellMultiSelect`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-multi-select.tsx`
This mirrors Task 4. Only differences: `handleSelect` is named `handleToggle`, selected check uses `selectedSet.has(...)`, and `handleAddOption` commits `[...selectedIds, newChoice.id]` rather than replacing.
- [ ] **Step 1: Import the hook**
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Build the nav item list and instantiate the hook**
After `showAddOption` (currently line 74), add:
```tsx
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
const navItems: NavItem[] = useMemo(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const nav = useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
```
- [ ] **Step 3: Replace `handleKeyDown`**
Replace lines 102114 with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex >= 0 && nav.activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[nav.activeIndex];
if (item.kind === "choice") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, nav, navItems, handleToggle, handleAddOption, showAddOption],
);
```
- [ ] **Step 4: Update the choices render loop**
Replace `filteredChoices.map(...)` (currently lines 143160) with:
```tsx
{filteredChoices.map((choice, idx) => {
const isSelected = selectedSet.has(choice.id);
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={choice.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Update the "Add option" row**
Replace `showAddOption && (...)` (currently lines 161174) with the same IIFE pattern as Task 4:
```tsx
{showAddOption && (() => {
const idx = filteredChoices.length;
const isKeyboardActive = idx === nav.activeIndex;
return (
<div
ref={nav.setOptionRef(idx)}
className={`${cellClasses.addOptionRow} ${
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : ""
}`}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
);
})()}
```
- [ ] **Step 6: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 7: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-multi-select.tsx
git commit -m "feat(base): keyboard navigation for multi-select cell dropdown"
```
---
## Task 6: Wire keyboard nav into `CellStatus`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-status.tsx`
This cell renders choices grouped by category. Flatten the groups for nav indexing while keeping the grouped rendering.
- [ ] **Step 1: Import the hook**
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Flatten and instantiate the hook**
After the `groups` declaration (currently ending line 74), add:
```tsx
const flatChoices = useMemo(
() => groups.flatMap((g) => g.choices),
[groups],
);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const nav = useListKeyboardNav(flatChoices.length, [search, isEditing]);
```
- [ ] **Step 3: Replace `handleKeyDown`**
Replace lines 8391 with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex < 0 || nav.activeIndex >= flatChoices.length) return;
e.preventDefault();
handleSelect(flatChoices[nav.activeIndex]);
}
},
[onCancel, nav, flatChoices, handleSelect],
);
```
- [ ] **Step 4: Update the choice render inside groups**
Replace the inner `group.choices.map(...)` block (currently lines 132149) with:
```tsx
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = choice.id === selectedId;
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={choice.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-status.tsx
git commit -m "feat(base): keyboard navigation for status cell dropdown"
```
---
## Task 7: Manual QUX verification
No automated tests exist for cell components. Verify manually in a running dev client (only if the user asks — per CLAUDE.md, do not launch the client yourself).
For **each** of the four cells (person, select, multi-select, status), walk this checklist in the base grid:
**Golden path:**
1. Click the cell → popover opens, search focused, no initial highlight.
2. ArrowDown → first option highlights.
3. Repeat ArrowDown → highlight moves; dropdown scrolls past viewport.
4. Enter on highlight → that value is selected/toggled.
5. ArrowUp from index 0 → wraps to last item.
6. ArrowDown from last → wraps to first.
**Search + keyboard:**
7. Type a partial string → list filters, highlight resets.
8. ArrowDown → lands on first *filtered* option, not a stale index.
9. Clear the search → list expands, highlight resets.
**Mouse + keyboard interplay:**
10. Hover an option with mouse → that option becomes keyboard-active.
11. Move mouse away, press ArrowDown → nav continues from hovered index.
12. Click an option → selects cleanly, popover does not flicker-close (validates `onMouseDown.preventDefault`).
**Edge cases:**
13. Empty filter result → ArrowUp/Down/Home/End/Enter are no-ops; Escape still closes; cell-person's Backspace-removes-tag still works.
14. Home / End → jump to first / last item.
15. Escape at any time → popover closes, no commit.
**Cell-specific:**
- **cell-person** (multi mode): Backspace on empty search removes the last tag (existing behavior preserved).
- **cell-person** (single mode, `allowMultiple: false`): Enter still selects; selecting an already-selected person clears it.
- **cell-select** with typed new value: the "Add option" row appears as the last navigable item; ArrowDown reaches it and Enter triggers `handleAddOption`. Enter with no active index (user typed and hasn't pressed ArrowDown) still triggers `handleAddOption` (fallback preserved).
- **cell-multi-select**: same as cell-select for add-option behavior.
- **cell-status**: navigation crosses category boundaries seamlessly (To Do → In Progress → Complete).
**Visual:**
- Selected-only → blue.
- Keyboard-focused-only → gray.
- Both → darker blue (distinguishable from plain selected).
- [ ] **Step 1: Walk the checklist per cell. If any scenario fails, fix and re-verify that cell before moving on.**
---
## Remember
- Exact file paths above; don't grep for them at edit time.
- Preserve existing Escape/Backspace/Enter-adds-new behaviors verbatim.
- Don't swap to `useCombobox` — scope creep.
- One CSS class, one hook, four cell edits — that's the whole change.
- Commit after each task (GH-1, Conventional Commits).
- No Anthropic/Claude attribution in commits (undercover mode).