@helloimjolopez-pathway/pathway-components 0.2.1 → 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.
@@ -398,8 +398,8 @@ export function Button({
398
398
  };
399
399
 
400
400
  // Icon slot wrapper, iconWrap = outer slot size, iconInner = rendered glyph size.
401
- // fontVariationSettings overrides the global opsz:24 from preview-head.html, // all button icon sizes (14 to 18px) sit below the minimum opsz axis value (20),
402
- // so opsz:20 is the correct optical size for the tightest stroke rendering.
401
+ // opsz 24, as Figma draws its icons, so the glyph fills the slot in Figma's
402
+ // proportion (changed 2026-11-03; opsz 20 drew it about 6% small).
403
403
  const iconWrapStyle = {
404
404
  display: "inline-flex",
405
405
  alignItems: "center",
@@ -410,7 +410,7 @@ export function Button({
410
410
  fontSize: sz.iconInner,
411
411
  color: iconColor,
412
412
  transition: "color var(--motion-duration-2) var(--motion-easing-standard)",
413
- fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20",
413
+ fontVariationSettings: "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24",
414
414
  };
415
415
 
416
416
  // Outer button element, handles the touch-target zone
@@ -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={{
@@ -34,9 +34,12 @@
34
34
  * SideNav is FILL 1 throughout; the Widget header is FILL 0.
35
35
  * wght 400 everywhere in Pathway.
36
36
  * GRAD 0 everywhere in Pathway.
37
- * opsz matched to the font size, which is what keeps the stroke weight even
38
- * across sizes. A 16px icon at opsz 20 is visibly lighter than it
39
- * should be, and that defect shipped once in the SideNav rail.
37
+ * opsz 24 everywhere in Pathway (changed 2026-11-03). Figma's icons are the
38
+ * font's opsz-24 artwork scaled to each frame, so the glyph fills its
39
+ * box in the same proportion at every size. Tracking opsz to the frame
40
+ * (which the font clamps to 20 below 20px) redrew each glyph about 6%
41
+ * smaller inside the same box: a 16 help icon drew 12.5 against
42
+ * Figma's 13.33.
40
43
  *
41
44
  * SIZE IS THE FRAME, NEVER THE VECTOR. Figma nests a wrapper (24), an icon
42
45
  * frame (16) and the drawn vector (~12), because the Material grid has about
@@ -58,8 +61,8 @@ export const ICON_SOURCES = {
58
61
  webSymbols: "https://github.com/google/material-design-icons/tree/master/symbols/web",
59
62
  };
60
63
 
61
- /** Pathway's fixed axis values. Only FILL and opsz ever vary. */
62
- export const AXES = { wght: 400, GRAD: 0 };
64
+ /** Pathway's fixed axis values. Only FILL ever varies. */
65
+ export const AXES = { wght: 400, GRAD: 0, opsz: 24 };
63
66
 
64
67
  /**
65
68
  * The per-size table from docs/design-system-spec.md §7.2. `wrapper` is the
@@ -109,10 +112,9 @@ export function Icon({
109
112
  display: "block",
110
113
  flexShrink: 0,
111
114
  color,
112
- // opsz TRACKS THE FONT SIZE. Pinning it to 20 while the glyph renders
113
- // at 16 makes the stroke too light, which is what went wrong in the
114
- // collapsed SideNav rail.
115
- fontVariationSettings: `'FILL' ${fill}, 'wght' ${AXES.wght}, 'GRAD' ${AXES.GRAD}, 'opsz' ${frame}`,
115
+ // opsz is fixed at 24, Figma's artwork, so the glyph fills the frame
116
+ // exactly as it does in Figma. See AXES.
117
+ fontVariationSettings: `'FILL' ${fill}, 'wght' ${AXES.wght}, 'GRAD' ${AXES.GRAD}, 'opsz' ${AXES.opsz}`,
116
118
  ...style,
117
119
  }}
118
120
  {...rest}
@@ -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"
@@ -276,10 +311,9 @@ export function SideNavItem({
276
311
  // parent is centred by its line box, not by its own art, so
277
312
  // it rode high inside the 24px wrap.
278
313
  display: "block",
279
- // opsz MUST match fontSize. It said 20 against a 16px size,
280
- // so the glyph was drawn on a 20px grid and scaled down into
281
- // a 16px box, which shifts the art off centre.
282
- fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' ${L.iconInner}` }}>
314
+ // opsz 24, as Figma draws its icons, so the glyph fills the
315
+ // box in Figma's proportion (changed 2026-11-03).
316
+ fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24` }}>
283
317
  {item.icon}
284
318
  </span>
285
319
  : React.cloneElement(item.icon, { style: { color: iconColor } })}
@@ -298,8 +332,10 @@ export function SideNavItem({
298
332
  // box, so the label still occupied 8px of padding on the rail and ate
299
333
  // most of the free space the icon's auto margins needed: the icon
300
334
  // landed at x=5 instead of Figma's x=9.
301
- paddingLeft: isSidebarCollapsed ? 0 : (isChild ? 32 : L.textPad),
302
- paddingRight: isSidebarCollapsed ? 0 : ((!isChild && hasChildren) ? 0 : L.rowPadH),
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),
303
339
  maxWidth: isSidebarCollapsed ? 0 : 200,
304
340
  opacity: isSidebarCollapsed ? 0 : 1,
305
341
  overflow: "hidden",
@@ -322,16 +358,15 @@ export function SideNavItem({
322
358
  2026-10-02: two distinct glyph centres, 28.8 and 45.8, where there
323
359
  should be one. It is now not rendered at all when collapsed. */}
324
360
  {!isChild && hasChildren && !isSidebarCollapsed && (
325
- <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",
326
362
  alignItems: "center", justifyContent: "center", flexShrink: 0,
327
- maxWidth: isSidebarCollapsed ? 0 : 40,
328
363
  opacity: isSidebarCollapsed ? 0 : 1,
329
364
  overflow: "hidden",
330
365
  transition: "max-width var(--motion-duration-6) var(--motion-easing-emphasized), opacity var(--motion-duration-3) var(--motion-easing-standard)" }}>
331
366
  {/* Chevron rotation matches accordion timing, --motion-duration-5 + --motion-easing-accordion */}
332
367
  <div style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
333
368
  transition: "transform var(--motion-duration-5) var(--motion-easing-accordion)" }}>
334
- {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} />}
335
370
  </div>
336
371
  </div>
337
372
  )}
