2026-08-01 — XP-147 sidebar nav: 33 destinations from a typed model, permission-scoped
What shipped
Section titled “What shipped”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).
Key decisions
Section titled “Key decisions”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.
Gotchas / lessons
Section titled “Gotchas / lessons”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.
Reference changes
Section titled “Reference changes”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.