@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,951 @@
1
+ /**
2
+ * SideNav, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the SideNav
5
+ * implementation; the standalone demo (sidenav.html) and the Storybook
6
+ * stories both consume this.
7
+ *
8
+ * EXEMPT FROM FIGMA OVERRIDES (2026-09-07). Token sync and the component
9
+ * pipeline pull from Figma normally for everything else, but this component is
10
+ * NOT re-synced unless the user explicitly asks for it in that session. A
11
+ * reconciliation run will report drift here against Figma and that report is
12
+ * accurate; the correct action is still to leave this file alone, name the
13
+ * drift, and wait. Do not resolve it by fetching Figma. See CLAUDE.md 1.1.
14
+ *
15
+ * Spec: components/sidenav/sidenav-spec.md
16
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40004059-1375
17
+ * SideNav.Container, a COMPONENT_SET. The id cited here until
18
+ * 2026-10-07, 40003951-2927, does not exist in the file.
19
+ */
20
+
21
+ import React, { useState, useRef } from "react";
22
+ import { Scrollable } from "../scrollbar/scrollbar.jsx";
23
+ import ReactDOM from "react-dom";
24
+
25
+ /*
26
+ * ─── DESIGN TOKENS ───────────────────────────────────────────────────────────
27
+ *
28
+ * Every value below is a `var()` reference to a real custom property, never a
29
+ * resolved hex. This is deliberate and it is a behavioural fix, not a style
30
+ * preference:
31
+ *
32
+ * This module previously called `t("no fill (rest paints nothing)")`, which reads
33
+ * src/tokens/tokens.js and returns a LIGHT-MODE HEX at module load. That baked
34
+ * one theme into the bundle, so Midnight Mode could not work at all in the coded
35
+ * component no matter what the consumer did. `t()` also returns the token id
36
+ * unchanged when it cannot resolve a name, which emits invalid CSS that the
37
+ * browser silently drops: so a renamed token failed as a colour that quietly
38
+ * reverted to inherited, with no error anywhere.
39
+ *
40
+ * Referencing the custom property instead means the cascade resolves the value,
41
+ * so `[data-theme="midnight"]` just works, and a missing token is visible in
42
+ * devtools as an unresolved var rather than disappearing.
43
+ *
44
+ * WHAT THE CONSUMER MUST LOAD (see sidenav-spec.md §Consuming):
45
+ * themes/light.css + themes/midnight.css → every --semantic-color-* below
46
+ * layout.css → every --semantic-layout-units-* below
47
+ * type.css → the Semantic: Type scale (compose it)
48
+ *
49
+ * Token names verified against the Figma Variables panel on 2026-09-02.
50
+ */
51
+ const c = (name) => `var(--semantic-color-${name})`;
52
+ // Elevation is its own collection and already carries the whole shadow string,
53
+ // theme-resolved: light casts rgba(24,27,43,0.08), midnight casts
54
+ // rgba(0,1,19,0.70). A hand-written rgba(0,0,0,0.03) is invisible on a dark
55
+ // canvas, which is exactly how dark mode ended up looking flat.
56
+ const c2 = (name) => `var(${name})`;
57
+ const u = (name) => `var(--semantic-layout-units-${name})`;
58
+ // Component metrics live in the CONTEXTUAL collection, not the semantic one, so
59
+ // they need their own prefix. SideNav's two widths moved there on 2026-09-16:
60
+ // they were numeric literals with a "no token in Figma" comment, which was true
61
+ // until the tokens were created.
62
+ const x = (name) => `var(--contextual-layout-units-${name})`;
63
+
64
+ // Negate a length that may be either a number or a var() reference. Used for the
65
+ // scroll-region edge bleed, where `-value` on a string would give NaN.
66
+ const neg = (value) =>
67
+ typeof value === "number" ? -value : `calc(-1 * ${value})`;
68
+
69
+ export const T = {
70
+ fill: {
71
+ // Rest has NO fill. The Figma token was deleted so that resting genuinely
72
+ // paints nothing rather than painting the sheet colour over the sheet.
73
+ navBase: "transparent",
74
+ navHover: c("fill-action-selection-hover"),
75
+ navActive: c("fill-action-selection-selected"),
76
+ navTrail: c("fill-action-selection-trail"),
77
+ infoSubtle: c("stroke-static-neutral-base"), // container right border
78
+ },
79
+ surface: {
80
+ // Figma binds Fill/Surface/Elevated on SideNav.Container (node 40004059:1375).
81
+ // This was fill-static-neutral-mono, which is the same #ffffff in Light and
82
+ // therefore looked correct, but mono is the INVERSION ANCHOR: it is #ffffff
83
+ // in Midnight too. Elevated is #121523 there. So the nav stayed a white slab
84
+ // in dark mode while every surface around it went dark.
85
+ //
86
+ // It is also what a reader of this repo reports back as the nav's fill, which
87
+ // is how the drift surfaced: the value quoted did not match Figma.
88
+ navLight: c("fill-surface-elevated"), // the nav container itself
89
+ navElevated: c("fill-surface-elevated"), // PopoverMenu, per Figma's "Popover Menu"
90
+ },
91
+ // Label, icon and chevron all resolve to the SAME token per state. A nav item
92
+ // is one interactive surface, so its foreground must not split across two
93
+ // ramps that can drift apart.
94
+ text: {
95
+ navBase: c("foreground-action-secondary-rest"),
96
+ navHover: c("foreground-action-secondary-hover"),
97
+ navActive: c("foreground-action-secondary-pressed"),
98
+ navDisabled: c("foreground-action-disabled"),
99
+ secondary: c("foreground-static-neutral-base"), // PopoverMenu.SectionLabel
100
+ secondarySubtle: c("foreground-static-neutral-base"), // NavSectionLabel
101
+ },
102
+ icon: {
103
+ navBase: c("foreground-action-secondary-rest"),
104
+ navHover: c("foreground-action-secondary-hover"),
105
+ navActive: c("foreground-action-secondary-pressed"),
106
+ navDisabled: c("foreground-action-disabled"),
107
+ actionSecondary: c("foreground-action-secondary-rest"), // CollapseButton
108
+ },
109
+ // The active-item stripe takes the loud brand blue straight from the Action
110
+ // tier rather than owning a token of its own.
111
+ indicator: c("fill-action-primary-strong-pressed"),
112
+ radius: u("cornerradius-base"),
113
+ };
114
+
115
+ /*
116
+ * ─── LAYOUT ──────────────────────────────────────────────────────────────────
117
+ * Values that exist as semantic tokens reference them, with the Figma value as
118
+ * the var() fallback so the component still lays out correctly if a consumer
119
+ * loads only the colour themes. Values with no token in Figma stay numeric and
120
+ * say so.
121
+ */
122
+ export const L = {
123
+ // SideNav is chrome that does NOT change across breakpoints, so its own
124
+ // metrics live in Contextual: Layout & Units rather than Responsive: Layout.
125
+ // Values measured from SideNav.Container in Figma: expanded padding 16, the
126
+ // collapsed rail 12, vertical 8, item gap 6. These were on the generic
127
+ // semantic scale until 2026-09-17, which meant a change to Padding/Base
128
+ // would silently move the nav.
129
+ navPadH: x("sidenav-padding-horizontal-expanded"),
130
+ navColPadH: x("sidenav-padding-horizontal-collapsed"),
131
+ navPadTop: x("sidenav-padding-vertical"),
132
+ // Read from SideNav.Container in Figma on 2026-09-16: every expanded variant
133
+ // is 250 wide (Base, Stroked, Mobile.Base, Mobile.Stroked) and every
134
+ // collapsed one is 72. Was 240, which no longer matched the component.
135
+ navW: x("sidenav-width-expanded"),
136
+ navWcol: x("sidenav-width-collapsed"),
137
+ menuGap: x("sidenav-gap-vertical"),
138
+ menuPadT: u("padding-tight"),
139
+ menuPadB: u("padding-xxwide"),
140
+ itemH: u("accessibility-touch-target-aa-height"),
141
+ iconWrap: 24, // no token in Figma, leading icon wrapper
142
+ iconInner: 16, // Material Symbols Rounded frame size, per design-system-spec §7.2
143
+ rowPadH: u("padding-tight"),
144
+ // Collapsed rail: container.rowStart's padding-right in Figma. It is what
145
+ // keeps the centred icon a hair left of the square's true centre, the same as
146
+ // the design.
147
+ railIconInset: u("padding-xxtight"),
148
+ textPad: u("padding-xtight"),
149
+ childIndent: 24, // no token in Figma, level-1 left indent
150
+ stripeW: 4, // no token in Figma, indicator stripe width
151
+ colPadL: u("padding-medium"),
152
+ colPadR: u("padding-tight"),
153
+ collapseGap: u("padding-xxtight"),
154
+ };
155
+
156
+ // ─── IndicatorStripe ──────────────────────────────────────────────────────────
157
+ export function IndicatorStripe({ visible }) {
158
+ return (
159
+ <div style={{ width: L.stripeW, alignSelf: "stretch", display: "flex",
160
+ flexDirection: "column", alignItems: "flex-start",
161
+ justifyContent: "center", padding: "4px 0", flexShrink: 0 }}>
162
+ <div style={{ flex: "1 0 0", width: L.stripeW, minHeight: 1,
163
+ borderRadius: "0 8px 8px 0",
164
+ backgroundColor: visible ? T.indicator : "transparent",
165
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
166
+ </div>
167
+ );
168
+ }
169
+
170
+ // ─── SideNavItem ──────────────────────────────────────────────────────────────
171
+ export function SideNavItem({
172
+ item, isActive, isTrail, isExpanded, isSidebarCollapsed,
173
+ isChild, onClick, onToggle,
174
+ popoverOpen, anchorRect, onPopoverEnter, onPopoverLeave, activeId,
175
+ chevronUp, chevronDown, collapseIcon, expandIcon,
176
+ }) {
177
+ const [hovered, setHovered] = useState(false);
178
+ const hasChildren = !!(item.children && item.children.length);
179
+ const itemRef = useRef(null);
180
+
181
+ const isExpandedGrouper = hasChildren && isExpanded && !isSidebarCollapsed;
182
+ const isCollapsedActiveGrouper = isTrail && (!isExpanded || isSidebarCollapsed);
183
+ const isCollapsedGrouper = isSidebarCollapsed && hasChildren;
184
+ const showStripe = isCollapsedActiveGrouper || (isActive && !isCollapsedGrouper);
185
+
186
+ // A disabled item takes no fill and no hover, so it cannot look interactive.
187
+ const isDisabled = !!item.disabled;
188
+
189
+ const fillBg =
190
+ isDisabled ? T.fill.navBase
191
+ : (isActive || isCollapsedActiveGrouper) ? T.fill.navActive
192
+ : isExpandedGrouper ? T.fill.navTrail
193
+ : hovered ? T.fill.navHover
194
+ : T.fill.navBase;
195
+ const textColor =
196
+ isDisabled ? T.text.navDisabled
197
+ : (isActive || isCollapsedActiveGrouper || isExpandedGrouper) ? T.text.navActive
198
+ : hovered ? T.text.navHover
199
+ : T.text.navBase;
200
+ const iconColor =
201
+ isDisabled ? T.icon.navDisabled
202
+ : (isActive || isCollapsedActiveGrouper) ? T.icon.navActive
203
+ : hovered ? T.icon.navHover
204
+ : T.icon.navBase;
205
+
206
+ const handleClick = () => {
207
+ if (isDisabled) return;
208
+ if (hasChildren && !isSidebarCollapsed) onToggle(item.id);
209
+ else onClick(item.id);
210
+ };
211
+
212
+ return (
213
+ <div ref={itemRef} style={{ position: "relative" }}>
214
+ <div role="button" tabIndex={isDisabled ? -1 : 0}
215
+ aria-current={isActive ? "page" : undefined}
216
+ aria-disabled={isDisabled ? true : undefined}
217
+ aria-expanded={hasChildren && !isSidebarCollapsed ? isExpanded : undefined}
218
+ onClick={handleClick}
219
+ onKeyDown={e => !isDisabled && (e.key === "Enter" || e.key === " ") && handleClick()}
220
+ onMouseEnter={() => {
221
+ if (isDisabled) return;
222
+ setHovered(true);
223
+ if (isSidebarCollapsed && onPopoverEnter) {
224
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : null;
225
+ onPopoverEnter(item.id, rect);
226
+ }
227
+ }}
228
+ onMouseLeave={() => {
229
+ if (isDisabled) return;
230
+ setHovered(false);
231
+ if (isSidebarCollapsed && onPopoverLeave) onPopoverLeave();
232
+ }}
233
+ style={{ display: "flex", alignItems: "center", minHeight: L.itemH,
234
+ // COLLAPSED IS A SQUARE. Figma's SideNavItem.Collapsed is 44x44 at
235
+ // x=8 inside the 60px column (84px rail minus 12px padding each side),
236
+ // so the fill is inset 8px and reads as a rounded square. Stretching
237
+ // it to the full 60 made it a rectangle, which is what Jo spotted.
238
+ // 44 is L.itemH, so the square is expressed as width = height rather
239
+ // than as a second number that could drift from it.
240
+ width: isSidebarCollapsed ? L.itemH : "100%",
241
+ marginLeft: isSidebarCollapsed ? "auto" : undefined,
242
+ marginRight: isSidebarCollapsed ? "auto" : undefined,
243
+ // Figma's container.rowStart is 40 wide inside the 44 square with
244
+ // padding-right 6 and primary=CENTER, so the icon centres in the 34
245
+ // that is left after the 4px indicator column. See the icon wrap below.
246
+ paddingRight: isSidebarCollapsed ? L.railIconInset : undefined,
247
+ // border-box or the 6px padding is ADDED to the 44, which made the rail
248
+ // item 50 wide and no longer a square.
249
+ boxSizing: "border-box",
250
+ borderRadius: T.radius, backgroundColor: fillBg,
251
+ cursor: isDisabled ? "not-allowed" : "pointer",
252
+ overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)", userSelect: "none" }}>
253
+
254
+ <IndicatorStripe visible={showStripe} />
255
+
256
+ {/* Leading icon, Level 0 only */}
257
+ {!isChild && item.icon && (
258
+ <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
259
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
260
+ // COLLAPSED CENTRES, EXPANDED INDENTS. The fixed 12px left margin
261
+ // applied in both states, so on the rail the 24px icon sat at
262
+ // x 16..40 of the 44 square: 6px right of centre, in every state.
263
+ // Reported 2026-10-02. Auto margins hand the free space back to
264
+ // both sides, which with the 6px right padding above reproduces
265
+ // Figma's x=9 exactly.
266
+ marginLeft: isSidebarCollapsed ? "auto" : L.rowPadH,
267
+ marginRight: isSidebarCollapsed ? "auto" : undefined,
268
+ color: iconColor }}>
269
+ {typeof item.icon === "function"
270
+ ? item.icon({ size: L.iconInner, color: iconColor })
271
+ : typeof item.icon === "string"
272
+ ? <span className="material-symbols-rounded" aria-hidden="true"
273
+ style={{ fontSize: L.iconInner, lineHeight: 1, color: iconColor,
274
+ // display:block so the glyph is laid out as a box rather than
275
+ // on a text baseline: an inline glyph in a flex-centred
276
+ // parent is centred by its line box, not by its own art, so
277
+ // it rode high inside the 24px wrap.
278
+ display: "block",
279
+ // opsz MUST match fontSize. It said 20 against a 16px size,
280
+ // so the glyph was drawn on a 20px grid and scaled down into
281
+ // a 16px box, which shifts the art off centre.
282
+ fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' ${L.iconInner}` }}>
283
+ {item.icon}
284
+ </span>
285
+ : React.cloneElement(item.icon, { style: { color: iconColor } })}
286
+ </div>
287
+ )}
288
+
289
+ {/* Label */}
290
+ <div style={{
291
+ // Collapsed, the label must take NO part in the flex distribution.
292
+ // With flex:1 it still claimed the free space even though max-width
293
+ // clamped it to zero, so the icon's auto margins had nothing left to
294
+ // centre with and the icon stayed hard left.
295
+ flex: isSidebarCollapsed ? "0 0 0px" : 1,
296
+ minWidth: 0,
297
+ // Collapsed, the padding has to go too. max-width clamps the CONTENT
298
+ // box, so the label still occupied 8px of padding on the rail and ate
299
+ // most of the free space the icon's auto margins needed: the icon
300
+ // landed at x=5 instead of Figma's x=9.
301
+ paddingLeft: isSidebarCollapsed ? 0 : (isChild ? 32 : L.textPad),
302
+ paddingRight: isSidebarCollapsed ? 0 : ((!isChild && hasChildren) ? 0 : L.rowPadH),
303
+ maxWidth: isSidebarCollapsed ? 0 : 200,
304
+ opacity: isSidebarCollapsed ? 0 : 1,
305
+ overflow: "hidden",
306
+ pointerEvents: isSidebarCollapsed ? "none" : "auto",
307
+ transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
308
+ <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 500,
309
+ fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: textColor, margin: 0,
310
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
311
+ transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>
312
+ {item.label}
313
+ </p>
314
+ </div>
315
+
316
+ {/* Chevron, groupers only, and EXPANDED ONLY.
317
+ Figma's SideNavItem.Collapsed is indicator + Container.LeadingIcon
318
+ and nothing else: there is no chevron in the rail. Giving it
319
+ width 0 and max-width 0 was not enough, because the glyph still
320
+ painted outside its collapsed box, so the rail showed a second
321
+ column of 10px chevrons 17px to the right of the icons. Measured
322
+ 2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
323
+ should be one. It is now not rendered at all when collapsed. */}
324
+ {!isChild && hasChildren && !isSidebarCollapsed && (
325
+ <div style={{ width: isSidebarCollapsed ? 0 : 40, height: L.iconWrap, display: "flex",
326
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
327
+ maxWidth: isSidebarCollapsed ? 0 : 40,
328
+ opacity: isSidebarCollapsed ? 0 : 1,
329
+ overflow: "hidden",
330
+ transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
331
+ {/* Chevron rotation matches accordion timing, --motion-duration-5 + --motion-easing-accordion */}
332
+ <div style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
333
+ transition: "transform var(--motion-duration-5) var(--motion-easing-accordion)" }}>
334
+ {chevronDown ? chevronDown({ size: 10, color: iconColor }) : <ChevDown size={10} color={iconColor} />}
335
+ </div>
336
+ </div>
337
+ )}
338
+ </div>
339
+
340
+ {/* Hover-safe bridge */}
341
+ {isSidebarCollapsed && popoverOpen && anchorRect && (
342
+ <div style={{ position: "fixed", left: anchorRect.right, top: anchorRect.top,
343
+ width: 8, height: anchorRect.height, zIndex: 999 }}
344
+ onMouseEnter={() => {
345
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
346
+ onPopoverEnter && onPopoverEnter(item.id, rect);
347
+ }} />
348
+ )}
349
+
350
+ {/* Collapsed grouper → flyout popover */}
351
+ {isSidebarCollapsed && hasChildren && popoverOpen && anchorRect &&
352
+ <CollapsedPopover item={item} onClick={onClick} anchorRect={anchorRect}
353
+ activeId={activeId}
354
+ onMouseEnter={() => {
355
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
356
+ onPopoverEnter && onPopoverEnter(item.id, rect);
357
+ }}
358
+ onMouseLeave={onPopoverLeave} />
359
+ }
360
+
361
+ {/* Collapsed destination → tooltip */}
362
+ {isSidebarCollapsed && !hasChildren && popoverOpen && anchorRect &&
363
+ <SideNavTooltip label={item.label} anchorRect={anchorRect}
364
+ onMouseEnter={() => {
365
+ const rect = itemRef.current ? itemRef.current.getBoundingClientRect() : anchorRect;
366
+ onPopoverEnter && onPopoverEnter(item.id, rect);
367
+ }}
368
+ onMouseLeave={onPopoverLeave} />
369
+ }
370
+ </div>
371
+ );
372
+ }
373
+
374
+ // ─── SideNavTooltip ─────────────────────────────────────────────────────────
375
+ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }) {
376
+ if (!anchorRect) return null;
377
+ return ReactDOM.createPortal(
378
+ <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
379
+ style={{ position: "fixed",
380
+ left: anchorRect.right + 8,
381
+ top: anchorRect.top + anchorRect.height / 2,
382
+ zIndex: 1000, backgroundColor: T.surface.navLight,
383
+ border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
384
+ boxShadow: c2("--elevation-widget"),
385
+ padding: "6px 8px", whiteSpace: "nowrap", pointerEvents: "auto",
386
+ animation: "popoverInCentered var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
387
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
388
+ fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)", color: T.text.secondary }}>
389
+ {label}
390
+ </span>
391
+ </div>,
392
+ document.body
393
+ );
394
+ }
395
+
396
+ // ─── SectionLabel ─────────────────────────────────────────────────────────────
397
+ // Figma node 40006794-5977, building block used in CollapsedPopover headers.
398
+ // Tokens: Foreground/Static/Neutral/Base (#313131), Label/Section/Small/Semibold
399
+ // (11px / 600 / 16px / 0.6px letter-spacing, uppercase)
400
+ // Padding: Padding/Tight (12px left), Padding/XTight (8px vertical)
401
+ export function SectionLabel({ label }) {
402
+ return (
403
+ <div style={{ display: "flex", alignItems: "center", width: "100%",
404
+ paddingLeft: "var(--semantic-layout-units-padding-tight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)"}}>
405
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
406
+ fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
407
+ color: T.text.secondary, textTransform: "uppercase",
408
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
409
+ {label}
410
+ </span>
411
+ </div>
412
+ );
413
+ }
414
+
415
+ // ─── CollapsedPopover ─────────────────────────────────────────────────────────
416
+ export function CollapsedPopover({ item, onClick, anchorRect, onMouseEnter, onMouseLeave, activeId }) {
417
+ if (!anchorRect) return null;
418
+ return ReactDOM.createPortal(
419
+ <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
420
+ style={{ position: "fixed", left: anchorRect.right + 8, top: anchorRect.top,
421
+ zIndex: 1000, backgroundColor: T.surface.navElevated,
422
+ border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
423
+ boxShadow: c2("--elevation-widget"),
424
+ padding: "var(--semantic-layout-units-padding-xxtight)", minWidth: 200, pointerEvents: "auto",
425
+ animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
426
+ {/* Section label, Figma component 40006794-5977 */}
427
+ <div style={{ borderBottom: `0.5px solid ${c("stroke-static-neutral-faint")}` }}>
428
+ <SectionLabel label={item.label} />
429
+ </div>
430
+ {item.children.map(child =>
431
+ <PopoverRow key={child.id} item={child} onClick={onClick} activeId={activeId} />
432
+ )}
433
+ </div>,
434
+ document.body
435
+ );
436
+ }
437
+
438
+ // ─── PopoverRow ───────────────────────────────────────────────────────────────
439
+ export function PopoverRow({ item, onClick, activeId }) {
440
+ const [h, setH] = useState(false);
441
+ const isActive = item.id === activeId;
442
+ const bg = isActive ? T.fill.navActive : h ? T.fill.navHover : "transparent";
443
+ const color = isActive ? T.text.navActive : h ? T.text.navHover : T.text.navBase;
444
+ return (
445
+ <div onClick={() => onClick(item.id)}
446
+ onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
447
+ style={{ display: "flex", alignItems: "center", minHeight: 40, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
448
+ overflow: "hidden", backgroundColor: bg, cursor: "pointer",
449
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
450
+ <div style={{ width: 4, alignSelf: "stretch", borderRadius: "0 4px 4px 0",
451
+ backgroundColor: isActive ? T.indicator : "transparent",
452
+ flexShrink: 0, transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
453
+ <span style={{ padding: "0 8px", fontFamily: "'Red Hat Text',sans-serif",
454
+ fontWeight: 400, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)",
455
+ color, transition: "color var(--motion-duration-3) var(--motion-easing-standard)", flex: 1 }}>
456
+ {item.label}
457
+ </span>
458
+ </div>
459
+ );
460
+ }
461
+
462
+ // ─── CollapseButton ───────────────────────────────────────────────────────────
463
+ // Redesigned in Figma (2026-05-12): now uses Slot.RowStart (Module.Mark icon)
464
+ // + Slot.RowEnd (Action Icon). The action icon uses Foreground/Action/Secondary/Rest
465
+ // (#6b6b6b), different from nav item icons. SVG assets fetched from Figma directly.
466
+ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
467
+ const [h, setH] = useState(false);
468
+ const labelColor = h ? T.text.navHover : T.text.navBase;
469
+ const actionIconColor = T.icon.actionSecondary; // var(--semantic-color-stroke-action-field-hover), always static, no hover change
470
+ return (
471
+ <div style={{ width: "100%" }}>
472
+ <div style={{ height: 1, backgroundColor: T.fill.infoSubtle, marginBottom: L.collapseGap }} />
473
+ <div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
474
+ aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
475
+ role="button" tabIndex={0}
476
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
477
+ style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
478
+ borderRadius: T.radius, backgroundColor: h ? T.fill.navHover : T.fill.navBase,
479
+ cursor: "pointer", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)", overflow: "hidden" }}>
480
+
481
+ {/* Slot.RowStart, 4px stripe column (structural, always present) */}
482
+ <div style={{ width: L.stripeW, alignSelf: "stretch", flexShrink: 0 }} />
483
+
484
+ {/* Container.Main, label (hidden when collapsed) */}
485
+ <div style={{ flex: 1, paddingLeft: L.rowPadH,
486
+ maxWidth: isSidebarCollapsed ? 0 : 200,
487
+ opacity: isSidebarCollapsed ? 0 : 1,
488
+ overflow: "hidden",
489
+ transition: "max-width var(--motion-duration-5) var(--motion-easing-standard), opacity var(--motion-duration-3) var(--motion-easing-standard)",
490
+ pointerEvents: isSidebarCollapsed ? "none" : "auto" }}>
491
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)",
492
+ lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: labelColor,
493
+ transition: "color var(--motion-duration-3) var(--motion-easing-standard)", whiteSpace: "nowrap" }}>
494
+ Collapse
495
+ </span>
496
+ </div>
497
+
498
+ {/* Slot.RowEnd, Action Icon (right_panel_open / left_panel_open from Figma) */}
499
+ <div style={{ width: 40, height: L.iconWrap, display: "flex",
500
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
501
+ marginLeft: isSidebarCollapsed ? "auto" : 0,
502
+ paddingLeft: isSidebarCollapsed ? 0 : 0 }}>
503
+ {isSidebarCollapsed
504
+ ? (expandIcon
505
+ ? expandIcon({ size: 12, color: actionIconColor })
506
+ : <LeftPanelOpenIcon color={actionIconColor} />)
507
+ : (collapseIcon
508
+ ? collapseIcon({ size: 12, color: actionIconColor })
509
+ : <RightPanelOpenIcon color={actionIconColor} />)}
510
+ </div>
511
+ </div>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ // ─── SideNav (top-level container) ────────────────────────────────────────────
517
+ // ── NavSectionLabel ──────────────────────────────────────────────────────────
518
+ // Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
519
+ // Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Base (#606060)
520
+ // Container: h-[40px], pl-[4px], pr-[4px], py-[8px]
521
+ // In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
522
+ // ── NavHeader ────────────────────────────────────────────────────────────────
523
+ // Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
524
+ // in Slot.RowEnd (expanded) or centered (collapsed). Divider below.
525
+ // Matches Figma "Slot.NavHeader" (node 40007331:7794).
526
+ // Hidden on mobile (<768px), TopNav hamburger is the sole toggle there.
527
+ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIcon }) {
528
+ const [h, setH] = useState(false);
529
+ const actionIconColor = T.icon.actionSecondary; // var(--semantic-color-stroke-action-field-hover), always static
530
+ return (
531
+ <div style={{ width: "100%", flexShrink: 0 }}>
532
+ <div onClick={onToggle} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
533
+ aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
534
+ role="button" tabIndex={0}
535
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
536
+ style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
537
+ borderRadius: T.radius, cursor: "pointer",
538
+ backgroundColor: h ? T.fill.navHover : "transparent",
539
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
540
+ {isSidebarCollapsed ? (
541
+ /* Collapsed: centered action icon */
542
+ <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
543
+ {expandIcon
544
+ ? expandIcon({ size: 12, color: actionIconColor })
545
+ : <LeftPanelOpenIcon color={actionIconColor} />}
546
+ </div>
547
+ ) : (
548
+ /* Expanded: right-aligned action icon in Slot.RowEnd */
549
+ <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "flex-end" }}>
550
+ <div style={{ width: 36, height: 36, display: "flex",
551
+ alignItems: "center", justifyContent: "center" }}>
552
+ {collapseIcon
553
+ ? collapseIcon({ size: 12, color: actionIconColor })
554
+ : <RightPanelOpenIcon color={actionIconColor} />}
555
+ </div>
556
+ </div>
557
+ )}
558
+ </div>
559
+ {/* Divider, Stroke/Static/Neutral/Base, py-[2px] in Figma */}
560
+ <div style={{ padding: "2px 0" }}>
561
+ <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
562
+ </div>
563
+ </div>
564
+ );
565
+ }
566
+
567
+ export function NavSectionLabel({ label }) {
568
+ return (
569
+ <div style={{ display: "flex", alignItems: "center", height: 40, width: "100%",
570
+ paddingLeft: "var(--semantic-layout-units-padding-xxxtight)", paddingRight: "var(--semantic-layout-units-padding-xxxtight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)",
571
+ flexShrink: 0 }}>
572
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
573
+ fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
574
+ textTransform: "uppercase", color: T.text.secondarySubtle,
575
+ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
576
+ {label}
577
+ </span>
578
+ </div>
579
+ );
580
+ }
581
+
582
+ // ── BulletDot ────────────────────────────────────────────────────────────────
583
+ // 6px filled circle, leading icon for SideNavListSection's ListItem.
584
+ // Color cycles Base / Hover / Active just like SideNavItem icons.
585
+ export function BulletDot({ color }) {
586
+ return (
587
+ <div style={{ width: 6, height: 6, borderRadius: "50%",
588
+ backgroundColor: color, flexShrink: 0 }} />
589
+ );
590
+ }
591
+
592
+ // ── ListItem ─────────────────────────────────────────────────────────────────
593
+ // Figma: SideNavItemList (node 40007332:6994). Used inside SideNavListSection.
594
+ // 48px min-height, 12px/400/18px/0.6px Text/Body/XSmall/Regular.
595
+ // Same Base/Hover/Active token cycle as SideNavItem destinations.
596
+ export function ListItem({ item, isActive, onClick }) {
597
+ const [hovered, setHovered] = useState(false);
598
+ const bg = isActive ? T.fill.navActive : hovered ? T.fill.navHover : "transparent";
599
+ const color = isActive ? T.text.navActive : hovered ? T.text.navHover : T.text.navBase;
600
+ const dotColor = isActive ? T.icon.navActive : hovered ? T.icon.navHover : T.icon.navBase;
601
+ return (
602
+ <div onClick={() => onClick && onClick(item.id)}
603
+ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
604
+ role="button" tabIndex={0}
605
+ onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
606
+ style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
607
+ borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
608
+ overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
609
+ userSelect: "none" }}>
610
+ <IndicatorStripe visible={isActive} />
611
+ <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
612
+ alignItems: "center", justifyContent: "center", flexShrink: 0,
613
+ marginLeft: L.rowPadH }}>
614
+ <BulletDot color={dotColor} />
615
+ </div>
616
+ <div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: L.rowPadH }}>
617
+ <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
618
+ fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)", color,
619
+ margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
620
+ transition: "color var(--motion-duration-3) var(--motion-easing-standard)" }}>
621
+ {item.label}
622
+ </p>
623
+ </div>
624
+ </div>
625
+ );
626
+ }
627
+
628
+ // ── SideNavListSection ───────────────────────────────────────────────────────
629
+ // Figma: SideNav.ListSection (node 40007332:8034). A labelled grouping of flat
630
+ // nav items: no icons, no children, no expand/collapse. Used for contextual
631
+ // link lists (Recent Content, Pinned, Bookmarks). Only shown in expanded sidebar.
632
+ export function SideNavListSection({ label, items, activeId, onNavigate }) {
633
+ return (
634
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xtight)", width: "100%", flexShrink: 0 }}>
635
+ <NavSectionLabel label={label} />
636
+ <div style={{ display: "flex", flexDirection: "column" }}>
637
+ {items.map(item => (
638
+ <ListItem key={item.id} item={item}
639
+ isActive={item.id === activeId}
640
+ onClick={onNavigate} />
641
+ ))}
642
+ </div>
643
+ </div>
644
+ );
645
+ }
646
+
647
+ export function SideNav({
648
+ items,
649
+ sections,
650
+ listSection,
651
+ activeId,
652
+ onNavigate,
653
+ collapsed = false,
654
+ onCollapseChange,
655
+ hideCollapseButton = false,
656
+ defaultExpanded = {},
657
+ className = "",
658
+ // ── SLOTS ─────────────────────────────────────────────────────────────────
659
+ // Three insertion points, mirroring the slot structure in Figma. Added
660
+ // 2026-09-10 at the user's explicit request (which is what the §1.1 Figma
661
+ // exemption requires before SideNav changes).
662
+ //
663
+ // WHY SLOTS RATHER THAN MORE PROPS: the `items` array can express anything
664
+ // item-SHAPED - a label, an icon, nesting, a disabled state. It cannot
665
+ // express a module-specific widget: a support button, a plan-usage meter, a
666
+ // "New" badge component a team already owns. Without a slot those teams have
667
+ // exactly one option, which is to stop using the component and rebuild the
668
+ // nav. That is the drift this component exists to prevent, so the escape
669
+ // hatch has to be inside it.
670
+ //
671
+ // headerEnd after the collapse toggle in the pinned header. This is the
672
+ // common case: a module name, a small action. In Figma the
673
+ // header is a slot that can hold the toggle alone, or the
674
+ // toggle plus a label or button.
675
+ // children inside the scroll region, after the items. Anything that
676
+ // scrolls with the list.
677
+ // footer PINNED below the scroll region, so it stays put while the
678
+ // list scrolls. Buttons live here.
679
+ //
680
+ // Each is null by default and renders NOTHING when null - no wrapper, no
681
+ // padding, no border. An always-rendered empty wrapper would shift the
682
+ // layout of every nav that does not use the slot.
683
+ headerEnd = null,
684
+ children = null,
685
+ footer = null,
686
+ }) {
687
+ const [expanded, setExpanded] = useState(defaultExpanded);
688
+ const [popoverId, setPopoverId] = useState(null);
689
+ const [popoverRect, setPopoverRect] = useState(null);
690
+ const timerRef = useRef(null);
691
+
692
+ // Build a single flat list of all nav items for trail computation, regardless of
693
+ // whether the data was passed as flat `items` or as `sections` (which contain items).
694
+ // This keeps the active/trail logic unchanged from the flat-items API.
695
+ const flatItems = sections
696
+ ? sections.flatMap(s => s.items)
697
+ : (items || []);
698
+
699
+ const trailParentId = (() => {
700
+ const p = flatItems.find(item => item.children && item.children.some(c => c.id === activeId));
701
+ return p ? p.id : null;
702
+ })();
703
+
704
+ // Accordion behaviour: opening one grouper auto-closes any other that's open.
705
+ // Matches the demo HTML (2026-05-13).
706
+ const toggleExpand = id => setExpanded(prev => {
707
+ const isNowOpen = !prev[id];
708
+ if (isNowOpen) return { [id]: true }; // close all others
709
+ const next = { ...prev }; delete next[id]; return next;
710
+ });
711
+ const onPopoverEnter = (id, rect) => {
712
+ clearTimeout(timerRef.current);
713
+ setPopoverId(id);
714
+ if (rect) setPopoverRect(rect);
715
+ };
716
+ const onPopoverLeave = () => {
717
+ timerRef.current = setTimeout(() => {
718
+ setPopoverId(null);
719
+ setPopoverRect(null);
720
+ }, 300);
721
+ };
722
+
723
+ // Renders one nav item + its animated child list. Shared between flat-items and
724
+ // sectioned renders so behaviour is identical regardless of which API is used.
725
+ const renderItem = (item) => {
726
+ const hasChildren = !!(item.children && item.children.length);
727
+ const isActive = item.id === activeId;
728
+ const isTrail = item.id === trailParentId;
729
+ const isExp = !!expanded[item.id];
730
+ return (
731
+ <div key={item.id}>
732
+ <SideNavItem item={item} isActive={isActive} isTrail={isTrail}
733
+ isExpanded={isExp} isSidebarCollapsed={collapsed}
734
+ isChild={false} onClick={onNavigate} onToggle={toggleExpand}
735
+ popoverOpen={popoverId === item.id}
736
+ anchorRect={popoverId === item.id ? popoverRect : null}
737
+ onPopoverEnter={onPopoverEnter} onPopoverLeave={onPopoverLeave}
738
+ activeId={activeId} />
739
+ {/* Level 1 children, animated accordion. grid-template-rows 0fr→1fr at
740
+ --motion-duration-5 + --motion-easing-accordion, with inner wrapper opacity
741
+ fade --motion-duration-4 (70ms delay on expand, no delay on collapse). */}
742
+ {hasChildren && !collapsed && (
743
+ <div style={{
744
+ display: "grid",
745
+ gridTemplateRows: isExp ? "1fr" : "0fr",
746
+ marginTop: isExp ? L.menuGap : 0,
747
+ transition: "grid-template-rows var(--motion-duration-5) var(--motion-easing-accordion), margin-top var(--motion-duration-5) var(--motion-easing-accordion)" }}>
748
+ <div style={{
749
+ overflow: "hidden",
750
+ display: "flex", flexDirection: "column", gap: L.menuGap,
751
+ opacity: isExp ? 1 : 0,
752
+ transition: isExp
753
+ ? "opacity var(--motion-duration-4) var(--motion-easing-standard) 70ms"
754
+ : "opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
755
+ {item.children.map(child => (
756
+ <SideNavItem key={child.id} item={child}
757
+ isActive={child.id === activeId} isTrail={false}
758
+ isExpanded={false} isSidebarCollapsed={false}
759
+ isChild={true} onClick={onNavigate} onToggle={() => {}} />
760
+ ))}
761
+ </div>
762
+ </div>
763
+ )}
764
+ </div>
765
+ );
766
+ };
767
+
768
+ return (
769
+ <nav className={className} style={{
770
+ width: collapsed ? L.navWcol : L.navW,
771
+ height: "100%",
772
+ backgroundColor: T.surface.navLight,
773
+ display: "flex", flexDirection: "column",
774
+ paddingTop: L.navPadTop,
775
+ paddingBottom: 0,
776
+ paddingLeft: collapsed ? L.navColPadH : L.navPadH,
777
+ paddingRight: collapsed ? L.navColPadH : L.navPadH,
778
+ boxSizing: "border-box", flexShrink: 0,
779
+ // Smooth-spring curve, soft personality, no overshoot.
780
+ transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
781
+ borderRight: `0.5px solid ${T.fill.infoSubtle}`,
782
+ overflow: "hidden", // the inner menu scrolls; the nav itself does not
783
+ }}>
784
+ {/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
785
+ item list scrolls beneath it. Hidden on mobile (<768px). */}
786
+ {(!hideCollapseButton || headerEnd) && (
787
+ <div style={{ flexShrink: 0 }}>
788
+ {!hideCollapseButton && (
789
+ <NavHeader isSidebarCollapsed={collapsed}
790
+ onToggle={() => onCollapseChange && onCollapseChange(!collapsed)} />
791
+ )}
792
+ {/* headerEnd slot. Hidden on the 72px rail: the rail is only wide
793
+ enough for a centred icon, so arbitrary header content would
794
+ either overflow it or force it wider. It fades rather than
795
+ unmounting, on the same curve as the section labels, so the
796
+ header does not jump when the rail toggles. */}
797
+ {headerEnd && (
798
+ <div style={{
799
+ opacity: collapsed ? 0 : 1,
800
+ maxHeight: collapsed ? 0 : 200,
801
+ overflow: "hidden",
802
+ pointerEvents: collapsed ? "none" : "auto",
803
+ padding: collapsed ? 0 : `0 ${L.navPadH}`,
804
+ transition:
805
+ "opacity var(--motion-duration-4) var(--motion-easing-standard), " +
806
+ "max-height var(--motion-duration-5) var(--motion-easing-spring)",
807
+ }}>
808
+ {headerEnd}
809
+ </div>
810
+ )}
811
+ </div>
812
+ )}
813
+
814
+ {/* SideNavMenu: the ONLY scroll region, wrapped in <Scrollable> so the custom
815
+ overlay scrollbar (identical on every OS, never affects layout/padding) handles
816
+ overflow. flex:1 + minHeight:0 lets it shrink below content height; the pinned
817
+ header above never moves. Applies in BOTH expanded (240px) and collapsed (72px) states. */}
818
+ <Scrollable
819
+ style={{ flex: 1, minHeight: 0,
820
+ // Bleed the scroll region out to the nav's TRUE right edge so the overlay thumb
821
+ // hugs the edge in BOTH states (the nav's border-box paddingRight would otherwise
822
+ // inset the thumb by 16px expanded / 12px on the 72px rail, badly off on the rail).
823
+ // negated through calc() because these are var() references, and negating
824
+ // a string would produce NaN
825
+ marginRight: neg(collapsed ? L.navColPadH : L.navPadH) }}
826
+ viewStyle={{ display: "flex", flexDirection: "column", gap: L.menuGap,
827
+ paddingTop: L.menuPadT,
828
+ // Re-add the content's right inset INSIDE the scroll view so items keep their
829
+ // position; only the thumb moves to the edge. (Spec rule: padding on viewStyle,
830
+ // never on the wrapper, keeps the thumb flush on any scroll surface.)
831
+ paddingRight: collapsed ? L.navColPadH : L.navPadH }}
832
+ >
833
+
834
+ {/* Sectioned render: when `sections` prop is provided */}
835
+ {sections && sections.map(({ section, items: secItems }, sIdx) => (
836
+ <div key={section} style={{ display: "flex", flexDirection: "column",
837
+ gap: L.menuGap, flexShrink: 0 }}>
838
+ {/* Expanded: NavSectionLabel; fades to 0 height as rail collapses */}
839
+ <div style={{ opacity: collapsed ? 0 : 1,
840
+ maxHeight: collapsed ? 0 : 40, overflow: "hidden",
841
+ transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)",
842
+ flexShrink: 0 }}>
843
+ <NavSectionLabel label={section} />
844
+ </div>
845
+ {/* Collapsed rail: Divider replaces the section label (skip first section) */}
846
+ {sIdx > 0 && (
847
+ <div style={{ opacity: collapsed ? 1 : 0,
848
+ maxHeight: collapsed ? 5 : 0, overflow: "hidden",
849
+ transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)",
850
+ padding: "2px 0", flexShrink: 0 }}>
851
+ <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
852
+ </div>
853
+ )}
854
+ {secItems.map(renderItem)}
855
+ </div>
856
+ ))}
857
+
858
+ {/* Flat render: when only `items` is provided (backward compatibility) */}
859
+ {!sections && (items || []).map(renderItem)}
860
+
861
+ {/* SideNavListSection, optional. Hidden in collapsed rail. */}
862
+ {listSection && (
863
+ <div style={{ opacity: collapsed ? 0 : 1,
864
+ maxHeight: collapsed ? 0 : 400, overflow: "hidden",
865
+ transition: "opacity var(--motion-duration-4) var(--motion-easing-standard), max-height var(--motion-duration-5) var(--motion-easing-spring)" }}>
866
+ <SideNavListSection label={listSection.label} items={listSection.items}
867
+ activeId={activeId} onNavigate={onNavigate} />
868
+ </div>
869
+ )}
870
+
871
+ {/* Body slot, scrolls with the item list, so it sits INSIDE
872
+ Scrollable and above the spacer. Use this for content that belongs
873
+ with the items; use `footer` for anything that must stay visible. */}
874
+ {children && <div style={{ flexShrink: 0 }}>{children}</div>}
875
+
876
+ {/* Bottom spacer, fills remaining height */}
877
+ <div style={{ flex: 1, minHeight: L.menuPadB }} />
878
+ </Scrollable>
879
+
880
+ {/* Footer slot, OUTSIDE Scrollable and flexShrink:0, so it is pinned to
881
+ the bottom of the nav and stays put while the list scrolls. The
882
+ divider only appears when the slot has content, so a nav without a
883
+ footer is unchanged. Padding drops to the rail's narrower inset when
884
+ collapsed rather than hiding: a footer button is usually an icon
885
+ button that still works at 72px, unlike arbitrary header content. */}
886
+ {footer && (
887
+ <div style={{
888
+ flexShrink: 0,
889
+ borderTop: `0.5px solid ${T.fill.infoSubtle}`,
890
+ padding: collapsed ? `8px ${L.navColPadH}` : `8px ${L.navPadH}`,
891
+ transition: "padding var(--motion-duration-6) var(--motion-easing-emphasized)",
892
+ }}>
893
+ {footer}
894
+ </div>
895
+ )}
896
+ </nav>
897
+ );
898
+ }
899
+
900
+ // ─── Built-in chevron icons ───────────────────────────────────────────────────
901
+ function ChevDown({ size = 10, color }) {
902
+ return (
903
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
904
+ <path d="M3 5.5L8 10.5L13 5.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
905
+ </svg>
906
+ );
907
+ }
908
+ function ChevUp({ size = 10, color }) {
909
+ return (
910
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
911
+ <path d="M3 10.5L8 5.5L13 10.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
912
+ </svg>
913
+ );
914
+ }
915
+
916
+ // ─── Collapse / Expand action icons (fetched from Figma 2026-05-12) ──────────
917
+ //
918
+ // PINNED 2026-09-07: DO NOT RE-SYNC THESE TWO FROM FIGMA.
919
+ //
920
+ // The Figma SideBar Expand/Collapse component was changed after this date to a
921
+ // different icon. Jo has explicitly rejected that change for the repo, for
922
+ // Storybook and for <pathway-sidenav>. The panel-style pair below is the
923
+ // intended design and stays.
924
+ //
925
+ // This is a deliberate divergence from Figma, which normally wins for component
926
+ // visual design (CLAUDE.md §1). So if a reconciliation run reports these as
927
+ // drift, that report is correct and the answer is "leave it". Do not resolve it
928
+ // by pulling Figma, and do not silently re-fetch node 40006793:3783. Ask first
929
+ // if you think it should change.
930
+ // ─────────────────────────────────────────────────────────────────────────────
931
+ // right_panel_open, used on the collapse button when sidebar is EXPANDED (click to collapse)
932
+ // Token: Foreground/Action/Secondary/Rest (#6b6b6b)
933
+ // Figma component: SideBar Expand/Collapse, Type=Collapse, node 40006793:3783
934
+ function RightPanelOpenIcon({ size = 12, color = T.icon.actionSecondary }) {
935
+ return (
936
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
937
+ <path d="M5.66667 7.86667V4.13333C5.66667 3.97778 5.6 3.87222 5.46667 3.81667C5.33333 3.76111 5.21111 3.78889 5.1 3.9L3.46667 5.53333C3.33333 5.66667 3.26667 5.82222 3.26667 6C3.26667 6.17778 3.33333 6.33333 3.46667 6.46667L5.1 8.1C5.21111 8.21111 5.33333 8.23889 5.46667 8.18333C5.6 8.12778 5.66667 8.02222 5.66667 7.86667ZM1.33333 12C0.966667 12 0.652778 11.8694 0.391667 11.6083C0.130556 11.3472 0 11.0333 0 10.6667V1.33333C0 0.966667 0.130556 0.652778 0.391667 0.391667C0.652778 0.130556 0.966667 0 1.33333 0H10.6667C11.0333 0 11.3472 0.130556 11.6083 0.391667C11.8694 0.652778 12 0.966667 12 1.33333V10.6667C12 11.0333 11.8694 11.3472 11.6083 11.6083C11.3472 11.8694 11.0333 12 10.6667 12H1.33333ZM8.66667 10.6667H10.6667V1.33333H8.66667V10.6667ZM7.33333 10.6667V1.33333H1.33333V10.6667H7.33333Z" fill={color} />
938
+ </svg>
939
+ );
940
+ }
941
+
942
+ // left_panel_open, used on the collapse button when sidebar is COLLAPSED (click to expand)
943
+ // Token: Foreground/Action/Secondary/Rest (#6b6b6b)
944
+ // Figma component: SideBar Expand/Collapse, Type=Expand, node 40006793:3783
945
+ function LeftPanelOpenIcon({ size = 12, color = T.icon.actionSecondary }) {
946
+ return (
947
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
948
+ <path d="M6.33333 4.13333V7.86667C6.33333 8.02222 6.4 8.12778 6.53333 8.18333C6.66667 8.23889 6.78889 8.21111 6.9 8.1L8.53333 6.46667C8.66667 6.33333 8.73333 6.17778 8.73333 6C8.73333 5.82222 8.66667 5.66667 8.53333 5.53333L6.9 3.9C6.78889 3.78889 6.66667 3.76111 6.53333 3.81667C6.4 3.87222 6.33333 3.97778 6.33333 4.13333ZM1.33333 12C0.966667 12 0.652778 11.8694 0.391667 11.6083C0.130556 11.3472 0 11.0333 0 10.6667V1.33333C0 0.966667 0.130556 0.652778 0.391667 0.391667C0.652778 0.130556 0.966667 0 1.33333 0H10.6667C11.0333 0 11.3472 0.130556 11.6083 0.391667C11.8694 0.652778 12 0.966667 12 1.33333V10.6667C12 11.0333 11.8694 11.3472 11.6083 11.6083C11.3472 11.8694 11.0333 12 10.6667 12H1.33333ZM3.33333 10.6667V1.33333H1.33333V10.6667H3.33333ZM4.66667 10.6667H10.6667V1.33333H4.66667V10.6667Z" fill={color} />
949
+ </svg>
950
+ );
951
+ }