@helloimjolopez-pathway/pathway-components 0.2.2 → 0.2.4

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.
@@ -90,6 +90,13 @@ export const T = {
90
90
  * @param {string} color Override the resting colour. Omit on a light surface;
91
91
  * pass one on a dark or on-brand surface, where Ghost
92
92
  * is the right family.
93
+ * @param {object} states Override the hover and pressed tokens as
94
+ * { hover, pressed, layerHover, layerPress }. A dark or
95
+ * on-brand surface needs this as well as `color`: the
96
+ * default ladder paints a light Primary/Subtle layer and a
97
+ * dark Secondary glyph, which is right on a light page and
98
+ * wrong on the navy TopNav. SideNav.Control (40007082:7703)
99
+ * binds the Ghost ladder instead.
93
100
  */
94
101
  export function ActionIcon({
95
102
  name,
@@ -97,6 +104,7 @@ export function ActionIcon({
97
104
  label,
98
105
  fill = 0,
99
106
  color,
107
+ states,
100
108
  onClick,
101
109
  disabled,
102
110
  className = "",
@@ -107,7 +115,8 @@ export function ActionIcon({
107
115
  const [hov, setHov] = useState(false);
108
116
  const [press, setPress] = useState(false);
109
117
  const s = ACTION_ICON_SIZES[size] || ACTION_ICON_SIZES.Small;
110
- const fg = disabled ? T.rest : press ? T.pressed : hov ? T.hover : (color || T.rest);
118
+ const st = { ...T, ...states };
119
+ const fg = disabled ? T.rest : press ? st.pressed : hov ? st.hover : (color || T.rest);
111
120
 
112
121
  return (
113
122
  <button
@@ -141,7 +150,7 @@ export function ActionIcon({
141
150
  <span style={{
142
151
  display: "inline-flex", alignItems: "center", justifyContent: "center",
143
152
  width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
144
- background: press ? T.layerPress : hov ? T.layerHover : "transparent",
153
+ background: press ? st.layerPress : hov ? st.layerHover : "transparent",
145
154
  transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}`,
146
155
  }}>
147
156
  <Icon name={name} size={s.glyph} fill={fill} style={{
@@ -0,0 +1,46 @@
1
+ /**
2
+ * NavShell's standalone demo. Imports the real NavShell, which composes the
3
+ * real TopNav and SideNav, so nothing here can fall out of step with them: see
4
+ * components/_demo/harness.jsx for why the newer demos are generated.
5
+ *
6
+ * Replaced the hand-written nav-shell.html on 2026-11-03. That page carried its
7
+ * own copies of TopNav and SideNav, both from before the Figma parity passes:
8
+ * a 240px nav, no item radius, a 4px stripe rounded at 4, a 20px glyph, a 22px
9
+ * module mark, raw rgba hover fills, and a 56px NavHeader Figma does not draw.
10
+ */
11
+ import React from "react";
12
+ import { DemoChrome, mount } from "../_demo/harness.jsx";
13
+ import { NavShell } from "./nav-shell.jsx";
14
+ import { PageTemplate } from "../page-template/page-template.jsx";
15
+ import { NAV_SECTIONS } from "../../src/stories/Library/SideNav/sidenavDemoData.jsx";
16
+
17
+ const C = (n) => `var(--semantic-color-${n})`;
18
+ const Y = (n) => `var(--semantic-type-${n})`;
19
+
20
+ // NavShell is fixed to the viewport, so every width, Desktop included, renders
21
+ // in an inner frame: in this document it would sit on top of the demo chrome.
22
+ const WIDTHS = { Desktop: 1440, Tablet: 900, Mobile: 390 };
23
+
24
+ const ORG = { id: "gcc", name: "Grace Community Church", campus: "Knoxville", initials: "GC" };
25
+ const USER = { name: "Jo Lopez", initials: "JL", email: "jo@gracecommunity.org" };
26
+
27
+ function App() {
28
+ return (
29
+ <DemoChrome title="NavShell" widths={WIDTHS}
30
+ blurb={"TopNav, SideNav and the page area composed into one application frame. " +
31
+ "Desktop pushes the page with a collapsible nav, Tablet locks the 72px rail, " +
32
+ "Mobile opens the nav as an overlay from the hamburger."}>
33
+ <NavShell activeModuleId="accounting" org={ORG} user={USER}
34
+ navSections={NAV_SECTIONS} activeNavId="balance_sheet" onNavigate={() => {}}>
35
+ <PageTemplate>
36
+ <h1 style={{ margin: 0, fontSize: Y("font-size-xl"), lineHeight: Y("line-height-xl-single"),
37
+ fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") }}>
38
+ Balance Sheet
39
+ </h1>
40
+ </PageTemplate>
41
+ </NavShell>
42
+ </DemoChrome>
43
+ );
44
+ }
45
+
46
+ mount(<App />);
@@ -49,9 +49,9 @@ function useBreakpoint() {
49
49
  * SideNav fixed, left edge, top 60px → bottom, push layout
50
50
  * Shell.Main scrollable content area, margin-left = SideNav width
51
51
  *
52
- * Desktop: SideNav 240px expanded | 84px collapsed, user toggles
53
- * Tablet: SideNav always 84px rail, toggle locked
54
- * Mobile: SideNav hidden, slides in as 240px overlay on hamburger tap
52
+ * Desktop: SideNav 250px expanded | 72px collapsed, user toggles
53
+ * Tablet: SideNav always 72px rail, toggle locked
54
+ * Mobile: SideNav hidden, slides in as 250px overlay on hamburger tap
55
55
  *
56
56
  * Props (TopNav pass-through):
57
57
  * modules Array<{id, label, icon}> — all modules (default: DEFAULT_MODULES)
@@ -107,10 +107,13 @@ export function NavShell({
107
107
  // Mobile overlay open state
108
108
  const [mobileOpen, setMobileOpen] = useState(false);
109
109
 
110
- // Tablet: rail (84px) is fixed, no user toggle
111
- // Mobile: always "collapsed" visually (but managed separately via mobileOpen)
110
+ // Tablet: rail (72px) is fixed, no user toggle
111
+ // Mobile: expanded inside the overlay, opened and closed via mobileOpen
112
112
  // Desktop: user-controlled
113
- const effectiveCollapsed = isTablet || isMobile ? true : collapsed;
113
+ // Mobile is EXPANDED: the overlay is the full 250 nav, as spec §8 and the
114
+ // NavShell story both have it. This forced it collapsed, so the 250 overlay
115
+ // opened with a 72 rail inside it and 178px of blank.
116
+ const effectiveCollapsed = isTablet ? true : isMobile ? false : collapsed;
114
117
 
115
118
  // Shell.Main margin-left: matches current SideNav width for push layout.
116
119
  // Mobile: no margin (SideNav is an overlay).
@@ -163,6 +166,12 @@ export function NavShell({
163
166
  </a>
164
167
 
165
168
  {/* ── TopNav: position fixed, full width, 60px ─────────────────────── */}
169
+ {/* The comment above was all there was: TopNav renders a plain in-flow
170
+ <nav>, so it sat in the document flow while Shell.Main ALSO took a
171
+ 60px top margin, putting a blank 60px band under the bar, and the
172
+ fixed SideNav sat under any wrapper padding. The wrapper makes it
173
+ fixed, as spec §9.3 says. */}
174
+ <div style={{ position: "fixed", top: 0, left: 0, right: 0, zIndex: 100 }}>
166
175
  <TopNav
167
176
  modules={modules}
168
177
  activeModuleId={activeModuleId}
@@ -174,6 +183,7 @@ export function NavShell({
174
183
  onOrgSelect={onOrgSelect}
175
184
  onSideNavToggle={handleSideNavToggle}
176
185
  />
186
+ </div>
177
187
 
178
188
  {/* ── Mobile scrim — darkens content while SideNav overlay is open ─── */}
179
189
  {isMobile && mobileOpen && (
@@ -190,7 +200,7 @@ export function NavShell({
190
200
 
191
201
  {/* ── SideNav container ────────────────────────────────────────────── */}
192
202
  {/* Position: fixed so it stays below TopNav while the content scrolls.
193
- On mobile: width controls the overlay reveal (0 → 240px); the SideNav
203
+ On mobile: width controls the overlay reveal (0 → 250px); the SideNav
194
204
  itself renders inside a clipping container and slides in from the left.
195
205
  On desktop/tablet: overflow hidden so the SideNav's own width transition
196
206
  stays within the container; no width needed here. */}
@@ -219,7 +229,8 @@ export function NavShell({
219
229
  onNavigate={onNavigate}
220
230
  collapsed={effectiveCollapsed}
221
231
  onCollapseChange={handleCollapseChange}
222
- hideCollapseButton={isTablet}
232
+ // Mobile: the TopNav hamburger is the only toggle (NavHeader spec).
233
+ hideCollapseButton={isTablet || isMobile}
223
234
  headerEnd={navHeaderEnd}
224
235
  footer={navFooter}
225
236
  >
@@ -513,6 +513,7 @@ function OrgRow({ org, active, onSelect }) {
513
513
  onMouseLeave={() => setHov(false)}
514
514
  aria-current={active ? "true" : undefined}
515
515
  style={{
516
+ position: "relative",
516
517
  display: "flex", alignItems: "center", gap: 12, width: "100%", textAlign: "left",
517
518
  padding: "8px 8px", border: "none", cursor: "pointer", borderRadius: 8,
518
519
  fontFamily: "inherit",
@@ -520,6 +521,17 @@ function OrgRow({ org, active, onSelect }) {
520
521
  transition: noMotion ? "none" : "background var(--motion-duration-2) var(--motion-easing-standard)",
521
522
  }}
522
523
  >
524
+ {/* indicator.stripe on the selected row, as OrgSwitcher.Row State=Selected
525
+ draws it (40018011:16090): 4 wide, 44 tall, right corners rounded 8,
526
+ Fill/Action/Selection/Indicator. The same stripe as the side nav. */}
527
+ {active && (
528
+ <span aria-hidden="true" style={{
529
+ position: "absolute", left: 0, top: "50%", transform: "translateY(-50%)",
530
+ width: 4, height: 44,
531
+ borderRadius: "0 var(--semantic-layout-units-cornerradius-base) var(--semantic-layout-units-cornerradius-base) 0",
532
+ background: "var(--semantic-color-fill-action-selection-indicator)",
533
+ }} />
534
+ )}
523
535
  {/* Logo tile, org initials on brand fill (falls back from a logo image) */}
524
536
  <div style={{
525
537
  width: 44, height: 44, borderRadius: 6, flexShrink: 0, background: PANEL_T.logoBg,
@@ -538,7 +550,8 @@ function OrgRow({ org, active, onSelect }) {
538
550
  <ModuleCluster />
539
551
  </div>
540
552
  {/* Trailing chevron */}
541
- <Icon name="chevron_right" size={20} style={{ color: PANEL_T.icon, flexShrink: 0 }} />
553
+ {/* 12, as OrgSwitcher.Row's trailing chevron in Figma. Was 20. */}
554
+ <Icon name="chevron_right" size={12} style={{ color: PANEL_T.icon, flexShrink: 0 }} />
542
555
  </button>
543
556
  );
544
557
  }
@@ -577,7 +590,8 @@ export function OrgSwitcherPanel({
577
590
  display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "var(--semantic-layout-units-padding-xtight)", borderRadius: "var(--semantic-layout-units-cornerradius-base)",
578
591
  border: `1px solid ${PANEL_T.border}`,
579
592
  }}>
580
- <Icon name="search" size={20} style={{ color: PANEL_T.icon }} />
593
+ {/* 18, as OrgSwitcher.Panel's Field.Search icon in Figma. Was 20. */}
594
+ <Icon name="search" size={18} style={{ color: PANEL_T.icon }} />
581
595
  <input
582
596
  type="text"
583
597
  value={query}
@@ -598,17 +598,19 @@ export function TopNavSearch({
598
598
  flexShrink: 0,
599
599
  }}
600
600
  >
601
- {/* Fixed 32×32 square + border-radius 50% = always a perfect circle, regardless of flex context */}
601
+ {/* Container.Icon: 36x36 with CornerRadius/Full and a Button/BorderWidth/Rest
602
+ (0.75) stroke, read from TopNav.Global 2026-11-03. Was 32 with 0.5. */}
602
603
  <div
603
604
  style={{
604
605
  display: "flex",
605
606
  alignItems: "center",
606
607
  justifyContent: "center",
607
- width: 32,
608
- height: 32,
609
- borderRadius: "50%",
608
+ width: 36,
609
+ height: 36,
610
+ boxSizing: "border-box",
611
+ borderRadius: "var(--semantic-layout-units-cornerradius-full)",
610
612
  background: hov ? T.collapsedBtnHover : T.collapsedBtnFill,
611
- border: `0.5px solid ${hov ? T.collapsedBtnBorderHov : T.collapsedBtnBorder}`,
613
+ border: `var(--contextual-layout-units-button-borderwidth-rest) solid ${hov ? T.collapsedBtnBorderHov : T.collapsedBtnBorder}`,
612
614
  transition: "background var(--motion-duration-2) var(--motion-easing-standard), border-color var(--motion-duration-2) var(--motion-easing-standard)",
613
615
  }}
614
616
  >
@@ -12,6 +12,11 @@
12
12
  * accurate; the correct action is still to leave this file alone, name the
13
13
  * drift, and wait. Do not resolve it by fetching Figma. See CLAUDE.md 1.1.
14
14
  *
15
+ * 2026-11-03: Jo explicitly asked for SideNav to be brought in line with Figma
16
+ * (stripe height and the rest). Pulled from nodes 40003954:284, 40005607:25240,
17
+ * 40004035:27057, 40006794:5975, 40007332:6995 and 40007332:8034. The collapse
18
+ * and expand glyphs stay pinned regardless; see the note at the bottom.
19
+ *
15
20
  * Spec: components/sidenav/sidenav-spec.md
16
21
  * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40004059-1375
17
22
  * SideNav.Container, a COMPONENT_SET. The id cited here until
@@ -74,7 +79,10 @@ export const T = {
74
79
  navHover: c("fill-action-selection-hover"),
75
80
  navActive: c("fill-action-selection-selected"),
76
81
  navTrail: c("fill-action-selection-trail"),
77
- infoSubtle: c("stroke-static-neutral-base"), // container right border
82
+ // Container right border (Mode=Stroked) and every divider: Figma binds
83
+ // Stroke/Static/Neutral/Faint on both (40004183:6254, 40006793:3783).
84
+ // Was stroke-static-neutral-base, a rung darker.
85
+ infoSubtle: c("stroke-static-neutral-faint"),
78
86
  },
79
87
  surface: {
80
88
  // Figma binds Fill/Surface/Elevated on SideNav.Container (node 40004059:1375).
@@ -96,8 +104,9 @@ export const T = {
96
104
  navHover: c("foreground-action-secondary-hover"),
97
105
  navActive: c("foreground-action-secondary-pressed"),
98
106
  navDisabled: c("foreground-action-disabled"),
99
- secondary: c("foreground-static-neutral-base"), // PopoverMenu.SectionLabel
100
- secondarySubtle: c("foreground-static-neutral-base"), // NavSectionLabel
107
+ secondary: c("foreground-static-neutral-subtle"), // PopoverMenu.SectionHeader
108
+ secondarySubtle: c("foreground-static-neutral-faint"), // NavSectionLabel
109
+ strong: c("foreground-static-neutral-strong"), // PopoverMenu.Item, tooltip
101
110
  },
102
111
  icon: {
103
112
  navBase: c("foreground-action-secondary-rest"),
@@ -106,10 +115,12 @@ export const T = {
106
115
  navDisabled: c("foreground-action-disabled"),
107
116
  actionSecondary: c("foreground-action-secondary-rest"), // CollapseButton
108
117
  },
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"),
118
+ // Figma binds Fill/Action/Selection/Indicator on indicator.stripe (node
119
+ // 40004035:27051). It was fill-action-primary-strong-pressed, which happens
120
+ // to resolve to the same brand-550 today but is not the bound token.
121
+ indicator: c("fill-action-selection-indicator"),
122
+ // Figma binds NavItem/CornerRadius (Contextual) on every SideNavItem variant.
123
+ radius: x("navitem-cornerradius"),
113
124
  };
114
125
 
115
126
  /*
@@ -148,19 +159,43 @@ export const L = {
148
159
  textPad: u("padding-xtight"),
149
160
  childIndent: 24, // no token in Figma, level-1 left indent
150
161
  stripeW: 4, // no token in Figma, indicator stripe width
162
+ // Indicator stripe insets, read from Figma 2026-11-03. On a 44px item the
163
+ // stripe sits inside container.indicator (padding 4 top/bottom) and then
164
+ // indicator.stripe (padding 6 top/bottom), so it paints 44 - 8 - 12 = 24px
165
+ // tall. The repo had only the outer 4px, which made it 36px.
166
+ stripeOuterPadV: u("padding-xxxtight"),
167
+ stripeInnerPadV: u("padding-xxtight"),
168
+ stripeRadius: u("cornerradius-base"), // Figma Border Radius/S, 8
169
+ // Expanded rows: container.rowStart padding. Level 0 is 6 (Padding/XXTight),
170
+ // so the leading icon starts at x=10. It was 12, which put it at x=16.
171
+ itemPadH: u("padding-xxtight"),
172
+ // Container.HasChildren: 2px padding around a 16px frame holding a 12px
173
+ // expand_more glyph, 20px wide in total. Was a 40px box with a 10px glyph.
174
+ chevPad: u("padding-xxxxtight"),
175
+ chevFrame: 16, // no token in Figma, icon frame
176
+ chevGlyph: 12, // no token in Figma, expand_more glyph
177
+ // Level 1 (nesting) rows: rowStart 12 + container.main 24 + label 8, so the
178
+ // label starts at x=48 after the 4px indicator column. Was 32 (x=36).
179
+ childRowPadL: u("padding-tight"),
180
+ childMainPadL: u("padding-relaxed"),
151
181
  colPadL: u("padding-medium"),
152
182
  colPadR: u("padding-tight"),
153
183
  collapseGap: u("padding-xxtight"),
154
184
  };
155
185
 
156
186
  // ─── IndicatorStripe ──────────────────────────────────────────────────────────
157
- export function IndicatorStripe({ visible }) {
187
+ // Figma: container.indicator (padding 4 vertical) > indicator.stripe (padding 6
188
+ // vertical) > the 4px bar. `inset` overrides the total vertical inset for the
189
+ // rows where Figma places the stripe differently (SideNavItem.List: 6 only).
190
+ export function IndicatorStripe({ visible, inset }) {
158
191
  return (
159
192
  <div style={{ width: L.stripeW, alignSelf: "stretch", display: "flex",
160
193
  flexDirection: "column", alignItems: "flex-start",
161
- justifyContent: "center", padding: "4px 0", flexShrink: 0 }}>
194
+ justifyContent: "center",
195
+ padding: inset ? `${inset} 0` : `calc(${L.stripeOuterPadV} + ${L.stripeInnerPadV}) 0`,
196
+ boxSizing: "border-box", flexShrink: 0 }}>
162
197
  <div style={{ flex: "1 0 0", width: L.stripeW, minHeight: 1,
163
- borderRadius: "0 8px 8px 0",
198
+ borderRadius: `0 ${L.stripeRadius} ${L.stripeRadius} 0`,
164
199
  backgroundColor: visible ? T.indicator : "transparent",
165
200
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
166
201
  </div>
@@ -263,7 +298,7 @@ export function SideNavItem({
263
298
  // Reported 2026-10-02. Auto margins hand the free space back to
264
299
  // both sides, which with the 6px right padding above reproduces
265
300
  // Figma's x=9 exactly.
266
- marginLeft: isSidebarCollapsed ? "auto" : L.rowPadH,
301
+ marginLeft: isSidebarCollapsed ? "auto" : L.itemPadH,
267
302
  marginRight: isSidebarCollapsed ? "auto" : undefined,
268
303
  color: iconColor }}>
269
304
  {typeof item.icon === "function"
@@ -297,8 +332,10 @@ export function SideNavItem({
297
332
  // box, so the label still occupied 8px of padding on the rail and ate
298
333
  // most of the free space the icon's auto margins needed: the icon
299
334
  // landed at x=5 instead of Figma's x=9.
300
- paddingLeft: isSidebarCollapsed ? 0 : (isChild ? 32 : L.textPad),
301
- paddingRight: isSidebarCollapsed ? 0 : ((!isChild && hasChildren) ? 0 : L.rowPadH),
335
+ paddingLeft: isSidebarCollapsed ? 0
336
+ : (isChild ? `calc(${L.childRowPadL} + ${L.childMainPadL} + ${L.textPad})` : L.textPad),
337
+ // container.rowStart's right padding: 6 on Level 0, 12 on Level 1.
338
+ paddingRight: isSidebarCollapsed ? 0 : (isChild ? L.childRowPadL : L.itemPadH),
302
339
  maxWidth: isSidebarCollapsed ? 0 : 200,
303
340
  opacity: isSidebarCollapsed ? 0 : 1,
304
341
  overflow: "hidden",
@@ -321,16 +358,15 @@ export function SideNavItem({
321
358
  2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
322
359
  should be one. It is now not rendered at all when collapsed. */}
323
360
  {!isChild && hasChildren && !isSidebarCollapsed && (
324
- <div style={{ width: isSidebarCollapsed ? 0 : 40, height: L.iconWrap, display: "flex",
361
+ <div style={{ width: `calc(${L.chevFrame}px + 2 * ${L.chevPad})`, height: L.iconWrap, display: "flex",
325
362
  alignItems: "center", justifyContent: "center", flexShrink: 0,
326
- maxWidth: isSidebarCollapsed ? 0 : 40,
327
363
  opacity: isSidebarCollapsed ? 0 : 1,
328
364
  overflow: "hidden",
329
365
  transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
330
366
  {/* Chevron rotation matches accordion timing, --motion-duration-5 + --motion-easing-accordion */}
331
367
  <div style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
332
368
  transition: "transform var(--motion-duration-5) var(--motion-easing-accordion)" }}>
333
- {chevronDown ? chevronDown({ size: 10, color: iconColor }) : <ChevDown size={10} color={iconColor} />}
369
+ {chevronDown ? chevronDown({ size: L.chevGlyph, color: iconColor }) : <ChevDown size={L.chevGlyph} color={iconColor} />}
334
370
  </div>
335
371
  </div>
336
372
  )}
@@ -380,11 +416,18 @@ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }
380
416
  top: anchorRect.top + anchorRect.height / 2,
381
417
  zIndex: 1000, backgroundColor: T.surface.navLight,
382
418
  border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
419
+ // Figma binds Elevation/Card/Rest here, which has no CSS token yet.
420
+ // Widget is the nearest existing elevation.
383
421
  boxShadow: c2("--elevation-widget"),
384
- padding: "6px 8px", whiteSpace: "nowrap", pointerEvents: "auto",
422
+ padding: "var(--semantic-layout-units-padding-xxtight) var(--semantic-layout-units-padding-xtight)",
423
+ whiteSpace: "nowrap", pointerEvents: "auto",
385
424
  animation: "popoverInCentered var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
425
+ {/* Text/Body/Small/Regular, Foreground/Static/Neutral/Strong (Figma
426
+ "Text Container", node 40005607:25190). Letter spacing is Figma's
427
+ Wide, 0.3px, which is the repo's `spacious` token: the repo's `wide`
428
+ is 0.1px. */}
386
429
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
387
- 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 }}>
430
+ fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)", color: T.text.strong }}>
388
431
  {label}
389
432
  </span>
390
433
  </div>,
@@ -393,17 +436,19 @@ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }
393
436
  }
394
437
 
395
438
  // ─── SectionLabel ─────────────────────────────────────────────────────────────
396
- // Figma node 40006794-5977, building block used in CollapsedPopover headers.
397
- // Tokens: Foreground/Static/Neutral/Base (#313131), Label/Section/Small/Semibold
398
- // (11px / 600 / 16px / 0.6px letter-spacing, uppercase)
399
- // Padding: Padding/Tight (12px left), Padding/XTight (8px vertical)
439
+ // The flyout's header is PopoverMenu.SectionHeader (node 40018992:3361), read
440
+ // from SideNavItem.Collapsed on 2026-11-03: Text/Supporting/Base/Semibold
441
+ // (12px / 600 / 18px / 0.6px), Foreground/Static/Neutral/Subtle, sentence case,
442
+ // padding 6 vertical, 12 left, 8 right. It was the 11px uppercase nav section
443
+ // label in neutral-base with a divider under it, which Figma does not have.
400
444
  export function SectionLabel({ label }) {
401
445
  return (
402
- <div style={{ display: "flex", alignItems: "center", width: "100%",
403
- paddingLeft: "var(--semantic-layout-units-padding-tight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)"}}>
446
+ <div style={{ display: "flex", alignItems: "center", width: "100%", boxSizing: "border-box",
447
+ paddingLeft: "var(--semantic-layout-units-padding-tight)", paddingRight: "var(--semantic-layout-units-padding-xtight)",
448
+ paddingTop: "var(--semantic-layout-units-padding-xxtight)", paddingBottom: "var(--semantic-layout-units-padding-xxtight)" }}>
404
449
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
405
- fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
406
- color: T.text.secondary, textTransform: "uppercase",
450
+ fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
451
+ color: T.text.secondary,
407
452
  whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
408
453
  {label}
409
454
  </span>
@@ -418,14 +463,15 @@ export function CollapsedPopover({ item, onClick, anchorRect, onMouseEnter, onMo
418
463
  <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
419
464
  style={{ position: "fixed", left: anchorRect.right + 8, top: anchorRect.top,
420
465
  zIndex: 1000, backgroundColor: T.surface.navElevated,
421
- border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
422
- boxShadow: c2("--elevation-widget"),
423
- padding: "var(--semantic-layout-units-padding-xxtight)", minWidth: 200, pointerEvents: "auto",
466
+ // Figma "Popover Menu" in SideNavItem.Collapsed: radius Unit/10, Elevation/Lift,
467
+ // 220 wide (220 to 360), Items slot padding 8 vertical, 6 horizontal, gap 2.
468
+ border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--primitive-unit-unit-10)",
469
+ boxShadow: c2("--elevation-lift"),
470
+ padding: "var(--semantic-layout-units-padding-xtight) var(--semantic-layout-units-padding-xxtight)",
471
+ display: "flex", flexDirection: "column", gap: "var(--semantic-layout-units-gap-xxxtight)",
472
+ boxSizing: "border-box", minWidth: 220, maxWidth: 360, pointerEvents: "auto",
424
473
  animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
425
- {/* Section label, Figma component 40006794-5977 */}
426
- <div style={{ borderBottom: `0.5px solid ${c("stroke-static-neutral-faint")}` }}>
427
- <SectionLabel label={item.label} />
428
- </div>
474
+ <SectionLabel label={item.label} />
429
475
  {item.children.map(child =>
430
476
  <PopoverRow key={child.id} item={child} onClick={onClick} activeId={activeId} />
431
477
  )}
@@ -439,19 +485,31 @@ export function PopoverRow({ item, onClick, activeId }) {
439
485
  const [h, setH] = useState(false);
440
486
  const isActive = item.id === activeId;
441
487
  const bg = isActive ? T.fill.navActive : h ? T.fill.navHover : "transparent";
442
- const color = isActive ? T.text.navActive : h ? T.text.navHover : T.text.navBase;
488
+ // PopoverMenu.Item label is Foreground/Static/Neutral/Strong in every state;
489
+ // the selected row is marked by its fill and the stripe, never by the text.
490
+ const color = T.text.strong;
443
491
  return (
444
492
  <div onClick={() => onClick(item.id)}
445
493
  onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
446
- style={{ display: "flex", alignItems: "center", minHeight: 40, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
494
+ // PopoverMenu.Item: min 44, padding 12 all round, radius CornerRadius/Base.
495
+ style={{ position: "relative", display: "flex", alignItems: "center", minHeight: L.itemH,
496
+ boxSizing: "border-box", padding: "var(--semantic-layout-units-padding-tight)",
497
+ borderRadius: "var(--semantic-layout-units-cornerradius-base)",
447
498
  overflow: "hidden", backgroundColor: bg, cursor: "pointer",
448
499
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
449
- <div style={{ width: 4, alignSelf: "stretch", borderRadius: "0 4px 4px 0",
450
- backgroundColor: isActive ? T.indicator : "transparent",
451
- flexShrink: 0, transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
452
- <span style={{ padding: "0 8px", fontFamily: "'Red Hat Text',sans-serif",
453
- fontWeight: 400, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)",
454
- color, transition: "color var(--motion-duration-3) var(--motion-easing-standard)", flex: 1 }}>
500
+ {/* Stripe: absolutely placed, inset 8 top and bottom, so 28px on a 44px row. */}
501
+ <div style={{ position: "absolute", left: 0, top: "var(--semantic-layout-units-padding-xtight)",
502
+ bottom: "var(--semantic-layout-units-padding-xtight)", width: L.stripeW,
503
+ borderRadius: `0 ${L.stripeRadius} ${L.stripeRadius} 0`,
504
+ // PopoverMenu.Item's Stripe binds Fill/Action/Primary/Strong/Pressed in
505
+ // Figma (40018914:3149), not the nav's Selection/Indicator. Same value
506
+ // in Light; in Midnight they differ, so this follows Figma.
507
+ backgroundColor: isActive ? c("fill-action-primary-strong-pressed") : "transparent",
508
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
509
+ {/* Label/Menu/Base/Medium: 14 / 500 / 20 / 0.3 (the repo's `spacious`). */}
510
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif",
511
+ fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
512
+ color, flex: 1 }}>
455
513
  {item.label}
456
514
  </span>
457
515
  </div>
@@ -515,8 +573,9 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
515
573
  // ─── SideNav (top-level container) ────────────────────────────────────────────
516
574
  // ── NavSectionLabel ──────────────────────────────────────────────────────────
517
575
  // Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
518
- // Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Base (#606060)
519
- // Container: h-[40px], pl-[4px], pr-[4px], py-[8px]
576
+ // Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Faint
577
+ // Container: h 40, padding 6 left, 6 right, 12 vertical (read 2026-11-03; was
578
+ // 4 / 4 / 8 in neutral-base)
520
579
  // In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
521
580
  // ── NavHeader ────────────────────────────────────────────────────────────────
522
581
  // Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
@@ -532,31 +591,30 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
532
591
  aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
533
592
  role="button" tabIndex={0}
534
593
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
594
+ // Figma SideBar Expand/Collapse: Container.Main 44 tall, the Action.Icon
595
+ // is a 44 touch target holding a 28px Container.Icon (radius Unit/10),
596
+ // which is what takes the hover fill. The whole row used to fill, and
597
+ // the icon box was 36. The glyphs themselves stay pinned (see below).
535
598
  style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
536
- borderRadius: T.radius, cursor: "pointer",
537
- backgroundColor: h ? T.fill.navHover : "transparent",
538
- transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
539
- {isSidebarCollapsed ? (
540
- /* Collapsed: centered action icon */
541
- <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
542
- {expandIcon
543
- ? expandIcon({ size: 12, color: actionIconColor })
544
- : <LeftPanelOpenIcon color={actionIconColor} />}
545
- </div>
546
- ) : (
547
- /* Expanded: right-aligned action icon in Slot.RowEnd */
548
- <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "flex-end" }}>
549
- <div style={{ width: 36, height: 36, display: "flex",
550
- alignItems: "center", justifyContent: "center" }}>
551
- {collapseIcon
552
- ? collapseIcon({ size: 12, color: actionIconColor })
553
- : <RightPanelOpenIcon color={actionIconColor} />}
554
- </div>
599
+ justifyContent: isSidebarCollapsed ? "center" : "flex-end", cursor: "pointer" }}>
600
+ <div style={{ width: L.itemH, height: L.itemH, display: "flex",
601
+ alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
602
+ <div style={{ width: 28, height: 28, display: "flex", alignItems: "center",
603
+ justifyContent: "center", borderRadius: "var(--primitive-unit-unit-10)",
604
+ backgroundColor: h ? T.fill.navHover : "transparent",
605
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
606
+ {isSidebarCollapsed
607
+ ? (expandIcon
608
+ ? expandIcon({ size: 12, color: actionIconColor })
609
+ : <LeftPanelOpenIcon color={actionIconColor} />)
610
+ : (collapseIcon
611
+ ? collapseIcon({ size: 12, color: actionIconColor })
612
+ : <RightPanelOpenIcon color={actionIconColor} />)}
555
613
  </div>
556
- )}
614
+ </div>
557
615
  </div>
558
- {/* Divider, Stroke/Static/Neutral/Base, py-[2px] in Figma */}
559
- <div style={{ padding: "2px 0" }}>
616
+ {/* Divider, padding 4 vertical in Figma (was 2) */}
617
+ <div style={{ padding: "var(--semantic-layout-units-padding-xxxtight) 0" }}>
560
618
  <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
561
619
  </div>
562
620
  </div>
@@ -565,8 +623,8 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
565
623
 
566
624
  export function NavSectionLabel({ label }) {
567
625
  return (
568
- <div style={{ display: "flex", alignItems: "center", height: 40, width: "100%",
569
- 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)",
626
+ <div style={{ display: "flex", alignItems: "center", height: 40, width: "100%", boxSizing: "border-box",
627
+ paddingLeft: "var(--semantic-layout-units-padding-xxtight)", paddingRight: "var(--semantic-layout-units-padding-xxtight)", paddingTop: "var(--semantic-layout-units-padding-tight)", paddingBottom: "var(--semantic-layout-units-padding-tight)",
570
628
  flexShrink: 0 }}>
571
629
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
572
630
  fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
@@ -579,12 +637,15 @@ export function NavSectionLabel({ label }) {
579
637
  }
580
638
 
581
639
  // ── BulletDot ────────────────────────────────────────────────────────────────
582
- // 6px filled circle, leading icon for SideNavListSection's ListItem.
583
- // Color cycles Base / Hover / Active just like SideNavItem icons.
640
+ // Leading icon for SideNavListSection's ListItem: Figma's Icon.Leading there is
641
+ // the Material "circle" glyph in a 16px frame (a 13.3px disc), node 40007332:6989.
642
+ // It was a 6px dot. Color cycles Base / Hover / Active just like SideNavItem icons.
584
643
  export function BulletDot({ color }) {
585
644
  return (
586
- <div style={{ width: 6, height: 6, borderRadius: "50%",
587
- backgroundColor: color, flexShrink: 0 }} />
645
+ <svg width={L.iconInner} height={L.iconInner} viewBox="0 0 16 16" aria-hidden="true"
646
+ style={{ display: "block", flexShrink: 0 }}>
647
+ <circle cx="8" cy="8" r="6.6667" fill={color} />
648
+ </svg>
588
649
  );
589
650
  }
590
651
 
@@ -602,17 +663,21 @@ export function ListItem({ item, isActive, onClick }) {
602
663
  onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
603
664
  role="button" tabIndex={0}
604
665
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
605
- style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
666
+ // SideNavItem.List is a fixed 48 tall (was min 44).
667
+ style={{ display: "flex", alignItems: "center", height: 48, minHeight: L.itemH, width: "100%",
606
668
  borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
607
669
  overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
608
670
  userSelect: "none" }}>
609
- <IndicatorStripe visible={isActive} />
671
+ {/* Figma places this stripe with indicator.stripe's 6px inset only, so it
672
+ is 36px on the 48px row. */}
673
+ <IndicatorStripe visible={isActive} inset={L.stripeInnerPadV} />
610
674
  <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
611
675
  alignItems: "center", justifyContent: "center", flexShrink: 0,
612
676
  marginLeft: L.rowPadH }}>
613
677
  <BulletDot color={dotColor} />
614
678
  </div>
615
- <div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: L.rowPadH }}>
679
+ {/* text.label padding 8 both sides, then container.rowStart's 12. */}
680
+ <div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: `calc(${L.textPad} + ${L.rowPadH})` }}>
616
681
  <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
617
682
  fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)", color,
618
683
  margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
@@ -777,7 +842,7 @@ export function SideNav({
777
842
  boxSizing: "border-box", flexShrink: 0,
778
843
  // Smooth-spring curve, soft personality, no overshoot.
779
844
  transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
780
- borderRight: `0.5px solid ${T.fill.infoSubtle}`,
845
+ borderRight: `1px solid ${T.fill.infoSubtle}`,
781
846
  overflow: "hidden", // the inner menu scrolls; the nav itself does not
782
847
  }}>
783
848
  {/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
@@ -897,17 +962,14 @@ export function SideNav({
897
962
  }
898
963
 
899
964
  // ─── Built-in chevron icons ───────────────────────────────────────────────────
900
- function ChevDown({ size = 10, color }) {
901
- return (
902
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
903
- <path d="M3 5.5L8 10.5L13 5.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
904
- </svg>
905
- );
906
- }
907
- function ChevUp({ size = 10, color }) {
965
+ // Material Symbols Rounded expand_more, the exact glyph Figma places in
966
+ // Container.HasChildren (node 40014002:8313), 12px box. The hand-drawn stroke
967
+ // chevron it replaces was heavier and 10px. Up is the same glyph rotated, which
968
+ // is what the item already does when it opens.
969
+ function ChevDown({ size = 12, color }) {
908
970
  return (
909
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
910
- <path d="M3 10.5L8 5.5L13 10.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
971
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden="true">
972
+ <path d="M6 7.475C5.93333 7.475 5.87083 7.46458 5.8125 7.44375C5.75417 7.42292 5.7 7.3875 5.65 7.3375L3.35 5.0375C3.25833 4.94583 3.2125 4.82917 3.2125 4.6875C3.2125 4.54583 3.25833 4.42917 3.35 4.3375C3.44167 4.24583 3.55833 4.2 3.7 4.2C3.84167 4.2 3.95833 4.24583 4.05 4.3375L6 6.2875L7.95 4.3375C8.04167 4.24583 8.15833 4.2 8.3 4.2C8.44167 4.2 8.55833 4.24583 8.65 4.3375C8.74167 4.42917 8.7875 4.54583 8.7875 4.6875C8.7875 4.82917 8.74167 4.94583 8.65 5.0375L6.35 7.3375C6.3 7.3875 6.24583 7.42292 6.1875 7.44375C6.12917 7.46458 6.06667 7.475 6 7.475Z" fill={color} />
911
973
  </svg>
912
974
  );
913
975
  }
@@ -150,7 +150,11 @@ export const T = {
150
150
  // the dark bar; Jo had Figma rebound to the OrgName token that day.
151
151
  orgSubText: SCD("foreground-action-ghost-rest"),
152
152
  avatarBg: SCD("fill-action-primary-subtle-rest"),
153
+ avatarBgHover: SCD("fill-action-primary-subtle-hover"),
154
+ avatarBgPressed: SCD("fill-action-primary-subtle-pressed"),
153
155
  avatarText: SCD("foreground-action-primary-on-subtle-rest"),
156
+ avatarTextHover: SCD("foreground-action-primary-on-subtle-hover"),
157
+ avatarTextPressed: SCD("foreground-action-primary-on-subtle-pressed"),
154
158
  // White dropdown-menu surface, tracks fill-neutral-light (now warm-neutral-0).
155
159
  panelBg: SCL("fill-static-neutral-faint"),
156
160
  activeItem: SCL("fill-action-primary-subtle-rest"),
@@ -180,7 +184,7 @@ export const L = {
180
184
  navH: c("topnav-height"),
181
185
  navGap: r("topnav-gap-horizontal"),
182
186
  touchTarget: 48, modInnerH: 36,
183
- orgAvatarNav: 20, orgAvatarSm: 24, orgAvatarPanel: 32,
187
+ orgAvatarNav: 16, orgAvatarSm: 24, orgAvatarPanel: 32,
184
188
  searchPill: 32, avatarSize: 32,
185
189
  // ICON GEOMETRY, read off the leaf nodes of Figma TopNav.Global 40007067:6508
186
190
  // on 2026-10-02 rather than inferred from container padding:
@@ -347,9 +351,12 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
347
351
  *
348
352
  * THE STATE FILL BELONGS ON THE 28 BOX, not on the touch target.
349
353
  */
354
+ // TopNav.Actions in Figma (40007082:7313): help, settings, notifications_unread,
355
+ // each a 16 glyph in a 28 layer in a 44 target. This drew "notifications" twice.
350
356
  export const DEFAULT_ACTIONS = [
351
- { icon: "notifications", label: "Notifications" },
352
- { icon: "notifications", label: "Alerts" },
357
+ { icon: "help", label: "Help" },
358
+ { icon: "settings", label: "Settings" },
359
+ { icon: "notifications_unread", label: "Notifications" },
353
360
  ];
354
361
 
355
362
  function TopNavActionIcon({ icon, label, onClick }) {
@@ -358,8 +365,11 @@ function TopNavActionIcon({ icon, label, onClick }) {
358
365
  const fill = press ? T.controlPressed : hov ? T.controlHover : "transparent";
359
366
  const fg = press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
360
367
  return (
368
+ // The 44 target is the button itself. It used to be a min-size wrapper
369
+ // with a height:100% button, which a min-height parent does not resolve,
370
+ // so the clickable box collapsed to the 28 layer.
361
371
  <div style={{ display: "flex", alignItems: "center", justifyContent: "center",
362
- minHeight: L.actionTarget, minWidth: L.actionTarget }}>
372
+ width: L.actionTarget, height: L.actionTarget, flexShrink: 0 }}>
363
373
  <button type="button" aria-label={label} onClick={onClick}
364
374
  onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
365
375
  onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
@@ -378,7 +388,8 @@ function TopNavActionIcon({ icon, label, onClick }) {
378
388
  }
379
389
 
380
390
  export function TopNavActions({ breakpoint = "desktop", actions, onNotifications, onMore }) {
381
- const items = (actions || DEFAULT_ACTIONS).map((a) => ({ ...a, onClick: a.onClick || onNotifications }));
391
+ const items = (actions || DEFAULT_ACTIONS).map((a) => ({
392
+ ...a, onClick: a.onClick || (a.icon === "notifications_unread" ? onNotifications : undefined) }));
382
393
  if (breakpoint === "mobile" && items.length > 1) {
383
394
  return (
384
395
  <div style={{ display: "flex", alignItems: "center" }}>
@@ -393,6 +404,14 @@ export function TopNavActions({ breakpoint = "desktop", actions, onNotifications
393
404
  );
394
405
  }
395
406
 
407
+ // SideNav.Control's hover and pressed (Figma 40007082:7703): Fill/Action/Ghost on
408
+ // the 32 layer and a Foreground/Action/Ghost glyph. ActionIcon's own ladder is
409
+ // for light pages and painted a pale layer under a dark glyph on the navy bar.
410
+ const GHOST_ACTION_STATES = {
411
+ hover: T.monoHover, pressed: T.monoPressed,
412
+ layerHover: T.controlHover, layerPress: T.controlPressed,
413
+ };
414
+
396
415
  // ─── TopNavProfile ─────────────────────────────────────────────────────────────
397
416
  export function TopNavProfile({ user, open, onToggle, mobile = false }) {
398
417
  const [hov, setHov] = useState(false);
@@ -410,21 +429,27 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
410
429
  display: "flex", alignItems: "center", justifyContent: "center",
411
430
  // Figma TopNav.Profile is 48x48 with pad 4,4,4,4. This was 44 with 6.
412
431
  width: L.touchTarget, height: L.touchTarget, boxSizing: "border-box",
413
- background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
432
+ // The 48 box takes no fill in any state (Figma TopNav.Profile,
433
+ // 40016590:37966): hover and pressed land on the avatar itself, on
434
+ // the Primary/Subtle ladder. This used to paint a Ghost circle
435
+ // behind an avatar that never changed.
436
+ background: "transparent",
414
437
  border: "none", borderRadius: "50%", cursor: "pointer", padding: L.profilePad,
415
438
  transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
416
439
  }}
417
440
  >
418
441
  <div style={{
419
442
  width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
420
- background: T.avatarBg, display: "flex", alignItems: "center", justifyContent: "center",
443
+ background: open ? T.avatarBgPressed : hov ? T.avatarBgHover : T.avatarBg,
444
+ display: "flex", alignItems: "center", justifyContent: "center",
445
+ transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)",
421
446
  // Text/Body/Small/Semibold at EVERY breakpoint, read 2026-10-30. The
422
447
  // mobile 11px this used to drop to is not in Figma. Figma names the
423
448
  // 0.3 tracking Letter Spacing/Wide; the CSS emits 0.3 as -spacious.
424
449
  fontFamily: "var(--semantic-type-family-brand)",
425
450
  fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)",
426
451
  lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
427
- color: T.avatarText, flexShrink: 0, overflow: "hidden",
452
+ color: open ? T.avatarTextPressed : hov ? T.avatarTextHover : T.avatarText, flexShrink: 0, overflow: "hidden",
428
453
  }}>
429
454
  {user.avatarUrl
430
455
  ? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
@@ -482,7 +507,10 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
482
507
  style={{
483
508
  // Container.Main: gap Gap/XXXTight between RowStart and RowEnd,
484
509
  // padding 6,6,6,12 desktop and 6,4,6,8 mobile, 36 tall.
485
- display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)",
510
+ // Desktop places the chevron INSIDE Container.RowStart, whose gap is
511
+ // Gap/XXTight (4); mobile keeps it at Main level on Gap/XXXTight (2).
512
+ display: "flex", alignItems: "center",
513
+ gap: mobile ? "var(--semantic-layout-units-gap-xxxtight)" : "var(--semantic-layout-units-gap-xxtight)",
486
514
  minHeight: L.modInnerH, boxSizing: "border-box", minWidth: 0,
487
515
  maxWidth: mobile ? L.orgMainMaxMobile : L.orgMainMax,
488
516
  padding: mobile
@@ -495,7 +523,9 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
495
523
  // Drawn as an inset shadow because Figma strokes INSIDE the frame:
496
524
  // a CSS border adds to the box and made Container.Main 37.5, not 36.
497
525
  border: "none",
498
- boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
526
+ // Hover and pressed thicken to Button/BorderWidth/Selected (1px), as
527
+ // Figma binds them on 40006819:14582 and 40006933:15754.
528
+ boxShadow: `inset 0 0 0 ${open || hov ? "var(--contextual-layout-units-button-borderwidth-selected)" : "var(--contextual-layout-units-button-borderwidth-rest)"} ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
499
529
  cursor: "pointer", color: fg, fontFamily: "inherit",
500
530
  transition: "background var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)",
501
531
  }}
@@ -505,11 +535,16 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
505
535
  height: mobile ? 20 : 24, minWidth: 0 }}>
506
536
  {/* Logo avatar, only when a URL is provided (Figma "Show Org Avatar", default false) */}
507
537
  {hasLogo && (
508
- <div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxxtight)",
538
+ // Figma "Show Org Avatar": a 24 box with Padding/XXXTight, so a 16
539
+ // avatar with a 1px Stroke/Action/Primary/Strong/Rest ring. It was
540
+ // padded 2, a 20 avatar, ringed in Ghost.
541
+ <div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxtight)",
542
+ boxSizing: "border-box",
509
543
  display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
510
544
  <div style={{
511
545
  width: L.orgAvatarNav, height: L.orgAvatarNav, borderRadius: L.radiusSm,
512
- border: `1px solid ${T.orgStroke}`,
546
+ border: `1px solid ${SCD("stroke-action-primary-strong-rest")}`,
547
+ boxSizing: "border-box",
513
548
  background: "transparent",
514
549
  display: "flex", alignItems: "center", justifyContent: "center",
515
550
  overflow: "hidden", position: "relative", flexShrink: 0,
@@ -566,12 +601,13 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
566
601
  */
567
602
  export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint = "desktop", type = "interactive" }) {
568
603
  const [hov, setHov] = useState(false);
604
+ const [press, setPress] = useState(false);
569
605
  const active = modules.find(m => m.id === activeId) || modules[0];
570
606
  const showLabel = breakpoint === "desktop";
571
607
  const isStatic = type === "static";
572
608
  // The label follows the state (Ghost Rest, Hover, Pressed for Pressed and
573
609
  // Open); the mark and the chevron stay on Rest, as in Figma.
574
- const labelFg = isStatic ? T.monoBase : open ? T.monoPressed : hov ? T.monoHover : T.monoBase;
610
+ const labelFg = isStatic ? T.monoBase : open || press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
575
611
 
576
612
  // Icon + label: identical in both variants. Container.RowStart, gap Gap/XXTight.
577
613
  const inner = (
@@ -608,7 +644,7 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
608
644
  // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
609
645
  // This was a uniform 4 on all four sides.
610
646
  padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
611
- background: "transparent", border: "1px solid transparent",
647
+ background: "transparent", border: "none", boxSizing: "border-box",
612
648
  color: T.monoBase, fontFamily: "inherit", cursor: "default",
613
649
  }}
614
650
  >
@@ -623,24 +659,28 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
623
659
  // The 48 slot, Padding/XXTight by Padding/XXXTight. This was a raw 4px 2px.
624
660
  <div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
625
661
  <button
626
- onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
662
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
663
+ onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
627
664
  onClick={onToggle}
628
665
  aria-haspopup="listbox" aria-expanded={open}
629
666
  aria-label={`Switch module: ${active.label}`}
630
667
  style={{
631
- // Container.Main: gap Gap/XXXTight between RowStart and the chevron.
632
- display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)",
668
+ // Container.Main: gap Gap/XXXTight between RowStart and the chevron
669
+ // on desktop. With the label hidden (tablet, mobile) Figma has no gap:
670
+ // the mark and the chevron touch.
671
+ display: "flex", alignItems: "center", gap: showLabel ? "var(--semantic-layout-units-gap-xxxtight)" : 0,
633
672
  maxHeight: L.modInnerH, minHeight: L.modInnerH,
634
673
  // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
635
674
  // This was a uniform 4 on all four sides.
636
675
  padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
637
- background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
638
- // No stroke in any state, Figma's ModuleSwitcher has none, unlike the
639
- // org switcher next to it. Transparent rather than none so the two
640
- // controls keep the same box height.
641
- border: "1px solid transparent",
676
+ background: open || press ? T.controlPressed : hov ? T.controlHover : "transparent",
677
+ // No stroke in any state: Figma's ModuleSwitcher has none. This was a
678
+ // 1px transparent border, which inset the content by 1px (the mark sat
679
+ // at x=7, not 6). The org switcher draws its stroke as an inset
680
+ // shadow, so the two still share the 36 box height.
681
+ border: "none", boxSizing: "border-box",
642
682
  cursor: "pointer", color: T.monoBase, fontFamily: "inherit",
643
- transition: "background var(--motion-duration-3) var(--motion-easing-standard), border-color var(--motion-duration-3) var(--motion-easing-standard)",
683
+ transition: "background var(--motion-duration-3) var(--motion-easing-standard)",
644
684
  }}
645
685
  >
646
686
  {inner}
@@ -906,7 +946,7 @@ function TopNavNewCo({ navRef, className, breakpoint, org, user, openPanel, togg
906
946
  gap: isMobile ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
907
947
  {isMobile ? (
908
948
  <>
909
- <ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} onClick={onSideNavToggle} />
949
+ <ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} states={GHOST_ACTION_STATES} onClick={onSideNavToggle} />
910
950
  <PillarTrigger label={brandItem.label} open={openPanel === "pillar"} onToggle={() => toggle("pillar")} />
911
951
  {org_}
912
952
  </>
@@ -1128,6 +1168,7 @@ export function TopNav({
1128
1168
  size="Large"
1129
1169
  label="Open navigation menu"
1130
1170
  color={T.monoBase}
1171
+ states={GHOST_ACTION_STATES}
1131
1172
  onClick={onSideNavToggle}
1132
1173
  />
1133
1174
  )}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,