RFC 0085 — Vertical section navigation for overlay-shell plugins (NavRail)
Status: Partially implemented — Account's half fully shipped across tasks 14.5 and 14.6; Console's half superseded by workstream 0022's different design — see update below
Date: August 2026
Author: Claude Code + kasunben
Scope: packages/ui (new component, Dialog sizing, Storybook), plugins/console, plugins/account, docs/design-system.md, docs/plugin-development.md. Related to RFC 0076 (new-primitives precedent) and RFC 0001 (original overlay shell design).
Incorporated into plan: Partially — Console's half (originally epic task 9.22 in the Design System epic) was rejected as scoped; see below. Account's half is scoped as epic task 14.5 (desktop rail) and epic task 14.6 (mobile drill-down nav) in the Plugin — Accounts epic.
Update: Written independently of, and in parallel with, workstream 0022, which resolves the same underlying problem (Console's hand-rolled horizontal tab strip) for Console with a different, incompatible design:
shell: "overlay"→shell: "default"(a real full page, not a resized dialog) plusThreeColumnLayoutand a newNavListcomponent, including a mobile drill-down redesign this RFC had explicitly deferred as "future work, not a follow-up task yet." The two designs surfaced as a genuine merge conflict (workstream 0022 had already shipped code before this RFC's existence was discovered); the developer chose to keep workstream 0022's direction for Console. This RFC's Console-specific proposal is superseded and will not be built as written — epic task 9.22 is marked Rejected accordingly. TheNavRail-in-overlay pattern itself, and its application to Account (untouched by workstream 0022), are not rejected — they remain a live option, just no longer scoped as a single combined task with Console. A future task revisitingNavRailfor Account alone would need to re-derive its own scope from this RFC rather than reuse rejected task 9.22.
Update 2: Account's half re-scoped as epic task 14.5. Two changes from this RFC's original design, decided during that re-scoping: Account stays
shell: "overlay"rather than following Console'sshell: "default"conversion — Console's composition depends on a full-page height-unlock hook (data-plugin-fullbleed) that doesn't reach inside aDialog. And no newNavRailcomponent is built —@sovereignfs/ui's existingNavList(shipped for Console in workstream 0022 leg 1) has no such height dependency and drops into Account'sDialogdirectly, so this RFC's originally-proposed component work is no longer needed for Account either. The originally-planned"lg" → "md"resize itself did not ship as scoped, in three further rounds during implementation: task 14.5 first keptoverlaySize: "lg"unchanged (md's content-driven height would have made the dialog resize between short and long sections, whichlg's fixed box was specifically chosen to avoid), then, oncelgitself read as oversized for a settings surface, switched to@sovereignfs/ui's existingautosize instead ofmd— content-driven on both axes, already used by runtime-direct<Dialog>callers, extended to the manifestoverlaySizeenum for the first time — and finally, onceauto's own content-driven sizing turned out to visibly shrink to fit a near-empty intermediate page during Account's/account→/account/profileredirect before growing once real content landed, switched again to a newDialogSize,fixed— a true fixed box likelg(content never resizes it) but capped at64rem × 44reminstead of filling the viewport. See task 14.5's own Status note for the full three-round account.
Update 3: This RFC's own Alternatives/Open-questions entries below ("Ship the full mobile drill-down... Noted as explicit future work so it isn't lost" / "Full mobile drill-down list. Explicitly future work... not scheduled by this RFC") are picked up by epic task 14.6, scoped as a follow-up to 14.5 rather than folded into it. Account's mobile nav now matches the drill-down pattern Console itself shipped for its own mobile nav (task 13.17, workstream 0022): a full-screen index of tappable section rows, selecting one navigates into that section, which shows a
‹ Accountback-link instead of any persistent nav. See task 14.6's own Status note for the full account, including a real correctness risk found and resolved during planning (a redirect-vs-viewport race, same bug class as task 14.5's own rounds 3–4).
Summary
Console and Account currently render their section navigation ("Users", "Plugins", "Settings"... / "Profile", "Security", "Data"...) as a horizontal underline tab strip at the top of a full-screen (overlaySize: "lg") dialog. This RFC proposes switching both to a vertical rail navigation pattern — similar to Claude's own desktop settings modal — backed by a new packages/ui primitive (NavRail), and shrinking the dialog both plugins render in from lg (full-screen) to a resized md (a fixed, landscape- oriented box) so the rail-plus-content layout doesn't sprawl across the whole viewport. Mobile keeps its current behavior unchanged in this pass.
Motivation
Console has 11 sections and Account has 7; a horizontal strip at this count already wraps or scrolls sideways, and a full-screen dialog for what is functionally a settings panel reads as heavier than the content warrants. A vertical rail scales better to a longer, flatter list of sections and matches a well-established pattern for exactly this kind of surface (OS preference panes, Claude's own settings modal, GitHub repo settings).
Separately, today's implementation is duplicated: Console and Account each hand-roll their own horizontal nav CSS rather than sharing a component, which is exactly the kind of drift docs/design-system.md's "DS-first: plugins are consumers" principle exists to prevent. This RFC's component work retires that duplication in the same pass as the visual change.
Current state (what this builds on)
- Overlay sizing already has three fixed-box sizes.
Dialog(packages/ui/src/components/Dialog/Dialog.tsx:59) exposesDialogSize = 'sm' | 'md' | 'lg' | 'full'; the manifest schema (packages/manifest/src/schema.ts:166) already validatesoverlaySize: z.enum(['sm', 'md', 'lg']).mdis defined inDialog.module.css:108-113as a fixed36rem × 42rem(576×672px, portrait) box, but no shipped plugin uses it — only the referenceexample-plugins/example-overlay-medium. Console and Account are both pinned tooverlaySize: "lg"(plugins/console/manifest.json,plugins/account/manifest.json). - Every size collapses to full-screen on mobile regardless of value (
Dialog.module.css:163-198,@media (max-width: 768px)), so resizingmdis a desktop-only concern. - Three separate tab implementations already exist, none vertical:
packages/ui'sTabs(components/Tabs/Tabs.tsx, controlledvalue/onChange, ARIAtablist/tab, horizontal underline) andNavTabs(components/NavTabs/NavTabs.tsx, link-basedhref/active, horizontal underline) are both unused by Console/Account. Console (runtime/app/(platform)/(plugins)/console/layout.tsx:16-44,console.module.css:20-51, classes.nav/.navLink) and Account (plugins/account/app/layout.tsx:8-32,account.module.css, classes.tabs/.tab) each hand-roll a third, near-identical copy locally. - Mobile tab-strip placement is a distinct, load-bearing mechanism.
useOverlaySecondRow(Dialog.tsx:49-57) lets a plugin layout hand its nav strip up to the enclosingDialog's mobileOverlayHeader, which renders it as a second header row (OverlayHeader.tsx:59) — solving the "double header" problem where a plugin's own sticky strip would otherwise render a second time inside the scrolling content area. Both Console and Account layouts call this today; a vertical rail does not have a natural "second row" equivalent, so this RFC deliberately leaves mobile using the same mechanism unchanged (see Alternatives / Open questions). packages/uiis a published, versioned public contract. Currently0.47.0(packages/ui/package.json:3). Per NFR-04, breaking changes need at minimum a minor bump and a migration note indocs/upgrade.md, regardless of whether any shipped plugin currently consumes the value being changed.- No existing rail/sidebar DS primitive. Plugin-local sidebars exist (Shopper's
Sidebar.tsx, Tasks'ListSidebar.tsx, Tally'sGroupSidebar.tsx) but are all data-list UIs specific to their plugin, not a generic vertical section-nav primitive.
Proposed design
A. New component — NavRail (packages/ui)
- Link-based like
NavTabs(items: { label, href, active, icon? }[]), not controlled-state likeTabs— Console and Account are real routes (hard/soft navigation), not client-side panel switches. - Vertical stack layout, own column width, left-edge active indicator in place of
NavTabs's bottom-underline. iconis an optional per-item slot (not required) — additive capacity matching the reference screenshot's aesthetic, but Console/Account don't need to adopt icons in this same change.- Follows existing
packages/uiconventions: CSS Modules, semantic tokens only, no framework-specific link element baked in (consumes<Link>the same wayNavTabs/Breadcrumbdo per Next.js convention). - Storybook: new story under
packages/ui/src/stories/, gallery entry inDesignSystemOverview.stories.tsx's Component Gallery section, verified withpnpm --filter @sovereignfs/ui typecheckper the Storybook hygiene rule inCLAUDE.md. - Explicitly out of scope for this RFC: grouped/sectioned rail items (category headers like Claude's "General"/"Visuals"/"Desktop app"). Console and Account's section lists are flat today; forcing a grouping API before either plugin has grouped data would be speculative. See Open questions.
B. Redefine DialogSize.md
Change Dialog.module.css's .md from 36rem × 42rem (portrait) to a landscape box sized for a rail-plus-content layout — starting point proposal, to refine visually during implementation: 60rem × 40rem (960×640px). Desktop-only change (mobile already ignores size, see above).
This is a visible change to a public design-system value, so per NFR-04 it ships as a minor version bump (0.47.0 → 0.48.0) with a migration note in docs/upgrade.md, even though no shipped plugin currently sets overlaySize: "md" — the discipline applies to the contract, not current uptake.
C. Migrate Console and Account
plugins/console/manifest.jsonandplugins/account/manifest.json:shellConfig.overlaySize"lg" → "md".- Delete the hand-rolled
.nav/.navLink(console.module.css:20-51) and.tabs/.tab(account.module.css) rules; replace both layouts' nav strips withNavRail. - Mobile: no behavior change in this pass. Both layouts keep calling
useOverlaySecondRowwith a horizontal nav strip exactly as today. A future full drill-down list (section links as a plain vertical list filling the mobile screen, closer to Claude Mobile's settings pattern) is explicitly deferred — see Alternatives.
UI flows
Desktop, inside the md dialog (soft-navigated overlay):
┌─────────────────────────────────────────────────┐
│ ┌──────────┐ ┌───────────────────────────────┐ │
│ │ Overview │ │ │ │
│ │ Users │ │ (section content) │ │
│ │ Groups │ │ │ │
│ │ Plugins │ │ │ │
│ │ Settings │ │ │ │
│ │ ... │ │ │ │
│ └──────────┘ └───────────────────────────────┘ │
└─────────────────────────────────────────────────┘Mobile (unchanged): full-screen sheet, OverlayHeader title row + horizontal scrollable section strip as the second row, content below.
Alternatives considered
Add an orientation prop to the existing NavTabs instead of a new component. Rejected — the two aren't just a CSS flip: active-indicator side, container sizing (rail has an intrinsic column width; NavTabs is full-bleed), and icon-slot support diverge enough that packages/ui already treats Tabs and NavTabs as separate components rather than one parameterized component. A third distinct component keeps that precedent rather than breaking it with a conditional internal branch.
Redefine md vs. add a new size token. Considered adding a new size alongside md to avoid touching a published value at all. Rejected for this RFC: md is unused by any shipped plugin today, so redefining it costs nothing in practice while adding a new token would leave md permanently occupying a value nothing was ever built for. Flagged here rather than silently decided — see Open questions for the exact target dimensions.
Ship the full mobile drill-down (Claude Mobile-style list) now. Rejected for this RFC's scope — it's new mobile UX, not a reuse of an existing mechanism, and would meaningfully grow this change's surface. Noted as explicit future work so it isn't lost. Picked up by epic task 14.6 — see Update 3 above.
Open questions
- Desktop title placement. Today's
<h1>Console</h1>/<h1>Account</h1>sits above the horizontal nav (console.module.css.title,account.module.cssequivalent). Claude's reference modal has no equivalent page title — the rail starts near the top. Does the title move into a rail header, get dropped on desktop, or stay as its own row above the rail+content pane? Needs a decision before implementation, not specified by this RFC. - Standalone hard-navigation route treatment. Visiting
/consoleor/accountdirectly (noDialogancestor,useOverlaySecondRowis a no-op) currently renders the same horizontal header at full width. Does this route also switch to the vertical rail on desktop for consistency, or keep its current horizontal header? Not decided here. - Grouped rail sections. Deferred, per Proposed design §A — flagged as possible future
NavRailAPI growth (e.g. an optionalgroupsshape) once Console or Account's list actually needs grouping. - Exact
mdpixel dimensions.60rem × 40remis a starting proposal, not a committed value — refine visually once the rail component exists and Console's real content (widest case: the users table) is laid out inside it. - Full mobile drill-down list. Explicitly future work (see Alternatives) — not scheduled by this RFC, but the intent is recorded so it isn't forgotten. Picked up by epic task 14.6 — see Update 3 above.
Adoption path
Documentation-first; no ROADMAP.md scheduling commitment yet, matching the pattern of RFCs 0076–0078. Tentatively a single epic task (9.22, Design System epic) covering all three parts (NavRail component, md resize, Console/Account migration) as one branch/PR, since the migration is meaningless without the other two and the pieces are small enough not to need independent review gates.
Semver:
@sovereignfs/ui: minor bump (0.47.0 → 0.48.0) underfeat/, with a migration note indocs/upgrade.mdfor themdsize change per NFR-04.plugins/consoleandplugins/account: version only their ownmanifest.json(neverpackage.json, which stays pinned at0.0.0) — bump each manifest'sversionper the visual/behavioral change (currently0.1.0and0.1.1respectively).- Platform root
package.json: minor bump at implementation time, per the standard "each completed task bumps the minor version" convention.
Changelog
| Version | Date | Change |
|---|---|---|
| 0.1 | August 2026 | Initial draft |