Owner: Design Lead. Base: code as read on 2026-10-06. All paths are relative to Frontend/Admin/react/.
Priority: P0 blocks daily operator flow or a11y AA; P1 big speed gain; P2 polish.
Suggested order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13.
- Fixed 240px sidebar at
lg+, mobile Radix Dialog drawer (src/app/layout/AdminLayout.tsx:144-176). Desktop sidebar cannot collapse. DataTableonly renders rows. No sorting, selection, density, or sticky header (src/shared/ui/data-table.tsx:11-53).- Each page builds its own loading/error/empty/LoadMore branches (
PostsPage.tsx:202-223,UsersPage.tsx:106-127,CommentsPage.tsx:63-84,ReportsPage.tsx:129-150). ConfirmDialogexists, and its focus lands on Cancel (src/shared/ui/dialog.tsx:44-95). The confirm button shows no pending indicator.ReportsPageDecideDialoguses the plainDialog, even when "remove content" (destructive) is checked (ReportsPage.tsx:188-209).- Toaster:
top-right, richColors, closeButton (src/app/providers.tsx:9). - Nothing in
src/handles keyboard shortcuts, setsdocument.title, or guards unsaved changes (grep forkeydown|document.title|useBlocker|beforeunloadreturns no matches). - Tokens: colour tokens only (
src/styles.css:5-59). No radius, density,--success, or--warningtokens.OfflineBannerhard-codesamber-*(states.tsx:71). - Comments page has no search or filter (
CommentsPage.tsx:59-85).
Files: new src/shared/ui/list-view.tsx. Refactor PostsPage, UsersPage, CommentsPage, ReportsPage, BansPage, AuditPage, SessionsPage, RolesPage, TaxonomyPages.
Behavior:
- Props:
query(infinite or plain query result),toolbar?: ReactNode,empty: { title, hint?, action? },filtered: boolean,onClearFilters?, plus achildren(items)render function. - States:
isPending→LoadingRows rows={8}.isError→ErrorState.- Empty and
filtered→ "No results for current filters" with aButton variant="outline" size="sm"labelled "Clear filters". - Empty and not
filtered→empty.titleandempty.hint, withempty.action(for example "New post") when given.
- Background refetch (
isFetching && !isPending): show a 2px indeterminate bar at the top of the Card (absolute inset-x-0 top-0 h-0.5 bg-primary/60 animate-pulse). Keep the stale rows visible and never swap them for skeletons. - Extend
EmptyStateto{ icon?, title, hint?, action? }. Title usestext-sm font-medium text-foreground; hint usestext-muted-foreground.
Acceptance:
- No page contains its own
isPending ? … : isError ? …chain. - When a filter matches nothing, the "Clear filters" button appears and resets the URL search params.
- Rows do not flash to skeletons on refetch.
Files: src/shared/ui/data-table.tsx, src/shared/ui/table.tsx, new src/shared/ui/bulk-bar.tsx, plus page usage.
Behavior:
- Sort
- New optional props
sort?: { id: string; desc: boolean }andonSortChange?. Sorting is controlled from the URL search paramsort=fieldorsort=-field. Only columns withmeta.sortable: truecan be sorted. - The header becomes a
<button>inside<th aria-sort="ascending|descending|none">, with lucideArrowUp/ArrowDown/ChevronsUpDownatsize-3.5. - If the backend does not support sorting a list, sort client-side over the loaded pages and show a hint: "Sorted within loaded rows".
- New optional props
- Sticky header:
THeadgetssticky top-14 z-10 bg-card(header height is 56px =h-14). Change theTablewrapper tooverflow-x-autowithoverflow-y: clipso sticky still works. - Selection
- Opt-in prop
selectableadds a first column with a Checkbox (aria-label="Select row @{name}"). The header checkbox is tri-state (indeterminate). - Selection is keyed by
getRowIdand resets when filters change. - Shift+click selects a range.
- Opt-in prop
- Bulk bar: when
selected > 0, render arole="region" aria-label="Bulk actions"bar.- Classes:
sticky bottom-4 mx-auto flex w-fit items-center gap-2 rounded-lg border bg-card px-3 py-2 shadow-lg. - Content: "{n} selected", the actions, and "Clear" (
Escalso clears).
- Classes:
- Initial bulk actions
- Comments: Delete.
- Reports: Resolve and Dismiss.
- Posts: Delete, only for rows the operator may delete. Filter by the same rule as
PostsPage.tsx:79. - Run them as
Promise.allSettledover the existing single mutations, with at most 4 in parallel. - Always use
ConfirmDialogand state the count: "Delete 12 comments?". - Finish with one toast: success "12 deleted", or mixed "10 deleted, 2 failed" plus a "Show failed" action that re-selects only the failed rows.
- Row hover stays as is. Add
data-[state=selected]:bg-primary/5onTR.
Acceptance:
- Screen readers announce
aria-sort. - Selecting 3 rows and deleting runs one confirm and produces one summary toast.
- The header stays visible while scrolling 50+ rows.
- Keyboard: Space toggles the focused row checkbox.
Files: src/shared/ui/dialog.tsx, ReportsPage.tsx (DecideDialog), SanctionDialog.tsx, SessionsPage.tsx, RolesPage.tsx, TaxonomyPages.tsx, BansPage.tsx.
Behavior:
ConfirmDialog- Add
variant?: "destructive" | "default". - While pending, the confirm button shows
Loader2Icon className="animate-spin"and its label. Also setaria-busy, and block Esc and overlay close (onEscapeKeyDown/onPointerDownOutside→preventDefaultwhen pending). - Close only on success. On error, keep the dialog open and show the message inline (
role="alert" text-sm text-destructive) as well as the toast.
- Add
- Every irreversible action goes through
ConfirmDialog: delete, ban, revoke session, delete role or taxonomy, and resolve with remove-content. InDecideDialog, the button is already destructive-styled whenremoveis checked; it also needs the pending spinner and the Esc lock. - The confirm label repeats the verb and object ("Delete post"), not just "OK".
- High-risk actions (delete role, ban permanently) require typing the name to enable confirm. Use a
confirmText?: stringprop.
Acceptance:
- Grep shows no destructive mutation called without a confirm.
- Double-clicking confirm sends one request.
- Esc does nothing while a request is in flight.
Files: src/styles.css, button.tsx, AdminLayout.tsx, table.tsx, __root.tsx/route files.
Behavior:
- Contrast: dark-mode
destructiveisoklch(0.65 0.2 25)withtext-white(button.tsx:12), which likely fails 4.5:1. Add a--destructive-foregroundtoken: lightoklch(0.99 0 0), darkoklch(0.16 0.01 260). Usetext-destructive-foregroundand check the contrast with a contrast tool. Checkmuted-foregroundonmutedandsidebarbackgrounds in both themes as well. - Target size (2.5.8):
size="icon"is 36px, which passes.smis 32px, which passes. Keep 24px as the minimum, and do not introduce smaller icon buttons. - Focus not obscured (2.4.11): add
scroll-padding-top: 4remtohtmlso the sticky header (and sticky thead) never hides the focused row. - Focus on route change: after navigation, move focus to
<h1>(givePageHeaderh1tabIndex={-1}, focus it in an effect keyed on pathname). Setdocument.title = "{page} · {app.name}"via ausePageTitle(title)hook called byPageHeader. - Status chips: Reports filter "tabs" use
aria-pressed, which is fine. Add the count insr-onlyonce item 9 adds counts. - Tables: the actions column already has an
sr-onlyheader, which is good. Make each row's main link the accessible name, as it already is.
Acceptance:
- axe (vitest +
@axe-core/reactorvitest-axe) shows zero serious or critical issues on every route in both themes. - Tab after navigation starts at the page h1.
- The tab title changes per route.
Files: new src/app/command/CommandPalette.tsx. Mount it in AdminLayout.tsx. Add a "Search… ⌘K" trigger button in the header.
Dependency: bun add cmdk (latest). It is Radix-Dialog based, about 5 KB, and accessible. Rejected alternative: hand-rolling a listbox, which costs more effort and a11y risk.
Behavior:
- Sources
- Navigation:
visibleNavigation(canAny), which respects permissions. - Actions: "New post" (when
post.create), "Toggle theme", "Switch language → en/ru/uz", "Log out". - Remote: debounced (250ms) search across posts and users using the existing hooks/repositories, with up to 5 results each. Only run when the query is at least 2 characters.
- Navigation:
- Groups carry headings. Each item shows its icon and, where one exists, its shortcut hint (
kbdclass:rounded border px-1.5 font-mono text-[11px] text-muted-foreground). - Recent: the last 5 visited routes, stored in
localStorage["admin.recent"]. - Opens on
Mod+Kand on/when focus is not in an input.
Acceptance:
Ctrl+K, type "rep", Enter goes to/reports.- Items the admin lacks permission for never appear.
- Fully operable with arrows, Enter, and Esc.
- All labels are translated in en/ru/uz.
Files: new src/shared/lib/useHotkeys.ts (tiny; no dependency), AdminLayout.tsx, list pages.
Behavior:
- Global
g d/p/r/c/u/b/a: go to Dashboard, Posts, Reports, Comments, Users, Bans, Audit. This is a two-key sequence with a 1s timeout.?: open the shortcuts dialog.Mod+K: open the palette.
- List pages
/: focus the search input.n: new item where allowed.j/k: move the row focus ring.x: toggle selection.Enter: open the row.Esc: clear selection.
- Disable every shortcut while focus is in
input,textarea,select, or[contenteditable], or while a dialog is open. - The shortcuts dialog is generated from one registry, so the docs always match the code.
Acceptance:
- Typing "g" in a search box does nothing.
- On
/reports,j j xselects the second row. ?lists every binding.
Files: AdminLayout.tsx, new src/app/layout/useSidebar.ts.
Behavior:
- Collapse state: toggle with the
PanelLeftIconbutton in the sidebar footer, orMod+B. Persist inlocalStorage["admin.sidebar"].- Expanded:
w-60, contentlg:pl-60. - Collapsed:
w-14, contentlg:pl-14. - Animate with
transition-[width,padding] duration-200.
- Expanded:
- Collapsed sidebar:
- Labels become
sr-only. - Each item gets a Radix Tooltip (
side="right") carrying its label. - Group labels become a 1px divider (
mx-3 border-t).
- Labels become
- Sidebar footer (both states): user chip (avatar initial plus username) linked to Sessions. Logout moves into a user dropdown, which frees header space.
- Active link: keep
data-[status=active]styles and addaria-current="page"(TanStack sets it; verify in a test). - Mobile drawer: unchanged, but close it on route change via router subscription instead of
onClickonly, which covers palette navigation too.
Acceptance:
- The collapsed state survives a reload.
- Every icon in collapsed mode has an accessible name.
- No layout shift of
mainother than the width transition.
Files: PostsPage.tsx, UsersPage.tsx, CommentsPage.tsx, AuditPage.tsx, BansPage.tsx, new src/shared/ui/filter-chips.tsx.
Behavior:
- Every filter lives in the route search, as Posts, Users, and Reports already do, so links are shareable and Back works.
- Below the toolbar, show one active chip per filter (
Badge variant="secondary"with anXIconremove buttonaria-label="Remove filter {name}") and a "Reset all" link. - Replace the ad-hoc tag row (
PostsPage.tsx:193-200, which wrongly says "Close") with a chip. - Disabled filters (
ownsource,PostsPage.tsx:44) show a helpertext-xs text-muted-foregroundexplaining why. - Add search to Comments (by text or author) if the API supports it. Otherwise this is an open question.
- Search inputs: put a clear button in the input (type=search already gives this in some browsers; make it explicit) and show the
/kbd hint on the right.
Acceptance:
- Reloading a filtered URL restores the exact view.
- Each chip removes exactly one filter.
- Browser Back undoes the last filter change.
Files: nav.ts (add an optional badge?: "reports.open"), AdminLayout.tsx, ReportsPage.tsx, the moderation hooks.
Behavior:
- Show the open-report count as a pill on the "Reports" nav item (
ml-auto rounded-full bg-destructive/10 px-1.5 text-xs tabular-nums text-destructive) and on the "Open" tab. - Source:
meta.totalofuseReports({status:"open"})page 1, withstaleTime60s and refetch on window focus. - Hide the pill when the count is 0, and show
99+above 99.
Acceptance:
- Resolving a report decrements the count after the mutation invalidates the query.
- The nav pill text is part of the link's accessible name ("Reports, 5 open").
Files: src/styles.css, card.tsx, table.tsx, states.tsx.
Behavior:
- Add to
@theme:--radius: 0.5rem, plus--radius-sm/md/lg/xlderived from it.--color-success,--color-warning, and their-foregroundpairs, in both themes.--color-destructive-foreground.
- Replace
amber-*inOfflineBannerand thesuccess/warningbadge variants with these tokens. - Type scale:
- Page h1:
text-2xl font-semibold tracking-tight, as now. - Section h2:
text-base font-semibold. - Table:
text-sm. - Meta text:
text-xs text-muted-foreground. - Numbers: always
tabular-nums.
- Page h1:
- Spacing rhythm:
- Page:
p-4 sm:p-6. - Card toolbar:
p-3 sm:p-4,gap-3. - Between page sections:
gap-6. - Document this in a comment block at the top of
styles.css.
- Page:
Acceptance: grep finds no raw palette colours (amber-|green-|red-) in src/.
Files: data-table.tsx, table.tsx.
Behavior:
- A "Comfortable/Compact" toggle in the table toolbar sets
data-densityon the table, persisted in localStorage.- Compact:
TD py-1.5,TH h-8. - Comfortable: as now.
- Compact:
- Long text cells use
truncatewithtitleset to the full value. - Dates show relative time ("3 h ago"), with the absolute time in
titleand<time dateTime>. Add this tosrc/shared/lib/format.tsviaIntl.RelativeTimeFormat(localized for free in en/ru/uz).
Acceptance: compact mode shows at least 30% more rows per viewport at 1080p.
Files: providers.tsx and all toast.* call sites.
Behavior:
- Position:
bottom-right, so toasts do not cover the header toolbar. - Duration: 4s for success; errors stay until dismissed (
duration: Infinity). - Content: success toasts name the object ("Post “X” deleted"). Error toasts include the problem
detailfromerrorMessage, plus a "Retry" action where the mutation is idempotent. - Undo: where the backend allows reversal (unban, restore?), add an "Undo" action. This is an open question; see below.
- Never show both a toast and an inline error for the same validation failure in forms. Forms use inline errors only.
Acceptance: an error toast persists until closed, and success toasts include the item name.
Files: src/contexts/content/ui/PostEditorPage.tsx.
Behavior:
- Track the dirty state. Wire TanStack Router
useBlocker({ shouldBlockFn: () => dirty, withResolver: true })toConfirmDialogwith the text "Discard changes?", and addbeforeunload. Mod+Ssaves;Mod+Entersaves and publishes, if that is allowed.
Acceptance: navigating away from a dirty editor prompts, and a clean editor does not.
Saved views, column visibility and reordering, virtualised tables (list sizes are paginated; not needed yet), and dashboard redesign.
- Backend list sorting and search: which list endpoints accept
sort=andq=(comments, audit, bans)? Owner: Backend lead, before item 2 and item 8 start. Until then, item 2 uses the client-side fallback. - Bulk endpoints: none were found in the frontend repositories. Item 2 loops over single calls. If any list routinely needs more than 50-row bulk operations, ask the backend for batch endpoints.
- Undo: which actions are reversible server-side (unban, restore post)? Owner: Backend lead. This only affects item 12.
- Contrast values in item 4: these are inferred from the OKLCH lightness values, not measured. The Design QA Engineer must measure them before item 4 is closed.