@@ -381,11 +416,18 @@ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }
381
416
  top: anchorRect.top + anchorRect.height / 2,
382
417
  zIndex: 1000, backgroundColor: T.surface.navLight,
383
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.
384
421
  boxShadow: c2("--elevation-widget"),
385
- 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",
386
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. */}
387
429
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
388
- fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)", color: T.text.secondary }}>
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 }}>
389
431
  {label}
390
432
  </span>
391
433
  </div>,
@@ -394,17 +436,19 @@ export function SideNavTooltip({ label, anchorRect, onMouseEnter, onMouseLeave }
394
436
  }
395
437
 
396
438
  // ─── SectionLabel ─────────────────────────────────────────────────────────────
397
- // Figma node 40006794-5977, building block used in CollapsedPopover headers.
398
- // Tokens: Foreground/Static/Neutral/Base (#313131), Label/Section/Small/Semibold
399
- // (11px / 600 / 16px / 0.6px letter-spacing, uppercase)
400
- // Padding: Padding/Tight (12px left), Padding/XTight (8px vertical)
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.
401
444
  export function SectionLabel({ label }) {
402
445
  return (
403
- <div style={{ display: "flex", alignItems: "center", width: "100%",
404
- paddingLeft: "var(--semantic-layout-units-padding-tight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)"}}>
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)" }}>
405
449
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
406
- fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
407
- color: T.text.secondary, textTransform: "uppercase",
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,
408
452
  whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
409
453
  {label}
410
454
  </span>
@@ -419,14 +463,15 @@ export function CollapsedPopover({ item, onClick, anchorRect, onMouseEnter, onMo
419
463
  <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
420
464
  style={{ position: "fixed", left: anchorRect.right + 8, top: anchorRect.top,
421
465
  zIndex: 1000, backgroundColor: T.surface.navElevated,
422
- border: `0.5px solid ${c("stroke-static-neutral-faint")}`, borderRadius: "var(--semantic-layout-units-cornerradius-base)",
423
- boxShadow: c2("--elevation-widget"),
424
- padding: "var(--semantic-layout-units-padding-xxtight)", minWidth: 200, pointerEvents: "auto",
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",
425
473
  animation: "popoverIn var(--motion-duration-3) var(--motion-easing-spring) forwards" }}>
426
- {/* Section label, Figma component 40006794-5977 */}
427
- <div style={{ borderBottom: `0.5px solid ${c("stroke-static-neutral-faint")}` }}>
428
- <SectionLabel label={item.label} />
429
- </div>
474
+ <SectionLabel label={item.label} />
430
475
  {item.children.map(child =>
431
476
  <PopoverRow key={child.id} item={child} onClick={onClick} activeId={activeId} />
432
477
  )}
@@ -440,19 +485,28 @@ export function PopoverRow({ item, onClick, activeId }) {
440
485
  const [h, setH] = useState(false);
441
486
  const isActive = item.id === activeId;
442
487
  const bg = isActive ? T.fill.navActive : h ? T.fill.navHover : "transparent";
443
- const color = isActive ? T.text.navActive : h ? T.text.navHover : T.text.navBase;
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;
444
491
  return (
445
492
  <div onClick={() => onClick(item.id)}
446
493
  onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
447
- 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)",
448
498
  overflow: "hidden", backgroundColor: bg, cursor: "pointer",
449
499
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
450
- <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`,
451
504
  backgroundColor: isActive ? T.indicator : "transparent",
452
- flexShrink: 0, transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }} />
453
- <span style={{ padding: "0 8px", fontFamily: "'Red Hat Text',sans-serif",
454
- fontWeight: 400, fontSize: "var(--semantic-type-font-size-s)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)",
455
- color, transition: "color var(--motion-duration-3) var(--motion-easing-standard)", flex: 1 }}>
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 }}>
456
510
  {item.label}
457
511
  </span>
458
512
  </div>
@@ -516,8 +570,9 @@ export function CollapseButton({ isSidebarCollapsed, onToggle, collapseIcon, exp
516
570
  // ─── SideNav (top-level container) ────────────────────────────────────────────
517
571
  // ── NavSectionLabel ──────────────────────────────────────────────────────────
518
572
  // Optional in-nav section heading. Figma: SideNav.SectionLabel (node 40006794:5975).
519
- // Tokens: Label/Section/Small/Semibold (11px/600/16px/0.6px), Foreground/Static/Neutral/Base (#606060)
520
- // Container: h-[40px], pl-[4px], pr-[4px], py-[8px]
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)
521
576
  // In collapsed rail: hidden, replaced by a Divider (rendered by SideNav itself, see §2.3)
522
577
  // ── NavHeader ────────────────────────────────────────────────────────────────
523
578
  // Sits at the TOP of the SideNav. 48px row with the expand/collapse action icon
@@ -533,31 +588,30 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
533
588
  aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
534
589
  role="button" tabIndex={0}
535
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).
536
595
  style={{ display: "flex", alignItems: "center", height: L.itemH, width: "100%",
537
- borderRadius: T.radius, cursor: "pointer",
538
- backgroundColor: h ? T.fill.navHover : "transparent",
539
- transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)" }}>
540
- {isSidebarCollapsed ? (
541
- /* Collapsed: centered action icon */
542
- <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
543
- {expandIcon
544
- ? expandIcon({ size: 12, color: actionIconColor })
545
- : <LeftPanelOpenIcon color={actionIconColor} />}
546
- </div>
547
- ) : (
548
- /* Expanded: right-aligned action icon in Slot.RowEnd */
549
- <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "flex-end" }}>
550
- <div style={{ width: 36, height: 36, display: "flex",
551
- alignItems: "center", justifyContent: "center" }}>
552
- {collapseIcon
553
- ? collapseIcon({ size: 12, color: actionIconColor })
554
- : <RightPanelOpenIcon color={actionIconColor} />}
555
- </div>
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} />)}
556
610
  </div>
557
- )}
611
+ </div>
558
612
  </div>
559
- {/* Divider, Stroke/Static/Neutral/Base, py-[2px] in Figma */}
560
- <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" }}>
561
615
  <div style={{ height: 1, backgroundColor: T.fill.infoSubtle }} />
562
616
  </div>
563
617
  </div>
@@ -566,8 +620,8 @@ export function NavHeader({ isSidebarCollapsed, onToggle, collapseIcon, expandIc
566
620
 
567
621
  export function NavSectionLabel({ label }) {
568
622
  return (
569
- <div style={{ display: "flex", alignItems: "center", height: 40, width: "100%",
570
- paddingLeft: "var(--semantic-layout-units-padding-xxxtight)", paddingRight: "var(--semantic-layout-units-padding-xxxtight)", paddingTop: "var(--semantic-layout-units-padding-xtight)", paddingBottom: "var(--semantic-layout-units-padding-xtight)",
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)",
571
625
  flexShrink: 0 }}>
572
626
  <span style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 600,
573
627
  fontSize: "var(--semantic-type-font-size-xxs)", lineHeight: "var(--semantic-type-line-height-xxs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)",
@@ -580,12 +634,15 @@ export function NavSectionLabel({ label }) {
580
634
  }
581
635
 
582
636
  // ── BulletDot ────────────────────────────────────────────────────────────────
583
- // 6px filled circle, leading icon for SideNavListSection's ListItem.
584
- // 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.
585
640
  export function BulletDot({ color }) {
586
641
  return (
587
- <div style={{ width: 6, height: 6, borderRadius: "50%",
588
- 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>
589
646
  );
590
647
  }
591
648
 
@@ -603,17 +660,21 @@ export function ListItem({ item, isActive, onClick }) {
603
660
  onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
604
661
  role="button" tabIndex={0}
605
662
  onKeyDown={e => (e.key === "Enter" || e.key === " ") && onClick && onClick(item.id)}
606
- 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%",
607
665
  borderRadius: T.radius, backgroundColor: bg, cursor: "pointer",
608
666
  overflow: "hidden", transition: "background-color var(--motion-duration-3) var(--motion-easing-standard)",
609
667
  userSelect: "none" }}>
610
- <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} />
611
671
  <div style={{ width: L.iconWrap, height: L.iconWrap, display: "flex",
612
672
  alignItems: "center", justifyContent: "center", flexShrink: 0,
613
673
  marginLeft: L.rowPadH }}>
614
674
  <BulletDot color={dotColor} />
615
675
  </div>
616
- <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})` }}>
617
678
  <p style={{ fontFamily: "'Red Hat Text',sans-serif", fontWeight: 400,
618
679
  fontSize: "var(--semantic-type-font-size-xs)", lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-extraspacious)", color,
619
680
  margin: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
@@ -778,7 +839,7 @@ export function SideNav({
778
839
  boxSizing: "border-box", flexShrink: 0,
779
840
  // Smooth-spring curve, soft personality, no overshoot.
780
841
  transition: "width var(--motion-duration-6) var(--motion-easing-emphasized)",
781
- borderRight: `0.5px solid ${T.fill.infoSubtle}`,
842
+ borderRight: `1px solid ${T.fill.infoSubtle}`,
782
843
  overflow: "hidden", // the inner menu scrolls; the nav itself does not
783
844
  }}>
784
845
  {/* NavHeader, TOP of nav, PINNED (flexShrink:0) so it stays visible while the
@@ -898,17 +959,14 @@ export function SideNav({
898
959
  }
899
960
 
900
961
  // ─── Built-in chevron icons ───────────────────────────────────────────────────
901
- function ChevDown({ size = 10, color }) {
902
- return (
903
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
904
- <path d="M3 5.5L8 10.5L13 5.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
905
- </svg>
906
- );
907
- }
908
- function ChevUp({ size = 10, color }) {
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 }) {
909
967
  return (
910
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
911
- <path d="M3 10.5L8 5.5L13 10.5" stroke={color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
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} />
912
970
  </svg>
913
971
  );
914
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.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,