Skip to content

2026-08-01 — XP-147 sidebar nav: 33 destinations from a typed model, permission-scoped

Merge 5665841. apps/web/src/components/admin/nav-sections.ts is now the single nav model — 7 sections, 33 items, each with id/label/to/icon/perm — plus two pure functions, visibleNavSections() (drops items the operator can’t reach, and drops a section that ends up empty) and activeNavItemId() (prefix-safe deepest match, so /admin/tutors-archive never lights up Tutor). admin.tsx renders that model and lost ~290 lines of hand-written JSX. Seven destinations that previously had no sidebar entry are now reachable (golongan, tahun ajaran, kebijakan pembatalan, rekonsiliasi and friends). The rail also gained three honest states: a labelled skeleton while /me is in flight, an inline role="alert" error with the trace id and a Coba lagi retry, and the loaded sections. 11 unit tests on the two pure functions, 9 e2e/visual tests, and a new helpbook page (helpbook/menu-navigasi.md).

perm is typed Permission from @packages/auth/permissions, not string: an untyped typo fails open-to-hidden — the destination silently vanishes for every non-superuser and nothing ever flags it. The /admin loader prefetches /me but is client-guarded (typeof window === "undefined" early return) and non-throwing: this app’s queryClient is a module-scope singleton, so an SSR write of a per-user permission map inside a warm Worker isolate could be served to the next admin, and a throwing loader would hand the failure to the root error boundary where the sidebar’s own inline error can never render. Closing the remaining hard-reload gap needs beforeLoad session resolution

  • a per-request QueryClient + an SSR query bridge — an auth-shaped, app-wide change, left as a follow-up rather than smuggled into a nav ticket.

TanStack’s <Link> emits aria-current="page" on every ancestor match by itself: on /admin/sessions/queue the brand, Sesi and the queue item were all announced as the current page — the pre-fix rail was already lying to a screen reader, in the opposite direction from the finding that started the fix. activeOptions={{ exact: true }} on every link leaves the deepest-match model as the only current-page signal. Grepping source for aria-current proves nothing when the component emits it at runtime. Two open items for the human at the gate: the item height is the shadcn primitive’s 36px while the design spec asks for both 36px height and a ≥40px target (self-contradictory), and the brand link still carries admin-nav__item__dashboard, a testid inside the item namespace.

Updated .claude/rules/tanstack-best-practices.md (new nav/active-state section; the loader-prefetch pattern now warns that context.queryClient does not exist here and requires the client guard + non-throwing catch), .claude/rules/frontend-conventions.md (aria-current as state, stable testids, shell-furniture namespace, typed nav tables), .claude/rules/frontend-visual-test.md (scrollable columns need a bottom-of-scroll shot; screenshot a mobile drawer only after its transition settles) and the fresh-worktree-bootstrap skill (a pre-existing .dev.vars may be stale). No locked doc (plans/ARCHITECTURE.md, plans/scope/*.md) changed.