Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit eebfd69

Browse files
committed
feat(admin): redesign /ai/{summary,translation,insights} with article-grouped master-detail
- Drop the legacy AiRouteViewContent six-tab wrapper for these three routes; each /ai/* page now self-mounts AppPage + PageHeader + MasterDetailLayout. - Share a generic ArticleGroupedRouteView engine driven by a per-surface ArticleGroupedConfig<TItem>; three thin route wrappers (Summary / Translation / Insights) supply queries, item shape, drawer body, and extra actions. - List pane: borderless search + infinite scroll + neutral type badges; drops the noisy "raw article id" row footer. Both article and item rows use FocusScope + useListKeyboard + ListRow with proper j/k highlight + click sync. - Detail pane: master-style article link + divider + section title + item rows; trash on hover; mobile back button. - Edit + generate flows live in a ContentLayout split (resizable + collapsible) inside the detail slot, defaulting to a 70% editor pane. - Embedded editors: CodeMirror for summary / insights / translation text; Lexical via a lazy-loaded LexicalEmbeddedEditor (with NestedDoc providers and nestedDocEditNodes) when the translation contentFormat is "lexical". - PageHeader: new `iconOnly` flag on button actions so refresh stays square on desktop without falling back to `kind: "custom"`. - ContentLayout: new optional `mainMinSize` prop so consumers can carve out more room for the aside. - Docs: `.claude/skills/master-detail-list-keyboard/` codifies the j/k/click/ drawer-sync recipe so the bug doesn't recur. Spec lives under `docs/superpowers/specs/2026-05-28-ai-article-grouped-redesign-design.md`.
1 parent e15d5d6 commit eebfd69

34 files changed

Lines changed: 2898 additions & 6 deletions
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
---
2+
name: master-detail-list-keyboard
3+
description: Mandatory recipe for wiring j/k/↑/↓ keyboard navigation in admin-vue3 master-detail list panes. Apply when adding `useListKeyboard` to any list, building a new master-detail view, refactoring `ListRow` consumers, or whenever the user complains that "hjkl moves but the selected row doesn't update" / "键盘移动没有高亮" / "j/k 没反应" / "detail 不跟着键盘走" / "panel 打开后 j/k 切 item 右侧不刷新" / "edit drawer 不随键盘切换". Triggers on `useListKeyboard`, `FocusScope`, `ListRow`, `selection.selectOne`, `onItemFocus`, master-detail keyboard nav, drawer/panel-sync-with-keyboard.
4+
---
5+
6+
# master-detail list keyboard wiring (MUST)
7+
8+
## The trap
9+
10+
`useListKeyboard` exposes a `selection` model intended for **multi-select**. By default it calls `selection.selectOne(focusedId)` on every j/k tick. But the row's visual `selected` prop is **NOT auto-wired** — you must read `selection.isSelected(id)` yourself, otherwise pressing j/k changes nothing visible and the user thinks the keyboard is dead.
11+
12+
This bug bites every new master-detail page. Always recur. Hence this skill.
13+
14+
## The contract
15+
16+
Two distinct cases. **Pick one per scope** and wire it end-to-end.
17+
18+
### Case A — Outer master list (j/k drives the detail target)
19+
20+
This is the mail-client feel: arrow keys preview the next item by opening it in the detail pane.
21+
22+
```tsx
23+
useListKeyboard<Article>({
24+
scopeId: 'foo-articles',
25+
items: articles,
26+
getId: (a) => a.id,
27+
resetOn: [search],
28+
// 1. override default: drive the detail target, not the internal selection
29+
onItemFocus: (id) => {
30+
const article = articles.find((a) => a.id === id)
31+
if (article) setSelectedArticleId(article.id)
32+
},
33+
actions: [
34+
{
35+
key: 'open',
36+
label: 'Open',
37+
shortcut: 'Enter',
38+
run: (targets) => {
39+
const t = targets[0]
40+
if (t) setSelectedArticleId(t.id)
41+
},
42+
},
43+
],
44+
})
45+
46+
// row:
47+
<ArticleRow
48+
selected={article.id === selectedArticleId} // 2. selected = detail target
49+
isDetailTarget={article.id === selectedArticleId}
50+
onSelect={() => setSelectedArticleId(article.id)}
51+
/>
52+
```
53+
54+
**Both `onItemFocus` and `selected={... === selectedDetailId}` are required.** Without the override, j/k only mutates an internal selection model that nothing renders.
55+
56+
### Case B — Inner detail list (j/k highlights, Enter opens edit)
57+
58+
For a list inside the detail pane — e.g. summary rows under an article — j/k should highlight the focused row, NOT auto-open the editor. Use the hook's selection model directly:
59+
60+
```tsx
61+
const { selection } = useListKeyboard<Item>({
62+
scopeId: 'foo-items',
63+
items,
64+
getId: (it) => it.id,
65+
resetOn: [parentArticleId],
66+
// no onItemFocus — default `selection.selectOne(id)` is correct
67+
actions: [
68+
{ shortcut: 'Enter', run: (t) => openEditDrawer(t[0]) },
69+
{ shortcut: 'Backspace', run: (t) => confirmDelete(t[0]) },
70+
],
71+
})
72+
73+
// row:
74+
<ItemRow
75+
selected={selection.isSelected(item.id)} // ← read from selection model
76+
onSelect={() => openEditDrawer(item)}
77+
onDelete={() => confirmDelete(item)}
78+
/>
79+
```
80+
81+
**Reading `selection.isSelected(id)` is required** — it's the only place the j/k focus state surfaces.
82+
83+
#### Click → must also call selection.selectOne
84+
85+
Mouse clicks do NOT update the selection model automatically. ListRow's click handler only fires `onSelect(mode)`. If your row's visual `selected` is bound to `selection.isSelected(id)` (per Case B), clicking won't highlight the row — only j/k will. To make clicks behave the same as j/k, call `selection.selectOne(id)` in the click handler:
86+
87+
```tsx
88+
<ItemRow
89+
selected={selection.isSelected(item.id)}
90+
onSelect={(mode) => {
91+
if (mode === 'toggle') selection.toggle(item.id)
92+
else if (mode === 'range') selection.selectRange(item.id)
93+
else {
94+
selection.selectOne(item.id) // ← required for click highlight
95+
openEditDrawer(item)
96+
}
97+
}}
98+
/>
99+
```
100+
101+
(Drafts' `DraftsRouteViewContent.tsx` is the canonical example.)
102+
103+
#### When an external panel/drawer is open, sync target on j/k
104+
105+
If clicking an item opens an edit drawer / right-side panel, the user expects j/k to **also** swap the drawer's target while the drawer is open. Without this, the drawer is stuck on the originally-clicked item even though the highlight has moved.
106+
107+
Override `onItemFocus` to call `selection.selectOne` (preserve the highlight) **and** the external sync callback. Gate the sync on whether the drawer is currently open so j/k never opens a closed drawer from scratch:
108+
109+
```tsx
110+
const { selection } = useListKeyboard<Item>({
111+
scopeId: 'foo-items',
112+
items,
113+
getId: (it) => it.id,
114+
resetOn: [parentArticleId],
115+
onItemFocus: (id) => {
116+
selection.selectOne(id) // ← preserve default highlight
117+
const item = items.find((it) => it.id === id)
118+
if (item) onItemFocus?.(item) // ← inform parent
119+
},
120+
actions: [/* Enter, Backspace */],
121+
})
122+
```
123+
124+
…and at the parent, gate the sync:
125+
126+
```tsx
127+
<DetailPane
128+
onItemFocus={(item) => {
129+
if (editingItemId !== null) {
130+
setEditingItemId(item.id) // ← only when drawer is already open
131+
}
132+
}}
133+
/>
134+
```
135+
136+
Also: when an edit drawer's body owns local state (`useState` initialized from props), give it `key={editingItem.id}` so it remounts on item switch — otherwise the form keeps the old item's values.
137+
138+
```tsx
139+
<EditDrawerBody
140+
item={editingItem}
141+
key={editingItem.id} // ← required when switching items inside an open drawer
142+
...
143+
/>
144+
```
145+
146+
## Forbidden
147+
148+
- `selected={false}` constant on a `ListRow` inside a `FocusScope`. Means j/k is dead.
149+
- Wiring `selected={detailId === id}` while not passing `onItemFocus`. j/k advances the internal selection model invisibly; the user sees no movement.
150+
- Two scopes using the same `scopeId` on one page — `FocusScope` ids must be unique per page (use a `scopeIdPrefix` and append `-articles` / `-items`).
151+
- Auto-opening an edit drawer / modal from `onItemFocus`. That maps Up/Down to "open editor", which feels broken. Use `onItemFocus` only to drive **already-visible** state (the detail target, a preview, a highlight).
152+
- Forgetting `<FocusScope id={scopeId}>` around the rows. `useListKeyboard` is scope-gated; without the wrapper, nothing fires.
153+
154+
## Checklist when reviewing a list pane
155+
156+
1. Is there a `<FocusScope id={scopeId}>` around the row list? If no — keyboard nav is silently broken.
157+
2. Does `useListKeyboard` get the same `scopeId`? Mismatch → silent breakage.
158+
3. What drives `selected` on the row?
159+
- If `selection.isSelected(id)` → Case B. If you override `onItemFocus`, call `selection.selectOne(id)` yourself first.
160+
- If `someExternalId === id` → Case A. Must pass `onItemFocus` that updates `someExternalId`.
161+
- If literal `false` → bug.
162+
4. Does `resetOn` include the inputs that should clear focus (filter changes, search changes, parent article switch)? Without it, j/k can land on a stale id after list reshape.
163+
5. Does **clicking** a row update `selected` visually? If `selected` reads from `selection.isSelected(id)`, the row's `onSelect` handler must call `selection.selectOne(id)` — mouse clicks do not auto-update the selection model.
164+
6. If an edit drawer / right-side panel opens from the row, does j/k update it while it's open? Override `onItemFocus` to fire an external sync (gated on drawer open) AND keep `selection.selectOne(id)`. Pass `key={item.id}` to the drawer body so its local state resets on item switch.
165+
166+
## Reference views to copy
167+
168+
- `apps/admin/src/features/drafts/components/DraftsRouteViewContent.tsx` — Case B canonical (selection drives `selected`, Enter opens detail through the action registry).
169+
- `apps/admin/src/features/ai/components/article-grouped/ArticleListPane.tsx` — Case A canonical (j/k drives the article-detail target).
170+
- `apps/admin/src/features/ai/components/article-grouped/ArticleDetailPane.tsx` — Case B canonical (inner item list).
171+
172+
## Why a hook exposes both
173+
174+
`useListKeyboard` was built for multi-select lists (posts, notes, comments) where the selection model owns checkbox state AND focus visuals. For master-detail single-target, the selection model is the wrong source of truth — the URL-synced detail id is. Don't fight the hook; just pick the right case above. If a project-wide `useMasterDetailKeyboard` wrapper materializes, prefer it.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Sparkles } from 'lucide-react'
2+
3+
interface ArticleDetailEmptyStateProps {
4+
title: string
5+
description: string
6+
}
7+
8+
export function ArticleDetailEmptyState(props: ArticleDetailEmptyStateProps) {
9+
return (
10+
<div className="flex h-full min-h-0 items-center justify-center bg-white dark:bg-neutral-950">
11+
<div className="flex flex-col items-center gap-3 px-6 text-center">
12+
<div className="flex size-16 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800">
13+
<Sparkles aria-hidden="true" className="size-8 text-neutral-400" />
14+
</div>
15+
<p className="text-base font-medium text-neutral-900 dark:text-neutral-50">
16+
{props.title}
17+
</p>
18+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
19+
{props.description}
20+
</p>
21+
</div>
22+
</div>
23+
)
24+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { ArrowLeft, Loader2, Plus } from 'lucide-react'
2+
import { Link } from 'react-router'
3+
import type { ArticleInfo } from '~/api/ai'
4+
import type { ListAction } from '~/ui/list-actions'
5+
import type { ContextMenuItem } from '~/ui/overlay/context-menu'
6+
import type { ArticleGroupedConfig } from './types'
7+
8+
import { useI18n } from '~/i18n'
9+
import { FocusScope } from '~/ui/focus-scope'
10+
import { useListKeyboard } from '~/ui/list-actions'
11+
import { Button } from '~/ui/primitives/button'
12+
import { Scroll } from '~/ui/primitives/scroll'
13+
import { cn } from '~/utils/cn'
14+
15+
import { ItemRow } from './ItemRow'
16+
import { getRefTypeMeta } from './refTypeMeta'
17+
18+
interface ArticleDetailPaneProps<TItem> {
19+
config: ArticleGroupedConfig<TItem>
20+
article: ArticleInfo
21+
items: TItem[]
22+
isLoading: boolean
23+
onBack: () => void
24+
onGenerate: () => void
25+
onEdit: (item: TItem) => void
26+
onDelete: (item: TItem) => void
27+
/** Fires on keyboard focus traversal (j/k/arrow). Use to sync external state (e.g. open-drawer target). */
28+
onItemFocus?: (item: TItem) => void
29+
keyboardActions: ReadonlyArray<ListAction<TItem>>
30+
buildMenu: (item: TItem) => ContextMenuItem[]
31+
}
32+
33+
export function ArticleDetailPane<TItem>(props: ArticleDetailPaneProps<TItem>) {
34+
const { t } = useI18n()
35+
const scopeId = `${props.config.scopeIdPrefix}-items`
36+
const meta = getRefTypeMeta(props.article.type)
37+
const TypeIcon = meta.icon
38+
const editPath = meta.editPath?.(props.article.id) ?? null
39+
const GenerateIcon = props.config.generate.icon ?? Plus
40+
41+
const { selection } = useListKeyboard<TItem>({
42+
scopeId,
43+
items: props.items,
44+
getId: props.config.getId,
45+
resetOn: [props.article.id],
46+
actions: props.keyboardActions,
47+
onItemFocus: (id) => {
48+
selection.selectOne(id)
49+
const item = props.items.find((it) => props.config.getId(it) === id)
50+
if (item) props.onItemFocus?.(item)
51+
},
52+
})
53+
54+
const articleTitleNode = (
55+
<span className="inline-flex min-w-0 items-center gap-2">
56+
<TypeIcon
57+
aria-hidden="true"
58+
className="size-5 shrink-0 text-neutral-400"
59+
/>
60+
<span className="truncate text-base font-semibold text-neutral-950 dark:text-neutral-50">
61+
{props.article.title || t(meta.labelKey)}
62+
</span>
63+
</span>
64+
)
65+
66+
return (
67+
<FocusScope
68+
className={cn(
69+
'outline-hidden flex h-full min-h-0 flex-col bg-white dark:bg-neutral-950',
70+
)}
71+
id={scopeId}
72+
>
73+
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-neutral-200 px-4 dark:border-neutral-800">
74+
<div className="flex min-w-0 items-center gap-2">
75+
<button
76+
aria-label={t('common.back')}
77+
className="inline-flex size-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-950 lg:hidden dark:text-neutral-400 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
78+
onClick={props.onBack}
79+
type="button"
80+
>
81+
<ArrowLeft aria-hidden="true" className="size-4" />
82+
</button>
83+
<h2 className="truncate text-sm font-medium text-neutral-950 dark:text-neutral-50">
84+
{t(props.config.detailSectionTitleKey)}
85+
</h2>
86+
</div>
87+
<Button onClick={props.onGenerate} type="button" variant="subtle">
88+
<GenerateIcon aria-hidden="true" className="size-4" />
89+
{t(props.config.generate.labelKey)}
90+
</Button>
91+
</div>
92+
93+
<Scroll className="flex-1" innerClassName="p-4">
94+
{editPath ? (
95+
<Link
96+
className="inline-flex max-w-full items-center gap-2 transition-colors hover:text-blue-600 dark:hover:text-blue-400"
97+
to={editPath}
98+
>
99+
{articleTitleNode}
100+
</Link>
101+
) : (
102+
<div className="inline-flex max-w-full">{articleTitleNode}</div>
103+
)}
104+
105+
<div className="my-4 h-px bg-neutral-100 dark:bg-neutral-800" />
106+
107+
<div className="flex items-center gap-2">
108+
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
109+
{t(props.config.detailSectionTitleKey)}
110+
</h3>
111+
<span className="text-xs text-neutral-400">
112+
{t(props.config.itemCountKey, { count: props.items.length })}
113+
</span>
114+
</div>
115+
116+
{props.isLoading && props.items.length === 0 ? (
117+
<div className="flex justify-center py-8">
118+
<Loader2
119+
aria-hidden="true"
120+
className="size-5 animate-spin text-neutral-400"
121+
/>
122+
</div>
123+
) : props.items.length === 0 ? (
124+
<div className="mt-4 flex flex-col items-center gap-3 rounded border border-dashed border-neutral-200 px-4 py-8 text-center dark:border-neutral-800">
125+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
126+
{t(props.config.inlineEmptyKey, {
127+
kind: t(props.config.kindKey),
128+
})}
129+
</p>
130+
<Button onClick={props.onGenerate} type="button" variant="subtle">
131+
<GenerateIcon aria-hidden="true" className="size-4" />
132+
{t(props.config.generate.labelKey)}
133+
</Button>
134+
</div>
135+
) : (
136+
<div className="-mx-4 mt-3">
137+
{props.items.map((item) => {
138+
const id = props.config.getId(item)
139+
return (
140+
<ItemRow<TItem>
141+
buildMenu={props.buildMenu}
142+
createdAt={props.config.getCreatedAt(item)}
143+
id={id}
144+
item={item}
145+
key={id}
146+
lang={props.config.getLang(item)}
147+
onDelete={() => props.onDelete(item)}
148+
onSelect={(mode) => {
149+
if (mode === 'toggle') selection.toggle(id)
150+
else if (mode === 'range') selection.selectRange(id)
151+
else {
152+
selection.selectOne(id)
153+
props.onEdit(item)
154+
}
155+
}}
156+
preview={props.config.getPreview(item)}
157+
selected={selection.isSelected(id)}
158+
/>
159+
)
160+
})}
161+
</div>
162+
)}
163+
</Scroll>
164+
</FocusScope>
165+
)
166+
}

0 commit comments

Comments
 (0)