@helloimjolopez-pathway/pathway-components 0.2.2 → 0.2.3

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
  >
@@ -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,28 @@ 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",
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`,
450
504
  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 }}>
505
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
506
+ {/* Label/Menu/Base/Medium: 14 / 500 / 20 / 0.3 (the repo's `spacious`). */}
507
+ <span style={{ fontFamily: "'Red Hat Text',sans-serif",
508
+ fontWeight: 500, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
509
+ color, flex: 1 }}>
455
510
  {item.label}
456
511
  </span>
457
512
  </div>
@@ -515,8 +570,9 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
515
570
  // ─── SideNav (top-level container) ────────────────────────────────────────────
516
571
  // ── NavSectionLabel ──────────────────────────────────────────────────────────
517
572
  // 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]
573
+ // Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Faint
574
+ // Container: h 40, padding 6 left, 6 right, 12 vertical (read 2026-11-03; was
575
+ // 4 / 4 / 8 in neutral-base)
520
576
  // In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
521
577
  // ── NavHeader ────────────────────────────────────────────────────────────────
522
578
  // Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
@@ -532,31 +588,30 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
532
588
  aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
533
589
  role="button" tabIndex={0}
534
590
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onToggle()}
591
+ // Figma SideBar Expand/Collapse: Container.Main 44 tall, the Action.Icon
592
+ // is a 44 touch target holding a 28px Container.Icon (radius Unit/10),
593
+ // which is what takes the hover fill. The whole row used to fill, and
594
+ // the icon box was 36. The glyphs themselves stay pinned (see below).
535
595
  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>
596
+ justifyContent: isSidebarCollapsed ? "center" : "flex-end", cursor: "pointer" }}>
597
+ <div style={{ width: L.itemH, height: L.itemH, display: "flex",
598
+ alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
599
+ <div style={{ width: 28, height: 28, display: "flex", alignItems: "center",
600
+ justifyContent: "center", borderRadius: "var(--primitive-unit-unit-10)",
601
+ backgroundColor: h ? T.fill.navHover : "transparent",
602
+ transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
603
+ {isSidebarCollapsed
604
+ ? (expandIcon
605
+ ? expandIcon({ size: 12, color: actionIconColor })
606
+ : <LeftPanelOpenIcon color={actionIconColor} />)
607
+ : (collapseIcon
608
+ ? collapseIcon({ size: 12, color: actionIconColor })
609
+ : <RightPanelOpenIcon color={actionIconColor} />)}
555
610
  </div>
556
- )}
611
+ </div>
557
612
  </div>
558
- {/* Divider, Stroke/Static/Neutral/Base, py-[2px] in Figma */}
559
- <div style={{ padding: "2px 0" }}>
613
+ {/* Divider, padding 4 vertical in Figma (was 2) */}
614
+ <div style={{ padding: "var(--semantic-layout-units-padding-xxxtight) 0" }}>
560
615
  <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
561
616
  </div>
562
617
  </div>
@@ -565,8 +620,8 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
565
620
 
566
621
  export function NavSectionLabel({ label }) {
567
622
  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)",
623
+ <div style={{ display: "flex", alignItems: "center", height: 40, width: "100%", boxSizing: "border-box",
624
+ 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
625
  flexShrink: 0 }}>
571
626
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
572
627
  fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
@@ -579,12 +634,15 @@ export function NavSectionLabel({ label }) {
579
634
  }
580
635
 
581
636
  // ── BulletDot ────────────────────────────────────────────────────────────────
582
- // 6px filled circle, leading icon for SideNavListSection's ListItem.
583
- // Color cycles Base / Hover / Active just like SideNavItem icons.
637
+ // Leading icon for SideNavListSection's ListItem: Figma's Icon.Leading there is
638
+ // the Material "circle" glyph in a 16px frame (a 13.3px disc), node 40007332:6989.
639
+ // It was a 6px dot. Color cycles Base / Hover / Active just like SideNavItem icons.
584
640
  export function BulletDot({ color }) {
585
641
  return (
586
- <div style={{ width: 6, height: 6, borderRadius: "50%",
587
- backgroundColor: color, flexShrink: 0 }} />
642
+ <svg width={L.iconInner} height={L.iconInner} viewBox="0 0 16 16" aria-hidden="true"
643
+ style={{ display: "block", flexShrink: 0 }}>
644
+ <circle cx="8" cy="8" r="6.6667" fill={color} />
645
+ </svg>
588
646
  );
589
647
  }
590
648
 
@@ -602,17 +660,21 @@ export function ListItem({ item, isActive, onClick }) {
602
660
  onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
603
661
  role="button" tabIndex={0}
604
662
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
605
- style={{ display: "flex", alignItems: "center", minHeight: L.itemH, width: "100%",
663
+ // SideNavItem.List is a fixed 48 tall (was min 44).
664
+ style={{ display: "flex", alignItems: "center", height: 48, minHeight: L.itemH, width: "100%",
606
665
  borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
607
666
  overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
608
667
  userSelect: "none" }}>
609
- <IndicatorStripe visible={isActive} />
668
+ {/* Figma places this stripe with indicator.stripe's 6px inset only, so it
669
+ is 36px on the 48px row. */}
670
+ <IndicatorStripe visible={isActive} inset={L.stripeInnerPadV} />
610
671
  <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
611
672
  alignItems: "center", justifyContent: "center", flexShrink: 0,
612
673
  marginLeft: L.rowPadH }}>
613
674
  <BulletDot color={dotColor} />
614
675
  </div>
615
- <div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: L.rowPadH }}>
676
+ {/* text.label padding 8 both sides, then container.rowStart's 12. */}
677
+ <div style={{ flex: 1, minWidth: 0, paddingLeft: L.textPad, paddingRight: `calc(${L.textPad} + ${L.rowPadH})` }}>
616
678
  <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
617
679
  fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)", color,
618
680
  margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
@@ -777,7 +839,7 @@ export function SideNav({
777
839
  boxSizing: "border-box", flexShrink: 0,
778
840
  // Smooth-spring curve, soft personality, no overshoot.
779
841
  transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
780
- borderRight: `0.5px solid ${T.fill.infoSubtle}`,
842
+ borderRight: `1px solid ${T.fill.infoSubtle}`,
781
843
  overflow: "hidden", // the inner menu scrolls; the nav itself does not
782
844
  }}>
783
845
  {/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
@@ -897,17 +959,14 @@ export function SideNav({
897
959
  }
898
960
 
899
961
  // ─── 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 }) {
962
+ // Material Symbols Rounded expand_more, the exact glyph Figma places in
963
+ // Container.HasChildren (node 40014002:8313), 12px box. The hand-drawn stroke
964
+ // chevron it replaces was heavier and 10px. Up is the same glyph rotated, which
965
+ // is what the item already does when it opens.
966
+ function ChevDown({ size = 12, color }) {
908
967
  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" />
968
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden="true">
969
+ <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
970
  </svg>
912
971
  );
913
972
  }
@@ -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:
@@ -393,6 +397,14 @@ export function TopNavActions({ breakpoint = "desktop", actions, onNotifications
393
397
  );
394
398
  }
395
399
 
400
+ // SideNav.Control's hover and pressed (Figma 40007082:7703): Fill/Action/Ghost on
401
+ // the 32 layer and a Foreground/Action/Ghost glyph. ActionIcon's own ladder is
402
+ // for light pages and painted a pale layer under a dark glyph on the navy bar.
403
+ const GHOST_ACTION_STATES = {
404
+ hover: T.monoHover, pressed: T.monoPressed,
405
+ layerHover: T.controlHover, layerPress: T.controlPressed,
406
+ };
407
+
396
408
  // ─── TopNavProfile ─────────────────────────────────────────────────────────────
397
409
  export function TopNavProfile({ user, open, onToggle, mobile = false }) {
398
410
  const [hov, setHov] = useState(false);
@@ -410,21 +422,27 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
410
422
  display: "flex", alignItems: "center", justifyContent: "center",
411
423
  // Figma TopNav.Profile is 48x48 with pad 4,4,4,4. This was 44 with 6.
412
424
  width: L.touchTarget, height: L.touchTarget, boxSizing: "border-box",
413
- background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
425
+ // The 48 box takes no fill in any state (Figma TopNav.Profile,
426
+ // 40016590:37966): hover and pressed land on the avatar itself, on
427
+ // the Primary/Subtle ladder. This used to paint a Ghost circle
428
+ // behind an avatar that never changed.
429
+ background: "transparent",
414
430
  border: "none", borderRadius: "50%", cursor: "pointer", padding: L.profilePad,
415
431
  transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
416
432
  }}
417
433
  >
418
434
  <div style={{
419
435
  width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
420
- background: T.avatarBg, display: "flex", alignItems: "center", justifyContent: "center",
436
+ background: open ? T.avatarBgPressed : hov ? T.avatarBgHover : T.avatarBg,
437
+ display: "flex", alignItems: "center", justifyContent: "center",
438
+ transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)",
421
439
  // Text/Body/Small/Semibold at EVERY breakpoint, read 2026-10-30. The
422
440
  // mobile 11px this used to drop to is not in Figma. Figma names the
423
441
  // 0.3 tracking Letter Spacing/Wide; the CSS emits 0.3 as -spacious.
424
442
  fontFamily: "var(--semantic-type-family-brand)",
425
443
  fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)",
426
444
  lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
427
- color: T.avatarText, flexShrink: 0, overflow: "hidden",
445
+ color: open ? T.avatarTextPressed : hov ? T.avatarTextHover : T.avatarText, flexShrink: 0, overflow: "hidden",
428
446
  }}>
429
447
  {user.avatarUrl
430
448
  ? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
@@ -482,7 +500,10 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
482
500
  style={{
483
501
  // Container.Main: gap Gap/XXXTight between RowStart and RowEnd,
484
502
  // 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)",
503
+ // Desktop places the chevron INSIDE Container.RowStart, whose gap is
504
+ // Gap/XXTight (4); mobile keeps it at Main level on Gap/XXXTight (2).
505
+ display: "flex", alignItems: "center",
506
+ gap: mobile ? "var(--semantic-layout-units-gap-xxxtight)" : "var(--semantic-layout-units-gap-xxtight)",
486
507
  minHeight: L.modInnerH, boxSizing: "border-box", minWidth: 0,
487
508
  maxWidth: mobile ? L.orgMainMaxMobile : L.orgMainMax,
488
509
  padding: mobile
@@ -495,7 +516,9 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
495
516
  // Drawn as an inset shadow because Figma strokes INSIDE the frame:
496
517
  // a CSS border adds to the box and made Container.Main 37.5, not 36.
497
518
  border: "none",
498
- boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
519
+ // Hover and pressed thicken to Button/BorderWidth/Selected (1px), as
520
+ // Figma binds them on 40006819:14582 and 40006933:15754.
521
+ 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
522
  cursor: "pointer", color: fg, fontFamily: "inherit",
500
523
  transition: "background var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)",
501
524
  }}
@@ -505,11 +528,16 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
505
528
  height: mobile ? 20 : 24, minWidth: 0 }}>
506
529
  {/* Logo avatar, only when a URL is provided (Figma "Show Org Avatar", default false) */}
507
530
  {hasLogo && (
508
- <div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxxtight)",
531
+ // Figma "Show Org Avatar": a 24 box with Padding/XXXTight, so a 16
532
+ // avatar with a 1px Stroke/Action/Primary/Strong/Rest ring. It was
533
+ // padded 2, a 20 avatar, ringed in Ghost.
534
+ <div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxtight)",
535
+ boxSizing: "border-box",
509
536
  display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
510
537
  <div style={{
511
538
  width: L.orgAvatarNav, height: L.orgAvatarNav, borderRadius: L.radiusSm,
512
- border: `1px solid ${T.orgStroke}`,
539
+ border: `1px solid ${SCD("stroke-action-primary-strong-rest")}`,
540
+ boxSizing: "border-box",
513
541
  background: "transparent",
514
542
  display: "flex", alignItems: "center", justifyContent: "center",
515
543
  overflow: "hidden", position: "relative", flexShrink: 0,
@@ -566,12 +594,13 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
566
594
  */
567
595
  export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint = "desktop", type = "interactive" }) {
568
596
  const [hov, setHov] = useState(false);
597
+ const [press, setPress] = useState(false);
569
598
  const active = modules.find(m => m.id === activeId) || modules[0];
570
599
  const showLabel = breakpoint === "desktop";
571
600
  const isStatic = type === "static";
572
601
  // The label follows the state (Ghost Rest, Hover, Pressed for Pressed and
573
602
  // 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;
603
+ const labelFg = isStatic ? T.monoBase : open || press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
575
604
 
576
605
  // Icon + label: identical in both variants. Container.RowStart, gap Gap/XXTight.
577
606
  const inner = (
@@ -608,7 +637,7 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
608
637
  // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
609
638
  // This was a uniform 4 on all four sides.
610
639
  padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
611
- background: "transparent", border: "1px solid transparent",
640
+ background: "transparent", border: "none", boxSizing: "border-box",
612
641
  color: T.monoBase, fontFamily: "inherit", cursor: "default",
613
642
  }}
614
643
  >
@@ -623,24 +652,28 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
623
652
  // The 48 slot, Padding/XXTight by Padding/XXXTight. This was a raw 4px 2px.
624
653
  <div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
625
654
  <button
626
- onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
655
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
656
+ onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
627
657
  onClick={onToggle}
628
658
  aria-haspopup="listbox" aria-expanded={open}
629
659
  aria-label={`Switch module: ${active.label}`}
630
660
  style={{
631
- // Container.Main: gap Gap/XXXTight between RowStart and the chevron.
632
- display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)",
661
+ // Container.Main: gap Gap/XXXTight between RowStart and the chevron
662
+ // on desktop. With the label hidden (tablet, mobile) Figma has no gap:
663
+ // the mark and the chevron touch.
664
+ display: "flex", alignItems: "center", gap: showLabel ? "var(--semantic-layout-units-gap-xxxtight)" : 0,
633
665
  maxHeight: L.modInnerH, minHeight: L.modInnerH,
634
666
  // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
635
667
  // This was a uniform 4 on all four sides.
636
668
  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",
669
+ background: open || press ? T.controlPressed : hov ? T.controlHover : "transparent",
670
+ // No stroke in any state: Figma's ModuleSwitcher has none. This was a
671
+ // 1px transparent border, which inset the content by 1px (the mark sat
672
+ // at x=7, not 6). The org switcher draws its stroke as an inset
673
+ // shadow, so the two still share the 36 box height.
674
+ border: "none", boxSizing: "border-box",
642
675
  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)",
676
+ transition: "background var(--motion-duration-3) var(--motion-easing-standard)",
644
677
  }}
645
678
  >
646
679
  {inner}
@@ -906,7 +939,7 @@ function TopNavNewCo({ navRef, className, breakpoint, org, user, openPanel, togg
906
939
  gap: isMobile ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
907
940
  {isMobile ? (
908
941
  <>
909
- <ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} onClick={onSideNavToggle} />
942
+ <ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} states={GHOST_ACTION_STATES} onClick={onSideNavToggle} />
910
943
  <PillarTrigger label={brandItem.label} open={openPanel === "pillar"} onToggle={() => toggle("pillar")} />
911
944
  {org_}
912
945
  </>
@@ -1128,6 +1161,7 @@ export function TopNav({
1128
1161
  size="Large"
1129
1162
  label="Open navigation menu"
1130
1163
  color={T.monoBase}
1164
+ states={GHOST_ACTION_STATES}
1131
1165
  onClick={onSideNavToggle}
1132
1166
  />
1133
1167
  )}
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.3",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,