Sidebar / nav rail
dizyx · nockerl-design
Streaming · 2 tools running
nockerl-design · docs site
Designing the sidebar to the token-reactive standard.
Expanded · viewing nockerl-design · docs site. Tab in, arrow-key the rows, expand Tasks, collapse to the rail. The sidebar stays in the layout. The island is live.
// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,// not yet exported from @dizyx/nockerl-react; promotion is tracked.)
export function Shell() { const [section, setSection] = useState('chat'); const [collapsed, setCollapsed] = useState(false); return ( <div className="layout"> <Sidebar collapsed={collapsed} // expanded ↔ icon-only rail onToggleCollapse={() => setCollapsed((c) => !c)} selected={section} onSelect={setSection} sections={[ { label: 'Workspace', items: [ { id: 'home', label: 'Home', icon: <HomeIcon /> }, { id: 'chat', label: 'Chat', icon: <ChatIcon />, dot: 'streaming' }, { id: 'tasks', label: 'Tasks', icon: <TasksIcon />, badge: { count: 3, tone: 'attention' }, children: [{ id: 'tasks-mine', label: 'Assigned to me' }, { id: 'tasks-review', label: 'In review' }] }, { id: 'files', label: 'Files', icon: <FilesIcon />, badge: { count: 12 } }, ] }, ]} footer={<ProfileMenu name="Ada Lovelace" onSettings={openSettings} />} /> <main>{/* the content region; the sidebar stays beside it, never over it */}</main> </div> );}// Android (Jetpack Compose). There is NO side rail on Android: the persistent nav// lives as TOP CHROME inside com.nockerl.app.core.ui.MainScaffold (project + session// rows that collapse under the title bar), not a vertical sidebar. The honest// equivalent of the sidebar's section + selected-row vocabulary is that chrome:import com.nockerl.app.core.ui.ProjectRowimport com.nockerl.app.core.ui.TopChromeBoundaryimport com.nockerl.app.chat.ui.SessionChipsBar
// Inside MainScaffold's stacked top chrome:ProjectRow( projectName = state.projectName, isWorkspaceScoped = state.isWorkspaceScoped, onOpenPicker = viewModel::openProjectPicker, // the "sections" entry point onOpenFiles = viewModel::openFiles, onOpenTasks = viewModel::openTasks,)SessionChipsBar(/* horizontally-scrollable destinations; selected = cyan chip */)TopChromeBoundary( // 1.5dp cyan line + grab handle collapsed = headerCollapsed, onCollapsedChange = { headerCollapsed = it }, // collapse, but UP, not to a rail)// A vertical NavigationRail / PermanentNavigationDrawer is not used today; see drift.// macOS SwiftUI. The ROW is now the packaged `NockerlNavRow` (, to the// NockerlNavItem canon; Voice's hand-rolled SidebarRow retires on adoption);// the sidebar SHELL (fixed 216pt translucent panel, brand top, Settings cog// pinned bottom) stays app-side until the Swift nav-surface ships.import NockerlDesign
private let sidebarWidth: CGFloat = 216
private var sidebar: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 9) { NockerlLogo(size: 19); Text("Nockerl Voice") } .padding(.horizontal, 16).padding(.top, 50).padding(.bottom, 16)
ForEach(DashboardSection.navItems) { section in NockerlNavRow(section.title, selected: router.section == section) { router.section = section } icon: { Image(systemName: section.icon) } } Spacer(minLength: 0) NockerlNavRow("Settings", selected: router.section == .settings) { router.section = .settings } icon: { Image(systemName: "gearshape") } .padding(.bottom, 14) // the footer cog, pinned } .padding(.horizontal, 8) .frame(width: sidebarWidth) .background(NockerlTheme.canvas.opacity(0.3)) // translucent: facet field reads through .overlay(alignment: .trailing) { Rectangle().fill(NockerlTheme.hairline).frame(width: 1) }}Parameters
Section titled “Parameters”| Prop | Type | Default | Description |
|---|---|---|---|
sections * | NavSection[] | Grouped nav: { label, items }. Each item is { id, label, icon, badge?, dot?, children? } and renders as one focusable button. | |
selected * | string | Active destination id (a leaf, meaning a child id when a sub-item is active). Drives the cyan indicator + which content shows. | |
onSelect * | (id: string) => void | Fired when a (leaf) item is activated: click / Enter / Space. | |
collapsed | boolean | false | Collapses the sidebar to the icon-only rail; labels + counts hide and each item exposes its label via tooltip / aria-label. |
onToggleCollapse | () => void | Toggles collapsed from the rail button. | |
badge | { count: number; tone?: 'neutral' | 'attention' } | Per-item trailing count. neutral = mono chip; attention = warm count (status signal). Right-aligned; collapses to a corner dot on the rail. | |
children | NavSubItem[] | Nested sub-items on an item, which turn its row into an expandable disclosure (aria-expanded), indented under the parent. | |
footer | ReactNode | Pinned to the bottom: profile (avatar + name) + a settings cog (its own focusable target). |
There is no Android sidebar component. The persistent nav is top chrome composed
inside MainScaffold; these are the real load-bearing parameters of that chrome (the
honest equivalent of the sidebar’s sections + selected-row + collapse).
| Parameter | Type | Default | Description |
|---|---|---|---|
ProjectRow.projectName * | String | The active project, the chrome counterpart of the sidebar brand / section header. | |
ProjectRow.onOpenPicker * | () -> Unit | Opens the project picker sheet, the entry point to switch the nav scope. | |
ProjectRow.onOpenFiles / onOpenTasks * | () -> Unit | Destination buttons (Files / Tasks) → their bottom sheets (the nav items). | |
SessionChipsBar | @Composable | Horizontally-scrollable session destinations; the selected chip reads cyan (the selected-row equivalent). | |
TopChromeBoundary.collapsed * | Boolean | Whether the project + session rows collapse UP under the title bar, not into a vertical rail. | |
TopChromeBoundary.onCollapsedChange * | (Boolean) -> Unit | Drag / tap the 1.5dp cyan boundary handle to collapse / expand the chrome. |
The ROW ships as NockerlNavRow. The sidebar SHELL stays app-side (a
fixed translucent panel reading the app’s router). Canon deltas vs Voice’s retired
SidebarRow: hover wash reads onChrome @ 6% (not onSurface), resting ink is
onChromeMuted at weight medium (hover raises it), the selected wash is the
accentPrimarySoft token (~16%, theme-governed), rows gain the 0.985 press
scale and the 22% selected-hover deepen, and the row metrics are the canon’s
(min-height 40, padding 12/8/8, radius control).
| Parameter | Type | Default | Description |
|---|---|---|---|
label * | String | The destination name, also the row’s accessible name. | |
selected | Bool | false | Active destination (law §6): accentPrimarySoft wash + accent @0.45 thin border + accent ink + semibold; the icon stays (never a check swap), never a left rail. |
action * | () -> Void | Whole-row tap: the row is ONE button with one accessible name. | |
icon | @ViewBuilder | Leading glyph slot (an SF Symbol Image or any view) in the canonical 20pt slot, tinted with the row’s ink. Omit for a label-only row (no phantom slot). | |
focus (macOS) | behavior | Rows leave the focus chain (focusable(false) + focusEffectDisabled, 14+), the ratified stance for mouse-driven sidebars; other platforms keep native behavior. |