@helloimjolopez-pathway/pathway-components 0.2.0 → 0.2.2

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
@@ -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}
@@ -276,10 +276,9 @@ export function SideNavItem({
276
276
  // parent is centred by its line box, not by its own art, so
277
277
  // it rode high inside the 24px wrap.
278
278
  display: "block",
279
- // opsz MUST match fontSize. It said 20 against a 16px size,
280
- // so the glyph was drawn on a 20px grid and scaled down into
281
- // a 16px box, which shifts the art off centre.
282
- fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' ${L.iconInner}` }}>
279
+ // opsz 24, as Figma draws its icons, so the glyph fills the
280
+ // box in Figma's proportion (changed 2026-11-03).
281
+ fontVariationSettings: `'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24` }}>
283
282
  {item.icon}
284
283
  </span>
285
284
  : React.cloneElement(item.icon, { style: { color: iconColor } })}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * TopNav's standalone demo. Imports the real module, so there is nothing here
3
+ * that can fall out of step with the shipped component: see
4
+ * components/_demo/harness.jsx for why the newer demos are generated.
5
+ *
6
+ * Replaced the hand-written top-nav.html on 2026-10-30. That page carried its
7
+ * own copy of TopNav, and the copy had drifted from top-nav.jsx: it still
8
+ * abbreviated the mobile org label, still drew the campus line in Mono, and
9
+ * still sized the module mark at 24.
10
+ */
11
+ import React, { useState } from "react";
12
+ import { DemoChrome, mount } from "../_demo/harness.jsx";
13
+ import { TopNav } from "./top-nav.jsx";
14
+
15
+ const U = (n) => `var(--semantic-layout-units-${n})`;
16
+ const C = (n) => `var(--semantic-color-${n})`;
17
+ const Y = (n) => `var(--semantic-type-${n})`;
18
+
19
+ const BARS = [
20
+ { id: "desktop", label: "Desktop", width: 1200 },
21
+ { id: "tablet", label: "Tablet", width: 768 },
22
+ { id: "mobile", label: "Mobile", width: 393 },
23
+ ];
24
+ const ORG = { id: "gcc", name: "Grace Community Church", shortName: "Grace Comm", campus: "Knoxville", initials: "GC" };
25
+ const USER = { name: "Jo Lopez", initials: "JL", email: "jo@gracecommunity.org" };
26
+
27
+ const label = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
28
+ const card = {
29
+ background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
30
+ borderRadius: U("cornerradius-large"), padding: U("padding-relaxed"),
31
+ display: "flex", flexDirection: "column", gap: U("gap-base"), minWidth: 0,
32
+ };
33
+ const h2 = { margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") };
34
+
35
+ function App() {
36
+ const [showCampus, setShowCampus] = useState(false);
37
+ const [module, setModule] = useState("people");
38
+ const [brand, setBrand] = useState("amplify");
39
+ return (
40
+ <DemoChrome title="TopNav"
41
+ blurb={"The global bar across the top of every Pathway screen. Hover and open the module and org switchers. " +
42
+ "The campus line is off by default, as in Figma; turn it on below."}>
43
+ <div style={{ display: "flex", flexDirection: "column", gap: U("gap-relaxed") }}>
44
+ <section style={card}>
45
+ <h2 style={h2}>Options</h2>
46
+ <label style={{ ...label, display: "flex", alignItems: "center", gap: U("gap-xtight") }}>
47
+ <input id="topnav-show-campus" type="checkbox" checked={showCampus} onChange={(e) => setShowCampus(e.target.checked)} />
48
+ Show campus line (Figma "Show CityName")
49
+ </label>
50
+ <label style={{ ...label, display: "flex", alignItems: "center", gap: U("gap-xtight") }}>
51
+ Brand layout
52
+ <select id="topnav-brand" value={brand} onChange={(e) => setBrand(e.target.value)}>
53
+ <option value="amplify">Amplify</option>
54
+ <option value="newco">NewCo</option>
55
+ </select>
56
+ </label>
57
+ <label style={{ ...label, display: "flex", alignItems: "center", gap: U("gap-xtight") }}>
58
+ Module
59
+ <select id="topnav-module" value={module} onChange={(e) => setModule(e.target.value)}>
60
+ {["home", "people", "giving", "accounting", "communications"].map((m) => <option key={m} value={m}>{m}</option>)}
61
+ </select>
62
+ </label>
63
+ </section>
64
+ {BARS.map((b) => (
65
+ <section key={b.id} style={card}>
66
+ <h2 style={h2}>{b.label}</h2>
67
+ <div style={{ overflowX: "auto" }}>
68
+ <div style={{ width: b.width }}>
69
+ <TopNav key={module + brand} brand={brand} breakpoint={b.id} activeModuleId={module} org={ORG} user={USER} showCampus={showCampus} />
70
+ </div>
71
+ </div>
72
+ </section>
73
+ ))}
74
+ </div>
75
+ </DemoChrome>
76
+ );
77
+ }
78
+
79
+ mount(<App />);
@@ -143,13 +143,12 @@ export const T = {
143
143
  // emphasis comes from font-weight at the call site, not from a bolder colour.
144
144
  orgText: SCD("foreground-action-ghost-rest"),
145
145
  orgChevron: SCD("foreground-action-ghost-rest"),
146
- // SubOrgName, the campus line. Figma binds Foreground/Static/Neutral/Mono
147
- // here rather than a Ghost rung, so it stays put while OrgName tracks hover.
148
- // Mono is safe on this bar now that the bar renders Light, where it is
149
- // #ffffff; it would be wrong the moment anything inverted the bar, which is
150
- // recorded in the manifest as a Figma-side fragility rather than copied as a
151
- // pattern.
152
- orgSubText: SCD("foreground-static-neutral-mono"),
146
+ // SubOrgName, the campus line. Same Ghost rung as OrgName in every state,
147
+ // tracking hover and pressed with it. Figma bound it to
148
+ // Foreground/Static/Neutral/Mono until 2026-10-30, which inverts to a
149
+ // near-black in every dark mode and made the campus unreadable on
150
+ // the dark bar; Jo had Figma rebound to the OrgName token that day.
151
+ orgSubText: SCD("foreground-action-ghost-rest"),
153
152
  avatarBg: SCD("fill-action-primary-subtle-rest"),
154
153
  avatarText: SCD("foreground-action-primary-on-subtle-rest"),
155
154
  // White dropdown-menu surface, tracks fill-neutral-light (now warm-neutral-0).
@@ -180,7 +179,6 @@ export const L = {
180
179
  navPadV: r("topnav-padding-vertical"),
181
180
  navH: c("topnav-height"),
182
181
  navGap: r("topnav-gap-horizontal"),
183
- deskOrgMax: 316, mobOrgMax: 120,
184
182
  touchTarget: 48, modInnerH: 36,
185
183
  orgAvatarNav: 20, orgAvatarSm: 24, orgAvatarPanel: 32,
186
184
  searchPill: 32, avatarSize: 32,
@@ -192,16 +190,33 @@ export const L = {
192
190
  // TopNav.Search Container.Icon 32x32 box, search 16x16
193
191
  // The chevron glyph is the SAME 12 in both switchers; only the box differs,
194
192
  // which is why one constant serves both.
195
- // Org Switcher Container.Main padding, Figma 6,6,6,12.
193
+ // Org Switcher Container.Main padding, Figma 6,6,6,12; mobile 6,4,6,8.
196
194
  modPadL: u("padding-xxtight"), // 6 ModuleSwitcher Container.Main, left only
197
195
  profilePad: u("padding-xxxtight"), // 4 TopNav.Profile
198
196
  orgPadV: u("padding-xxtight"), // 6
199
197
  orgPadL: u("padding-tight"), // 12
200
- orgPadMobileH: u("padding-xxxxtight"),// 2
198
+ orgPadMobileR: u("padding-xxxtight"), // 4
199
+ orgPadMobileL: u("padding-xtight"), // 8
200
+ // The 48 box around ModuleSwitcher and Org Switcher: Padding/XXTight top and
201
+ // bottom; ModuleSwitcher and the mobile Org Switcher take Padding/XXXTight at
202
+ // the sides, the desktop Org Switcher Padding/XXTight. Read 2026-10-30; these
203
+ // were raw 4px and 4px 2px, so both switchers sat 4px short of the design.
204
+ slotPadV: u("padding-xxtight"), // 6
205
+ slotPadNarrow: u("padding-xxxtight"), // 4
206
+ // Container.Main max width, Figma 308 desktop, 102 mobile. OrgName truncates
207
+ // at 200, the campus line at 72.
208
+ orgMainMax: 308, orgMainMaxMobile: 102, orgNameMax: 200, orgCampusMax: 72,
209
+ // The mobile org label is a 52-wide text box in Figma (both brands), so the
210
+ // short name truncates at 52, not at the trigger's 102.
211
+ orgNameMaxMobile: 52,
212
+ // Module.Icon is 16 inside a 20 Container.IconLeading. It was 24 in a 30.
213
+ moduleMark: 16, moduleMarkBox: 20,
201
214
  chevronGlyph: 12,
202
215
  modChevronBox: 20,
203
216
  orgChevronBox: 24,
204
- actionIconBox: 28, actionIconGlyph: 14,
217
+ // Action Icon Size=Base: 16 glyph in a 28 Container.Icon. The glyph was 14,
218
+ // which also put it below the icon font's smallest optical size.
219
+ actionIconBox: 28, actionIconGlyph: 16,
205
220
  // Figma's Action Icon is 44, not the bar's 48. It is the one control on the
206
221
  // bar whose touch target is the AA minimum rather than the nav's own height.
207
222
  actionTarget: u("accessibility-touch-target-aa-width"),
@@ -310,78 +325,70 @@ export function OrgAvatarPlaceholder() {
310
325
  export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[id] }));
311
326
 
312
327
  // ─── TopNavActions ─────────────────────────────────────────────────────────────
313
- export function TopNavActions({ breakpoint = "desktop", onNotifications, onMore }) {
314
- const [hov0, setHov0] = useState(false);
315
- const [hov1, setHov1] = useState(false);
316
- const [hovMore, setHovMore] = useState(false);
328
+ /**
329
+ * TopNav.Actions, Figma 40007082:7313. A SLOT (Figma property "Slot.Actions"),
330
+ * not a fixed pair of bells: a product puts whatever action icons it needs in
331
+ * it, one bell, or a bell, a cog and a help icon. Each item renders as Figma's
332
+ * nested Action Icon at Size=Base.
333
+ *
334
+ * actions Array<{ icon, label, onClick }>. `icon` is a Material Symbols
335
+ * ligature, `label` the accessible name. Defaults to the two
336
+ * notification bells the bar has always shipped with.
337
+ *
338
+ * MOBILE FOLDS. On mobile, more than one action folds into a single more_vert
339
+ * ("More actions"), which is what Figma's mobile variant shows. One action on
340
+ * its own stays as itself.
341
+ *
342
+ * Action Icon Size=Base, read 2026-10-30 and 2026-11-02:
343
+ * target accessibility-touch-target-aa (44)
344
+ * Container.Icon 28, cornerradius-base: rest none, hover
345
+ * Fill/Action/Ghost/Hover, pressed Fill/Action/Ghost/Pressed
346
+ * glyph 16: Foreground/Action/Ghost/Rest, /Hover, /Pressed
347
+ *
348
+ * THE STATE FILL BELONGS ON THE 28 BOX, not on the touch target.
349
+ */
350
+ export const DEFAULT_ACTIONS = [
351
+ { icon: "notifications", label: "Notifications" },
352
+ { icon: "notifications", label: "Alerts" },
353
+ ];
317
354
 
318
- // Figma TopNav.Actions, node 40007082:7313, Amplify section, read 2026-10-02:
319
- //
320
- // component 132x48 horizontal, gap 0
321
- // Action Icon 44x44 three of them, 3 x 44 = 132
322
- // Container.Icon 28x28 r=8 rest no fill, hover Fill/Action/Ghost/Hover,
323
- // pressed Fill/Action/Ghost/Pressed
324
- // glyph 14x14 rest Foreground/Action/Ghost/Rest,
325
- // hover Foreground/Action/Ghost/Hover
326
- //
327
- // THE HOVER FILL BELONGS ON THE 28px BOX, not on the touch target. It was on
328
- // a 48px button, so the hover state painted a box nearly twice the size of
329
- // the one in the design. The target is also 44 in Figma, not 48.
330
- const btnStyle = () => ({
331
- display: "flex", alignItems: "center", justifyContent: "center",
332
- width: "100%", height: "100%", padding: 0,
333
- background: "transparent", border: "none", cursor: "pointer",
334
- });
335
- // The box that actually carries the state.
336
- const iconBoxStyle = (hov) => ({
337
- display: "flex", alignItems: "center", justifyContent: "center",
338
- width: L.actionIconBox, height: L.actionIconBox, borderRadius: L.radius,
339
- background: hov ? T.controlHover : "transparent",
340
- transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
341
- });
342
-
343
- if (breakpoint === "desktop") {
355
+ function TopNavActionIcon({ icon, label, onClick }) {
356
+ const [hov, setHov] = useState(false);
357
+ const [press, setPress] = useState(false);
358
+ const fill = press ? T.controlPressed : hov ? T.controlHover : "transparent";
359
+ const fg = press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
360
+ return (
361
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "center",
362
+ minHeight: L.actionTarget, minWidth: L.actionTarget }}>
363
+ <button type="button" aria-label={label} onClick={onClick}
364
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => { setHov(false); setPress(false); }}
365
+ onMouseDown={() => setPress(true)} onMouseUp={() => setPress(false)} onBlur={() => setPress(false)}
366
+ style={{ display: "flex", alignItems: "center", justifyContent: "center",
367
+ width: "100%", height: "100%", padding: 0,
368
+ background: "transparent", border: "none", cursor: "pointer" }}>
369
+ <span style={{ display: "flex", alignItems: "center", justifyContent: "center",
370
+ width: L.actionIconBox, height: L.actionIconBox, borderRadius: L.radius, background: fill,
371
+ transition: "background var(--motion-duration-2) var(--motion-easing-standard)" }}>
372
+ <Icon name={icon} size={L.actionIconGlyph}
373
+ style={{ color: fg, transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }} />
374
+ </span>
375
+ </button>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ export function TopNavActions({ breakpoint = "desktop", actions, onNotifications, onMore }) {
381
+ const items = (actions || DEFAULT_ACTIONS).map((a) => ({ ...a, onClick: a.onClick || onNotifications }));
382
+ if (breakpoint === "mobile" && items.length > 1) {
344
383
  return (
345
384
  <div style={{ display: "flex", alignItems: "center" }}>
346
- {[
347
- { hov: hov0, setHov: setHov0, label: "Notifications" },
348
- { hov: hov1, setHov: setHov1, label: "Alerts" },
349
- ].map(({ hov, setHov, label }, idx) => (
350
- <div key={idx} style={{ display: "flex", alignItems: "center", justifyContent: "center",
351
- minHeight: L.actionTarget, minWidth: L.actionTarget, padding: 0}}>
352
- <button
353
- onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
354
- onClick={onNotifications} aria-label={label}
355
- style={btnStyle()}
356
- >
357
- <span style={iconBoxStyle(hov)}>
358
- {/* The glyph tracks hover too: Figma moves it from
359
- Foreground/Action/Ghost/Rest to /Hover. It used to stay put. */}
360
- <Icon name="notifications" size={L.actionIconGlyph}
361
- style={{ color: hov ? T.monoHover : T.monoBase,
362
- transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }} />
363
- </span>
364
- </button>
365
- </div>
366
- ))}
385
+ <TopNavActionIcon icon="more_vert" label="More actions" onClick={onMore} />
367
386
  </div>
368
387
  );
369
388
  }
370
-
371
389
  return (
372
- <div style={{ display: "flex", alignItems: "center", justifyContent: "center",
373
- minHeight: L.actionTarget, minWidth: L.actionTarget }}>
374
- <button
375
- onMouseEnter={() => setHovMore(true)} onMouseLeave={() => setHovMore(false)}
376
- onClick={onMore} aria-label="More actions"
377
- style={btnStyle()}
378
- >
379
- <span style={iconBoxStyle(hovMore)}>
380
- <Icon name="more_vert" size={L.actionIconGlyph}
381
- style={{ color: hovMore ? T.monoHover : T.monoBase,
382
- transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }} />
383
- </span>
384
- </button>
390
+ <div style={{ display: "flex", alignItems: "center" }}>
391
+ {items.map((a, i) => <TopNavActionIcon key={`${a.icon}-${i}`} {...a} />)}
385
392
  </div>
386
393
  );
387
394
  }
@@ -390,8 +397,10 @@ export function TopNavActions({ breakpoint = "desktop", onNotifications, onMore
390
397
  export function TopNavProfile({ user, open, onToggle, mobile = false }) {
391
398
  const [hov, setHov] = useState(false);
392
399
  return (
400
+ // Figma TopNav.Profile is the 48 box itself. This wrapper added
401
+ // Padding/XXXXTight around the 48 button, so the profile took 52.
393
402
  <div style={{ position: "relative", display: "flex", alignItems: "center",
394
- justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget, padding: "var(--semantic-layout-units-padding-xxxxtight)"}}>
403
+ justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget }}>
395
404
  <button
396
405
  onClick={onToggle}
397
406
  onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
@@ -409,10 +418,13 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
409
418
  <div style={{
410
419
  width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
411
420
  background: T.avatarBg, display: "flex", alignItems: "center", justifyContent: "center",
412
- // Desktop/Tablet: Text/Body/Small/Semibold 14px/600
413
- // Mobile: Text/Supporting/Small/Semibold 11px/600 (Figma)
414
- fontSize: mobile ? 11 : 14, fontWeight: 600, letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
415
- color: T.avatarText, lineHeight: 1, flexShrink: 0, overflow: "hidden",
421
+ // Text/Body/Small/Semibold at EVERY breakpoint, read 2026-10-30. The
422
+ // mobile 11px this used to drop to is not in Figma. Figma names the
423
+ // 0.3 tracking Letter Spacing/Wide; the CSS emits 0.3 as -spacious.
424
+ fontFamily: "var(--semantic-type-family-brand)",
425
+ fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)",
426
+ lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)",
427
+ color: T.avatarText, flexShrink: 0, overflow: "hidden",
416
428
  }}>
417
429
  {user.avatarUrl
418
430
  ? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
@@ -425,60 +437,73 @@ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
425
437
  }
426
438
 
427
439
  // ─── OrgSwitcher ──────────────────────────────────────────────────────────────
428
- export function OrgSwitcher({ org, open, onToggle, mobile = false }) {
440
+ /**
441
+ * Org Switcher, Figma set 40006819:14583, read 2026-10-30.
442
+ *
443
+ * showCampus Figma "Show CityName (Catholic)", BOOLEAN, default false. The
444
+ * campus line is off unless a product turns it on. It was shown
445
+ * whenever an org had a campus, so every demo carried one.
446
+ * org.shortName Figma "Label-Mobile", TEXT. The mobile trigger shows this
447
+ * short name, truncated. Without one it falls back to the
448
+ * spec's initials rule (§10.2), with the campus initials only
449
+ * when showCampus is on.
450
+ *
451
+ * OrgName, the campus line and the trigger all follow the state together:
452
+ * Foreground/Action/Ghost/Rest, Hover, then Pressed for Pressed and Open. The
453
+ * chevron stays on Rest in every state, as in Figma.
454
+ */
455
+ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus = false }) {
429
456
  const [hov, setHov] = useState(false);
430
457
  const [imgFailed, setImgFailed] = useState(false);
431
458
 
432
459
  const hasLogo = org.logoUrl && !imgFailed;
460
+ const label = mobile
461
+ ? (org.shortName || mobileLabel(org.name, showCampus ? org.campus : null))
462
+ : org.name;
463
+ const campus = !mobile && showCampus && org.campus ? org.campus : null;
464
+ const fg = open ? T.monoPressed : hov ? T.monoHover : T.monoBase;
433
465
 
434
- // FIGMA HAS TWO TEXT NODES, NOT ONE STRING. OrgName takes
435
- // Foreground/Action/Ghost/* and follows the hover state; SubOrgName takes
436
- // Foreground/Static/Neutral/Mono and does not. Concatenating them into
437
- // "name | campus" gave the campus the org name's colour and made it track
438
- // hover, which the design does not. Read off set 40006819:14583, 2026-10-02.
439
- //
440
- // Mobile still abbreviates into one string, because the mobile variant has a
441
- // single label with no room for two.
442
- const label = mobile ? mobileLabel(org.name, org.campus) : org.name;
443
- const subLabel = mobile ? null : (org.campus || null);
444
-
445
- // Desktop/Tablet: Label/Button/S, 14px/500/20px
446
- // Mobile: Label/Button/XS, 12px/500/18px (Figma annotation)
447
- const labelStyle = mobile
448
- ? { fontSize: "var(--semantic-type-font-size-xs)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-xs-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)"}
449
- : { fontSize: "var(--semantic-type-font-size-s)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)"};
466
+ // Label/Button/Small at every breakpoint, mobile included (read 2026-11-02;
467
+ // the 12px Label/Button/XS mobile size is not in Figma). Figma binds its
468
+ // tracking to the PRIMITIVE Letter Spacing/Wide (0.3); the semantic CSS
469
+ // name for 0.3 is -spacious.
470
+ const labelStyle = { fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)"};
471
+ const truncate = { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 };
450
472
 
451
473
  return (
452
- <div style={{ position: "relative", padding: mobile ? "4px 2px" : 4,
453
- maxWidth: mobile ? L.mobOrgMax : L.deskOrgMax }}>
474
+ // The 48 slot: Padding/XXTight all round on desktop, XXTight by XXXTight on mobile.
475
+ <div style={{ position: "relative", minWidth: 0,
476
+ padding: mobile ? `${L.slotPadV} ${L.slotPadNarrow}` : L.slotPadV }}>
454
477
  <button
455
478
  onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
456
479
  onClick={onToggle}
457
480
  aria-haspopup="true" aria-expanded={open}
458
481
  aria-label={`Switch organisation: ${org.name}${org.campus ? ", "+org.campus : ""}`}
459
482
  style={{
460
- display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
461
- minHeight: 36,
462
- // Figma OrgSwitcher Container.Main: pl-12 pr-6 py-4 (updated 2026-06-08 from uniform 4px)
463
- // Figma Org Switcher Container.Main is pad 6,6,6,12 on desktop, read
464
- // off set 40006819:14583 on 2026-10-02. The vertical was 4 here, so
465
- // the trigger sat 4px shorter than the design and out of step with
466
- // the module switcher beside it.
467
- padding: mobile ? `${L.orgPadV} ${L.orgPadMobileH}` : `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
483
+ // Container.Main: gap Gap/XXXTight between RowStart and RowEnd,
484
+ // 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)",
486
+ minHeight: L.modInnerH, boxSizing: "border-box", minWidth: 0,
487
+ maxWidth: mobile ? L.orgMainMaxMobile : L.orgMainMax,
488
+ padding: mobile
489
+ ? `${L.orgPadV} ${L.orgPadMobileR} ${L.orgPadV} ${L.orgPadMobileL}`
490
+ : `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
468
491
  borderRadius: L.radius,
469
492
  // Rest is bare: stroke only, no fill. See the Ghost note in T.
470
493
  background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
471
- border: `1px solid ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
472
- cursor: "pointer", color: T.monoBase, fontFamily: "inherit",
473
- transition: "background var(--motion-duration-3) var(--motion-easing-standard), border-color var(--motion-duration-3) var(--motion-easing-standard)",
494
+ // Button/BorderWidth/Rest, as Figma binds it. This was a raw 1px.
495
+ // Drawn as an inset shadow because Figma strokes INSIDE the frame:
496
+ // a CSS border adds to the box and made Container.Main 37.5, not 36.
497
+ border: "none",
498
+ boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
499
+ cursor: "pointer", color: fg, fontFamily: "inherit",
500
+ transition: "background var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)",
474
501
  }}
475
502
  >
476
- {/* RowStart, org name label only. No avatar or logo displayed in the
477
- nav trigger per Figma (showOrgAvatar = false by default).
478
- If org.logoUrl is provided it is still used in the org panel below. */}
503
+ {/* Container.RowStart: 24 tall desktop, 20 mobile, gap Gap/XXTight. */}
479
504
  <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
480
- height: 20, padding: "0 2px" }}>
481
- {/* Logo avatar, only rendered when a URL is explicitly provided */}
505
+ height: mobile ? 20 : 24, minWidth: 0 }}>
506
+ {/* Logo avatar, only when a URL is provided (Figma "Show Org Avatar", default false) */}
482
507
  {hasLogo && (
483
508
  <div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxxtight)",
484
509
  display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
@@ -497,57 +522,30 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false }) {
497
522
  </div>
498
523
  </div>
499
524
  )}
500
- {/* Label */}
501
- <span
502
- aria-hidden={mobile ? "true" : undefined}
503
- style={{
504
- ...labelStyle,
505
- whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
506
- maxWidth: mobile ? 70 : 248, color: T.orgText, paddingRight: "var(--semantic-layout-units-padding-xxxtight)",
507
- }}
508
- >
509
- {label}
510
- </span>
511
- {/* SubOrgName. Its own node in Figma with its own token,
512
- Foreground/Static/Neutral/Mono, which on this bar resolves to
513
- #ffffff and does NOT follow the hover state the way OrgName does.
514
- Figma puts it in Container.CityName.Catholic beside the org name
515
- with gap 8. */}
516
- {subLabel && (
517
- <span
518
- style={{
519
- ...labelStyle,
520
- whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
521
- maxWidth: 110, flexShrink: 0,
522
- color: T.orgSubText,
523
- marginLeft: "var(--semantic-layout-units-gap-tight)",
524
- }}
525
- >
526
- {subLabel}
525
+ {/* Container.OrgName: OrgName and the campus line, gap Gap/Tight. */}
526
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", minWidth: 0 }}>
527
+ <span aria-hidden="true"
528
+ style={{ ...labelStyle, ...truncate, maxWidth: mobile ? L.orgNameMaxMobile : L.orgNameMax,
529
+ color: fg, transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }}>
530
+ {label}
527
531
  </span>
528
- )}
529
- {mobile && (
530
- <span style={{ position: "absolute", left: -9999, width: 1, height: 1, overflow: "hidden" }}>
531
- {org.name}{org.campus ? ", "+org.campus : ""}
532
- </span>
533
- )}
532
+ {campus && (
533
+ <span aria-hidden="true"
534
+ style={{ ...labelStyle, ...truncate, maxWidth: L.orgCampusMax, flexShrink: 0,
535
+ color: open ? T.monoPressed : hov ? T.monoHover : T.orgSubText,
536
+ transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }}>
537
+ {`| ${campus}`}
538
+ </span>
539
+ )}
540
+ </div>
534
541
  </div>
535
- {/* Chevron */}
536
- <div style={{
537
- display: "flex", alignItems: "center", justifyContent: "center",
538
- width: 16, height: 16, marginRight: 2,
542
+ {/* Container.RowEnd: 24 box desktop, 20 mobile, 12 expand_more on Ghost/Rest. */}
543
+ <span style={{ width: mobile ? L.modChevronBox : L.orgChevronBox, height: mobile ? L.modChevronBox : L.orgChevronBox,
544
+ display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
539
545
  transform: open ? "rotate(180deg)" : "none",
540
- transition: "transform var(--motion-duration-4) var(--motion-easing-standard)",
541
- }}>
542
- {/* Figma Org Switcher Container.RowEnd is a 24x24 box holding a 12x12
543
- expand_more. This was a bare 16px glyph with no box, so the
544
- chevron was both too large and unaligned with the module
545
- switcher's, which sits in a 20 box. */}
546
- <span style={{ width: L.orgChevronBox, height: L.orgChevronBox, display: "inline-flex",
547
- alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
548
- <Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.orgChevron }} />
549
- </span>
550
- </div>
546
+ transition: "transform var(--motion-duration-4) var(--motion-easing-standard)" }}>
547
+ <Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.orgChevron }} />
548
+ </span>
551
549
  </button>
552
550
  </div>
553
551
  );
@@ -571,23 +569,26 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
571
569
  const active = modules.find(m => m.id === activeId) || modules[0];
572
570
  const showLabel = breakpoint === "desktop";
573
571
  const isStatic = type === "static";
572
+ // The label follows the state (Ghost Rest, Hover, Pressed for Pressed and
573
+ // 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;
574
575
 
575
- // Icon + label: identical in both variants.
576
+ // Icon + label: identical in both variants. Container.RowStart, gap Gap/XXTight.
576
577
  const inner = (
577
- <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
578
- paddingRight: showLabel && !isStatic ? 2 : 0 }}>
578
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)" }}>
579
579
  {/* The module mark. Mono on the bar: the chrome is brand blue, so the
580
580
  mark inherits currentColor and reads as near-white, which is what the
581
581
  hand-drawn home icon did for one module and nothing did for the rest. */}
582
- <div style={{ width: 30, height: 30, display: "flex", alignItems: "center",
582
+ <div style={{ width: L.moduleMarkBox, height: L.moduleMarkBox, display: "flex", alignItems: "center",
583
583
  justifyContent: "center", flexShrink: 0, color: T.monoBase }}>
584
- <ModuleIcon module={active.id} size={24} color="mono" />
584
+ <ModuleIcon module={active.id} size={L.moduleMark} color="mono" />
585
585
  </div>
586
586
  {/* Label, desktop only */}
587
587
  {showLabel && (
588
- <span style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-s-single)",
588
+ <span style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-s-single)",
589
589
  letterSpacing: "var(--semantic-type-letter-spacing-spacious)", whiteSpace: "nowrap", overflow: "hidden",
590
- textOverflow: "ellipsis", maxWidth: 160, color: T.monoBase }}>
590
+ textOverflow: "ellipsis", maxWidth: 160, color: labelFg,
591
+ transition: "color var(--motion-duration-2) var(--motion-easing-standard)" }}>
591
592
  {active.label}
592
593
  </span>
593
594
  )}
@@ -598,7 +599,7 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
598
599
  // No chevron, no hover/pressed, no button role/affordance.
599
600
  if (isStatic) {
600
601
  return (
601
- <div style={{ position: "relative", padding: "4px 2px" }}>
602
+ <div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
602
603
  <div
603
604
  aria-label={`Current module: ${active.label}`}
604
605
  style={{
@@ -619,14 +620,16 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
619
620
 
620
621
  // INTERACTIVE (default), switch-module control.
621
622
  return (
622
- <div style={{ position: "relative", padding: "4px 2px" }}>
623
+ // The 48 slot, Padding/XXTight by Padding/XXXTight. This was a raw 4px 2px.
624
+ <div style={{ position: "relative", padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
623
625
  <button
624
626
  onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
625
627
  onClick={onToggle}
626
628
  aria-haspopup="listbox" aria-expanded={open}
627
629
  aria-label={`Switch module: ${active.label}`}
628
630
  style={{
629
- display: "flex", alignItems: "center",
631
+ // Container.Main: gap Gap/XXXTight between RowStart and the chevron.
632
+ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)",
630
633
  maxHeight: L.modInnerH, minHeight: L.modInnerH,
631
634
  // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
632
635
  // This was a uniform 4 on all four sides.
@@ -646,17 +649,312 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
646
649
  2026-10-02. The glyph was right at 12; the box said 16. */}
647
650
  <div style={{
648
651
  width: L.modChevronBox, height: L.modChevronBox, display: "flex", alignItems: "center",
649
- justifyContent: "center", marginLeft: 2,
652
+ justifyContent: "center",
650
653
  transform: open ? "rotate(180deg)" : "none",
651
654
  transition: "transform var(--motion-duration-4) var(--motion-easing-standard)",
652
655
  }}>
653
- <Icon name="expand_more" size={12} style={{ color: T.monoBase }} />
656
+ <Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.monoBase }} />
654
657
  </div>
655
658
  </button>
656
659
  </div>
657
660
  );
658
661
  }
659
662
 
663
+ // ─── TopNavProfileMenu ─────────────────────────────────────────────────────────
664
+ // The open profile menu. A separate pattern from the bar (see the spec's scope
665
+ // note); shared by both brand layouts so there is one copy of it.
666
+ function TopNavProfileMenu({ user }) {
667
+ return (
668
+ <div role="menu" data-theme="light"
669
+ style={{
670
+ position: "absolute", top: "calc(100% + 4px)", right: L.navPadH,
671
+ width: 200, background: T.panelBg,
672
+ border: `1px solid ${T.panelBorder}`, borderRadius: L.radius,
673
+ boxShadow: T.panelShadow, padding: "var(--semantic-layout-units-padding-xxxtight)", zIndex: 300,
674
+ animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
675
+ }}
676
+ >
677
+ <div style={{ padding: " ",
678
+ borderBottom: `1px solid ${T.panelDivider}`, marginBottom: 4 }}>
679
+ <div style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 600, color: T.itemText }}>{user.name}</div>
680
+ <div style={{ fontSize: "var(--semantic-type-font-size-xxs)", color: T.itemMeta, marginTop: 1 }}>{user.email}</div>
681
+ </div>
682
+ {["Profile settings", "Settings"].map(label => (
683
+ <button key={label} role="menuitem"
684
+ style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
685
+ borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
686
+ color: T.itemTextBase, background: "transparent",
687
+ border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
688
+ {label}
689
+ </button>
690
+ ))}
691
+ <button role="menuitem"
692
+ style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
693
+ borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
694
+ color: T.signOut, background: "transparent",
695
+ border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
696
+ Sign out
697
+ </button>
698
+ </div>
699
+ );
700
+ }
701
+
702
+ // ─── Brand=NewCo layout ────────────────────────────────────────────────────────
703
+ /**
704
+ * Figma TopNav.Global, Brand=NewCo (40018199:57659 desktop, 40018199:59368
705
+ * tablet, 40018199:59418 mobile), read 2026-11-03. A different layout from
706
+ * Amplify, not a recolour of it:
707
+ *
708
+ * desktop logo, divider, Pillar (brand tab + module tabs) | Org Switcher,
709
+ * search, help, settings, notifications, profile
710
+ * tablet the same RowStart | Org Switcher (mobile type), search,
711
+ * notifications, profile
712
+ * mobile SideNav.Control, Pillar trigger, Org Switcher (mobile type) |
713
+ * more_vert, profile
714
+ *
715
+ * The action icons are NewCo's own line icons (Figma icon/search, icon/help,
716
+ * icon/gear, icon/bell), not Material Symbols. Their paths are the Figma
717
+ * exports, drawn at 16 with a 1.44 stroke, as the instances are.
718
+ */
719
+ const NC_ICONS = {
720
+ search: ["M11 17.5C14.5899 17.5 17.5 14.5899 17.5 11C17.5 7.41015 14.5899 4.5 11 4.5C7.41015 4.5 4.5 7.41015 4.5 11C4.5 14.5899 7.41015 17.5 11 17.5Z", "M20 20L15.8 15.8"],
721
+ help: ["M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z", "M9.5 9.49999C9.50157 9.04964 9.62476 8.60808 9.85656 8.22196C10.0884 7.83584 10.4202 7.5195 10.8169 7.30638C11.2136 7.09325 11.6606 6.99125 12.1105 7.01116C12.5604 7.03106 12.9966 7.17212 13.3729 7.41945C13.7493 7.66677 14.0519 8.01117 14.2487 8.41625C14.4455 8.82133 14.5292 9.27205 14.491 9.72078C14.4528 10.1695 14.2941 10.5996 14.0316 10.9656C13.7692 11.3316 13.4128 11.6199 13 11.8C12.3 12.1 12 12.7 12 13.5M12 17H12.01"],
722
+ gear: ["M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z", "M19.4 15C19.2737 15.2975 19.2349 15.6249 19.288 15.9437C19.3411 16.2625 19.4841 16.5595 19.7 16.8L19.8 16.9C20.0292 17.0719 20.2189 17.2911 20.356 17.5427C20.4932 17.7943 20.5747 18.0724 20.595 18.3582C20.6153 18.644 20.5739 18.9309 20.4737 19.1994C20.3735 19.4678 20.2168 19.7116 20.0142 19.9142C19.8116 20.1168 19.5678 20.2735 19.2994 20.3737C19.0309 20.4739 18.744 20.5153 18.4582 20.495C18.1724 20.4747 17.8943 20.3932 17.6427 20.256C17.3911 20.1189 17.1719 19.9292 17 19.7L16.9 19.6C16.6595 19.3841 16.3625 19.2411 16.0437 19.188C15.7249 19.1349 15.3975 19.1737 15.1 19.3C14.8094 19.4311 14.5616 19.6413 14.3848 19.9065C14.208 20.1718 14.1093 20.4814 14.1 20.8V21C14.1 21.5304 13.8893 22.0391 13.5142 22.4142C13.1391 22.7893 12.6304 23 12.1 23C11.5696 23 11.0609 22.7893 10.6858 22.4142C10.3107 22.0391 10.1 21.5304 10.1 21V20.9C10.0822 20.5701 9.96871 20.2526 9.77335 19.9862C9.578 19.7198 9.30927 19.5161 9 19.4C8.70249 19.2737 8.37514 19.2349 8.05634 19.288C7.73753 19.3411 7.44048 19.4841 7.2 19.7L7.1 19.8C6.92807 20.0292 6.7089 20.2189 6.45731 20.356C6.20572 20.4932 5.9276 20.5747 5.64178 20.595C5.35595 20.6153 5.0691 20.5739 4.80065 20.4737C4.53219 20.3735 4.2884 20.2168 4.08579 20.0142C3.88317 19.8116 3.72646 19.5678 3.62626 19.2994C3.52606 19.0309 3.48472 18.744 3.50503 18.4582C3.52534 18.1724 3.60684 17.8943 3.74399 17.6427C3.88115 17.3911 4.07076 17.1719 4.3 17L4.4 16.9C4.61594 16.6595 4.75885 16.3625 4.81199 16.0437C4.86512 15.7249 4.82627 15.3975 4.7 15.1C4.56888 14.8094 4.3587 14.5616 4.09347 14.3848C3.82824 14.208 3.51863 14.1093 3.2 14.1H3C2.46957 14.1 1.96086 13.8893 1.58579 13.5142C1.21071 13.1391 1 12.6304 1 12.1C1 11.5696 1.21071 11.0609 1.58579 10.6858C1.96086 10.3107 2.46957 10.1 3 10.1H3.1C3.42986 10.0822 3.7474 9.96871 4.01379 9.77335C4.28018 9.578 4.48389 9.30927 4.6 9C4.72627 8.70249 4.76512 8.37514 4.71199 8.05634C4.65885 7.73753 4.51594 7.44048 4.3 7.2L4.2 7.1C3.97076 6.92807 3.78115 6.7089 3.64399 6.45731C3.50684 6.20572 3.42534 5.9276 3.40503 5.64178C3.38472 5.35595 3.42606 5.0691 3.52626 4.80065C3.62646 4.53219 3.78317 4.2884 3.98579 4.08579C4.1884 3.88317 4.43219 3.72646 4.70065 3.62626C4.9691 3.52606 5.25595 3.48472 5.54178 3.50503C5.8276 3.52534 6.10572 3.60684 6.35731 3.74399C6.6089 3.88115 6.82807 4.07076 7 4.3L7.1 4.4C7.34048 4.61594 7.63753 4.75885 7.95634 4.81199C8.27514 4.86512 8.60249 4.82627 8.9 4.7H9C9.29055 4.56888 9.53839 4.3587 9.71521 4.09347C9.89203 3.82824 9.99071 3.51863 10 3.2V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.1C14.0093 3.41863 14.108 3.72824 14.2848 3.99347C14.4616 4.2587 14.7094 4.46888 15 4.6C15.2975 4.72627 15.6249 4.76512 15.9437 4.71199C16.2625 4.65885 16.5595 4.51594 16.8 4.3L16.9 4.2C17.0719 3.97076 17.2911 3.78115 17.5427 3.64399C17.7943 3.50684 18.0724 3.42534 18.3582 3.40503C18.644 3.38472 18.9309 3.42606 19.1994 3.52626C19.4678 3.62646 19.7116 3.78317 19.9142 3.98579C20.1168 4.1884 20.2735 4.43219 20.3737 4.70065C20.4739 4.9691 20.5153 5.25595 20.495 5.54178C20.4747 5.8276 20.3932 6.10572 20.256 6.35731C20.1189 6.6089 19.9292 6.82807 19.7 7L19.6 7.1C19.3841 7.34048 19.2411 7.63753 19.188 7.95634C19.1349 8.27514 19.1737 8.60249 19.3 8.9V9C19.4311 9.29055 19.6413 9.53839 19.9065 9.71521C20.1718 9.89203 20.4814 9.99071 20.8 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.9C20.5814 14.0093 20.2718 14.108 20.0065 14.2848C19.7413 14.4616 19.5311 14.7094 19.4 15Z"],
723
+ bell: ["M10.3 21C10.4577 21.3159 10.7003 21.5816 11.0006 21.7674C11.3008 21.9531 11.6469 22.0515 12 22.0515C12.3531 22.0515 12.6992 21.9531 12.9994 21.7674C13.2997 21.5816 13.5423 21.3159 13.7 21M6 8C6 6.4087 6.63214 4.88258 7.75736 3.75736C8.88258 2.63214 10.4087 2 12 2C13.5913 2 15.1174 2.63214 16.2426 3.75736C17.3679 4.88258 18 6.4087 18 8C18 15 21 17 21 17H3C3 17 6 15 6 8Z"],
724
+ };
725
+
726
+ function NcIcon({ name }) {
727
+ // 24 viewBox drawn at 16. Stroke 2.16 in viewBox units renders 1.44, the
728
+ // instances' stroke weight.
729
+ return (
730
+ <svg width={L.actionIconGlyph} height={L.actionIconGlyph} viewBox="0 0 24 24" fill="none" aria-hidden="true"
731
+ stroke="currentColor" strokeWidth="2.16" strokeLinecap="round" strokeLinejoin="round" style={{ display: "block" }}>
732
+ {NC_ICONS[name].map((d, i) => <path key={i} d={d} />)}
733
+ </svg>
734
+ );
735
+ }
736
+
737
+ /** NewCo.Logo / Mark / Reversed, 27x32, painted in Foreground/Action/Ghost/Rest. */
738
+ function NewCoMark() {
739
+ return (
740
+ <svg width="27" height="32" viewBox="0 0 27 32" fill="currentColor" aria-hidden="true" style={{ display: "block" }}>
741
+ <path d="M2.29958 25.2142C1.02842 25.2142 0 24.3456 0 23.277C0 22.2765 0.900517 21.4519 2.05683 21.3508L22.8862 19.5081C23.0036 19.4971 23.1237 19.4927 23.2438 19.4927C25.1205 19.4927 26.6422 20.7747 26.6422 22.3556C26.6422 23.9366 25.1205 25.2186 23.2438 25.2186H2.29958V25.2142Z" />
742
+ <path d="M22.2387 5.52125C23.6274 4.83081 25.418 5.22221 26.2376 6.392C27.0572 7.56179 26.5925 9.07021 25.2039 9.76065C25.0369 9.84201 24.8672 9.91018 24.6923 9.96295L15.2956 12.7643C14.429 13.0238 13.4398 12.7379 12.9543 12.0453C12.4061 11.2603 12.7168 10.2488 13.6486 9.78484L22.2413 5.52125H22.2387Z" />
743
+ <path d="M8.7547 19.5479C7.74194 19.6754 6.74224 19.1631 6.43424 18.3121C6.09491 17.3754 6.71875 16.3837 7.83069 16.0979L22.453 12.3334C24.1653 11.8936 25.9767 12.705 26.4988 14.1453C27.0208 15.5877 26.0577 17.1137 24.348 17.5535C24.1914 17.5931 24.0322 17.6239 23.8755 17.6437L8.7547 19.5457V19.5479Z" />
744
+ </svg>
745
+ );
746
+ }
747
+
748
+ // Type styles the NewCo bar uses, composed from the scale. Figma binds the
749
+ // tracking to the primitive tier; these are the semantic names for the same values.
750
+ const NC_TYPE = {
751
+ menuBase: { fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-s-single)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)" }, // Label/Menu/Base/Medium
752
+ menuLarge: { fontSize: "var(--semantic-type-font-size-r)", fontWeight: "var(--semantic-type-weight-medium)", lineHeight: "var(--semantic-type-line-height-r-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)" }, // Label/Menu/Large/Medium
753
+ sectionSm: { fontSize: "var(--semantic-type-font-size-r)", fontWeight: "var(--semantic-type-weight-semibold)", lineHeight: "var(--semantic-type-line-height-r-single)", letterSpacing: "var(--semantic-type-letter-spacing-wide)" }, // Heading/Section/Small/Semibold
754
+ localBase: { fontSize: "var(--semantic-type-font-size-s)", fontWeight: "var(--semantic-type-weight-semibold)", lineHeight: "var(--semantic-type-line-height-s-tight)", letterSpacing: "var(--semantic-type-letter-spacing-spacious)" }, // Heading/Local/Base/Semibold
755
+ };
756
+
757
+ // Hover, press and focus as one hook, so every NewCo control reads its state the same way.
758
+ function usePress() {
759
+ const [hov, setHov] = useState(false);
760
+ const [press, setPress] = useState(false);
761
+ return { hov, press, handlers: {
762
+ onMouseEnter: () => setHov(true), onMouseLeave: () => { setHov(false); setPress(false); },
763
+ onMouseDown: () => setPress(true), onMouseUp: () => setPress(false), onBlur: () => setPress(false),
764
+ } };
765
+ }
766
+
767
+ /**
768
+ * Pillar.Item, Figma set 40018002:15993. Type=Brand is the first tab (the
769
+ * product name); Type=Default the module tabs. 36 tall inside a 44 target,
770
+ * CornerRadius/Full, padding Padding/Base (desktop) or Padding/Tight (tablet).
771
+ *
772
+ * Base no fill, Foreground/Action/Ghost/Rest
773
+ * Hover Fill/Action/Ghost/Rest, Foreground/Action/Ghost/Hover
774
+ * Pressed Fill/Action/Ghost/Pressed, Foreground/Action/Ghost/Pressed
775
+ * Active Fill/Action/Ghost/Hover, Foreground/Action/Ghost/Pressed, semibold style
776
+ */
777
+ function PillarItem({ label, active, brandItem, tablet, onClick }) {
778
+ const { hov, press, handlers } = usePress();
779
+ const fill = active ? T.controlHover : press ? T.controlPressed : hov ? T.searchFill : "transparent";
780
+ const fg = active || press ? T.monoPressed : hov ? T.monoHover : T.monoBase;
781
+ const type = brandItem ? (active ? NC_TYPE.sectionSm : NC_TYPE.menuLarge) : (active ? NC_TYPE.localBase : NC_TYPE.menuBase);
782
+ return (
783
+ <button type="button" aria-current={active ? "page" : undefined} onClick={onClick} {...handlers}
784
+ style={{ display: "flex", alignItems: "center", minWidth: L.actionTarget, minHeight: L.actionTarget,
785
+ padding: 0, border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit" }}>
786
+ <span style={{ display: "flex", alignItems: "center", height: L.modInnerH, boxSizing: "border-box",
787
+ padding: `0 ${tablet ? u("padding-tight") : u("padding-base")}`, borderRadius: u("cornerradius-full"),
788
+ background: fill, color: fg, whiteSpace: "nowrap", ...type,
789
+ transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)" }}>
790
+ {label}
791
+ </span>
792
+ </button>
793
+ );
794
+ }
795
+
796
+ /**
797
+ * Org Switcher, Brand=NewCo (set 40006819:14583). Pill-shaped (CornerRadius/Full)
798
+ * with a resting Stroke/Action/Ghost/Rest at Button/BorderWidth/Rest that stays
799
+ * in every state; the fill follows the state, the label stays on Ghost/Rest.
800
+ * Type=Desktop: padding 6,6,6,12, gap Gap/XXTight, chevron box 24.
801
+ * Type=Mobile (tablet and mobile): padding 8,4,8,8, gap Gap/XXXTight, chevron box 20,
802
+ * label from org.shortName (Figma Label-Mobile).
803
+ */
804
+ function NewCoOrgSwitcher({ org, open, onToggle, compact }) {
805
+ const { hov, press, handlers } = usePress();
806
+ const fill = open || press ? T.controlPressed : hov ? T.controlHover : "transparent";
807
+ return (
808
+ <div style={{ position: "relative", padding: L.slotPadV, minWidth: 0 }}>
809
+ <button type="button" onClick={onToggle} {...handlers}
810
+ aria-haspopup="true" aria-expanded={open}
811
+ aria-label={`Switch organisation: ${org.name}${org.campus ? ", " + org.campus : ""}`}
812
+ style={{ display: "flex", alignItems: "center", boxSizing: "border-box", minHeight: L.modInnerH, minWidth: 0,
813
+ maxWidth: compact ? L.orgMainMaxMobile : L.orgMainMax,
814
+ gap: compact ? "var(--semantic-layout-units-gap-xxxtight)" : "var(--semantic-layout-units-gap-xxtight)",
815
+ padding: compact ? `${u("padding-xtight")} ${u("padding-xxxtight")} ${u("padding-xtight")} ${u("padding-xtight")}`
816
+ : `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
817
+ borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
818
+ boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${T.orgStroke}`,
819
+ transition: "background var(--motion-duration-3) var(--motion-easing-standard)" }}>
820
+ <span aria-hidden="true" style={{ ...NC_TYPE.menuBase, color: T.monoBase, whiteSpace: "nowrap", overflow: "hidden",
821
+ textOverflow: "ellipsis", minWidth: 0, maxWidth: compact ? L.orgNameMaxMobile : L.orgNameMax }}>
822
+ {compact ? (org.shortName || org.name) : org.name}
823
+ </span>
824
+ <span style={{ width: compact ? L.modChevronBox : L.orgChevronBox, height: compact ? L.modChevronBox : L.orgChevronBox,
825
+ display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
826
+ transform: open ? "rotate(180deg)" : "none", transition: "transform var(--motion-duration-4) var(--motion-easing-standard)" }}>
827
+ <Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.orgChevron }} />
828
+ </span>
829
+ </button>
830
+ </div>
831
+ );
832
+ }
833
+
834
+ /** Pillar trigger, mobile: the product name as a pill dropdown, 36 inside a 48 slot. */
835
+ function PillarTrigger({ label, open, onToggle }) {
836
+ const { hov, press, handlers } = usePress();
837
+ const fill = open || press ? T.controlPressed : hov ? T.controlHover : "transparent";
838
+ return (
839
+ <div style={{ padding: `${L.slotPadV} ${L.slotPadNarrow}` }}>
840
+ <button type="button" onClick={onToggle} aria-haspopup="true" aria-expanded={open} {...handlers}
841
+ style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)", height: L.modInnerH,
842
+ boxSizing: "border-box", padding: `0 10px 0 ${u("padding-medium")}`, /* pathway-allow: Figma Pillar.Trigger right padding 10 has no token (gap logged in the spec) */
843
+ borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
844
+ boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${T.orgStroke}`, color: T.monoBase }}>
845
+ <span style={{ ...NC_TYPE.sectionSm, whiteSpace: "nowrap" }}>{label}</span>
846
+ <span style={{ width: L.modChevronBox, height: L.modChevronBox, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
847
+ <Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.monoBase }} />
848
+ </span>
849
+ </button>
850
+ </div>
851
+ );
852
+ }
853
+
854
+ /** One NewCo action: a 44 target, CornerRadius/Base, a 16 line icon, optional Badge. */
855
+ function NewCoAction({ icon, label, badge, onClick }) {
856
+ const { hov, press, handlers } = usePress();
857
+ return (
858
+ <button type="button" aria-label={label} onClick={onClick} {...handlers}
859
+ style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center",
860
+ width: L.actionTarget, height: L.actionTarget, padding: 0, border: "none", cursor: "pointer",
861
+ borderRadius: L.radius, background: press ? T.controlPressed : hov ? T.controlHover : "transparent",
862
+ color: press ? T.monoPressed : hov ? T.monoHover : T.monoBase,
863
+ transition: "background var(--motion-duration-2) var(--motion-easing-standard), color var(--motion-duration-2) var(--motion-easing-standard)" }}>
864
+ <NcIcon name={icon} />
865
+ {/* Badge: 8 circle at 26,10, Fill/Static/Attention/Strong with a 1.5 ring in
866
+ Fill/Surface/Chrome drawn INSIDE the 8, as Figma strokes it. */}
867
+ {badge && <span aria-hidden="true" style={{ position: "absolute", left: 26, top: 10, width: 8, height: 8,
868
+ boxSizing: "border-box", borderRadius: "50%", background: SCD("fill-static-attention-strong"),
869
+ boxShadow: `inset 0 0 0 var(--semantic-layout-units-borderwidth-medium) ${T.navBg}` }} />}
870
+ </button>
871
+ );
872
+ }
873
+
874
+ export const NEWCO_PILLARS = [
875
+ { id: "newco", label: "NewCo", brand: true },
876
+ { id: "people", label: "People" },
877
+ { id: "giving", label: "Giving" },
878
+ { id: "financials", label: "Financials" },
879
+ { id: "digital", label: "Digital" },
880
+ ];
881
+
882
+ export const NEWCO_ACTIONS = [
883
+ { icon: "search", label: "Search" },
884
+ { icon: "help", label: "Help" },
885
+ { icon: "gear", label: "Settings" },
886
+ { icon: "bell", label: "Notifications", badge: true },
887
+ ];
888
+
889
+ function TopNavNewCo({ navRef, className, breakpoint, org, user, openPanel, toggle, close, orgs, onOrgSelect,
890
+ pillars, activePillarId, onPillarSelect, ncActions, onSideNavToggle, onMore }) {
891
+ const isMobile = breakpoint === "mobile", isTablet = breakpoint === "tablet";
892
+ const brandItem = pillars.find((x) => x.brand) || pillars[0];
893
+ // Tablet keeps search and notifications; mobile folds the actions into more_vert.
894
+ const shown = isTablet ? ncActions.filter((a) => a.icon === "search" || a.icon === "bell") : ncActions;
895
+ const org_ = <NewCoOrgSwitcher org={org} open={openPanel === "org"} onToggle={() => { toggle("org"); onOrgSelect?.(); }} compact={isMobile || isTablet} />;
896
+ return (
897
+ <div style={{ background: "var(--semantic-color-fill-surface-chrome)", position: "relative", zIndex: 100 }}>
898
+ <nav ref={navRef} aria-label="Global navigation" className={className}
899
+ style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
900
+ height: L.navH, maxHeight: L.navH, boxSizing: "border-box",
901
+ // Container.Main padding 6 by 16 desktop and tablet, 6 by 8 mobile.
902
+ padding: `${L.navPadV} ${isMobile ? u("padding-xtight") : u("padding-base")}`, gap: L.navGap,
903
+ position: "relative", fontFamily: "'Red Hat Text', sans-serif",
904
+ WebkitFontSmoothing: "antialiased", MozOsxFontSmoothing: "grayscale" }}>
905
+ <div style={{ display: "flex", alignItems: "center", flexShrink: 0, minWidth: 0,
906
+ gap: isMobile ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
907
+ {isMobile ? (
908
+ <>
909
+ <ActionIcon name="menu" size="Large" label="Open navigation menu" color={T.monoBase} onClick={onSideNavToggle} />
910
+ <PillarTrigger label={brandItem.label} open={openPanel === "pillar"} onToggle={() => toggle("pillar")} />
911
+ {org_}
912
+ </>
913
+ ) : (
914
+ <>
915
+ {/* Logo.Home: 48 tall, padding 0 by Padding/XTight, CornerRadius/Base */}
916
+ <a href="#" aria-label="Home" onClick={(e) => e.preventDefault()}
917
+ style={{ display: "flex", alignItems: "center", height: L.touchTarget, padding: `0 ${u("padding-xtight")}`,
918
+ borderRadius: L.radius, color: T.monoBase }}>
919
+ <NewCoMark />
920
+ </a>
921
+ {/* Divider.Logo: 1 by 28, Fill/Action/Ghost/Pressed */}
922
+ <span aria-hidden="true" style={{ width: 1, height: 28, borderRadius: 1, background: T.controlPressed, flexShrink: 0 }} />
923
+ <div role="navigation" aria-label="Products"
924
+ style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)", paddingLeft: u("padding-xtight") }}>
925
+ {pillars.map((p) => (
926
+ <PillarItem key={p.id} label={p.label} brandItem={!!p.brand} tablet={isTablet}
927
+ active={p.brand ? activePillarId === p.id || !activePillarId : activePillarId === p.id}
928
+ onClick={() => onPillarSelect?.(p.id)} />
929
+ ))}
930
+ </div>
931
+ </>
932
+ )}
933
+ </div>
934
+ <div style={{ display: "flex", alignItems: "center", flexShrink: 0,
935
+ gap: isMobile || isTablet ? "var(--semantic-layout-units-gap-xxtight)" : "var(--semantic-layout-units-gap-tight)" }}>
936
+ {!isMobile && org_}
937
+ {isMobile ? (
938
+ <TopNavActionIcon icon="more_vert" label="More actions" onClick={onMore} />
939
+ ) : (
940
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)" }}>
941
+ {shown.map((a) => <NewCoAction key={a.label} {...a} />)}
942
+ </div>
943
+ )}
944
+ <TopNavProfile user={user} open={openPanel === "profile"} onToggle={() => toggle("profile")} mobile={isMobile} />
945
+ {openPanel === "profile" && <TopNavProfileMenu user={user} />}
946
+ </div>
947
+ {openPanel === "org" && (
948
+ <div style={{ position: "absolute", top: "calc(100% + 4px)", right: L.navPadH, zIndex: 300,
949
+ animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both" }}>
950
+ <OrgSwitcherPanel orgs={orgs} activeOrgId={org.id} onSelect={(o) => { close(); onOrgSelect?.(o); }} />
951
+ </div>
952
+ )}
953
+ </nav>
954
+ </div>
955
+ );
956
+ }
957
+
660
958
  // ─── TopNav ────────────────────────────────────────────────────────────────────
661
959
  /**
662
960
  * TopNav.Global component.
@@ -669,11 +967,19 @@ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint =
669
967
  * (no avatar/placeholder). logoUrl, when provided, renders the logo
670
968
  * in the nav trigger AND in the org-panel dropdown.
671
969
  * user, { name, initials, email, avatarUrl? }
970
+ * showCampus, boolean, default false. Figma Org Switcher "Show CityName
971
+ * (Catholic)": shows org.campus beside the org name.
972
+ * brand, "amplify" | "newco" Figma Brand. NewCo is its own layout: logo,
973
+ * Pillar tabs, pill org switcher, line-icon actions.
974
+ * pillars, activePillarId, onPillarSelect NewCo Pillar tabs (default NEWCO_PILLARS)
975
+ * newcoActions NewCo action icons (default NEWCO_ACTIONS)
672
976
  * breakpoint, "desktop" | "tablet" | "mobile"
673
977
  * onModuleSelect, (id: string) => void
674
978
  * onOrgSelect, () => void
675
979
  * onSearchOpen, () => void
676
980
  * onSideNavToggle, () => void (mobile only)
981
+ * actions, Array<{icon, label, onClick}> Figma TopNav.Actions slot (default: two bells).
982
+ * On mobile, more than one folds into more_vert.
677
983
  * onNotifications, () => void
678
984
  * onMore, () => void
679
985
  * className, string
@@ -685,12 +991,19 @@ export function TopNav({
685
991
  orgs = DEMO_ORGS, // list shown in the OrgSwitcher "Open" dropdown panel
686
992
  user = { name: "Jo Lopez", initials: "JL", email: "jo@sacredheart.org" },
687
993
  breakpoint = "desktop",
994
+ brand = "amplify", // Figma "Brand": "amplify" | "newco"
995
+ pillars = NEWCO_PILLARS,
996
+ activePillarId,
997
+ onPillarSelect,
998
+ newcoActions = NEWCO_ACTIONS,
999
+ showCampus = false, // Figma Org Switcher "Show CityName (Catholic)", default off
688
1000
  moduleSwitcherType = "interactive", // "interactive" | "static" (Amplify Dashboard), Figma "Type"
689
1001
  initialOpenPanel = null, // "module" | "org" | "profile" | null, seeds the open panel (demo/snapshot use)
690
1002
  onModuleSelect,
691
1003
  onOrgSelect,
692
1004
  onSearchOpen,
693
1005
  onSideNavToggle,
1006
+ actions,
694
1007
  onNotifications,
695
1008
  onMore,
696
1009
  className = "",
@@ -730,6 +1043,13 @@ export function TopNav({
730
1043
  // entire TopNav so it can never collide with the OrgSwitcher or other controls.
731
1044
  const useTakeover = searchExpanded;
732
1045
 
1046
+ if (brand === "newco") {
1047
+ return <TopNavNewCo navRef={navRef} className={className} breakpoint={breakpoint} org={org} user={user} orgs={orgs}
1048
+ openPanel={openPanel} toggle={toggle} close={close} onOrgSelect={onOrgSelect}
1049
+ pillars={pillars} activePillarId={activePillarId} onPillarSelect={onPillarSelect}
1050
+ ncActions={newcoActions} onSideNavToggle={onSideNavToggle} onMore={onMore} />;
1051
+ }
1052
+
733
1053
  return (
734
1054
  // TWO ELEMENTS, AND THE SPLIT IS THE POINT.
735
1055
  //
@@ -787,10 +1107,14 @@ export function TopNav({
787
1107
  padding: `${L.navPadV} ${L.navPadH}`, gap: L.navGap,
788
1108
  position: "relative", overflow: "visible",
789
1109
  fontFamily: "'Red Hat Text', sans-serif",
1110
+ // Grayscale smoothing, as Figma renders. macOS subpixel smoothing
1111
+ // draws light text on a dark bar visibly bolder, which made every
1112
+ // label and the avatar initials look a weight heavier than Figma.
1113
+ WebkitFontSmoothing: "antialiased", MozOsxFontSmoothing: "grayscale",
790
1114
  }}
791
1115
  >
792
- {/* ── Slot.RowStart ── */}
793
- <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
1116
+ {/* ── Slot.RowStart ── gap Gap/Tight; 0 on mobile (Figma Slot.RowStart.Mobile) */}
1117
+ <div style={{ display: "flex", alignItems: "center", gap: isMobile ? 0 : "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
794
1118
 
795
1119
  {/* SideNav.Control, mobile only. Figma set 40007082:7703: three State
796
1120
  variants wrapping an `Action Icon` at Size=Large, so a 48x48 target,
@@ -859,6 +1183,7 @@ export function TopNav({
859
1183
  open={openPanel === "org"}
860
1184
  onToggle={() => { toggle("org"); onOrgSelect?.(); }}
861
1185
  mobile={isMobile}
1186
+ showCampus={showCampus}
862
1187
  />
863
1188
 
864
1189
  {/* Org panel: the real OrgSwitcher "Open" dropdown (Figma 40007336:9453) */}
@@ -877,8 +1202,8 @@ export function TopNav({
877
1202
  )}
878
1203
  </div>
879
1204
 
880
- {/* ── Slot.RowEnd ── */}
881
- <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
1205
+ {/* ── Slot.RowEnd ── gap Gap/Tight; 0 on mobile (Figma Slot.RowEnd.Mobile) */}
1206
+ <div style={{ display: "flex", alignItems: "center", gap: isMobile ? 0 : "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
882
1207
 
883
1208
  {/* Collapsed search pill. Opening it triggers the full-bar takeover
884
1209
  overlay below (never an inline expand), so it cannot overlap the
@@ -891,7 +1216,7 @@ export function TopNav({
891
1216
  searchProps={{ value: searchQuery, onChange: setSearchQuery, onClear: () => setSearchQuery("") }}
892
1217
  />
893
1218
 
894
- <TopNavActions breakpoint={breakpoint} onNotifications={onNotifications} onMore={onMore} />
1219
+ <TopNavActions breakpoint={breakpoint} actions={actions} onNotifications={onNotifications} onMore={onMore} />
895
1220
 
896
1221
  <TopNavProfile
897
1222
  user={user}
@@ -901,39 +1226,7 @@ export function TopNav({
901
1226
  />
902
1227
 
903
1228
  {/* Profile menu */}
904
- {openPanel === "profile" && (
905
- <div role="menu" data-theme="light"
906
- style={{
907
- position: "absolute", top: "calc(100% + 4px)", right: L.navPadH,
908
- width: 200, background: T.panelBg,
909
- border: `1px solid ${T.panelBorder}`, borderRadius: L.radius,
910
- boxShadow: T.panelShadow, padding: "var(--semantic-layout-units-padding-xxxtight)", zIndex: 300,
911
- animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
912
- }}
913
- >
914
- <div style={{ padding: " ",
915
- borderBottom: `1px solid ${T.panelDivider}`, marginBottom: 4 }}>
916
- <div style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 600, color: T.itemText }}>{user.name}</div>
917
- <div style={{ fontSize: "var(--semantic-type-font-size-xxs)", color: T.itemMeta, marginTop: 1 }}>{user.email}</div>
918
- </div>
919
- {["Profile settings", "Settings"].map(label => (
920
- <button key={label} role="menuitem"
921
- style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
922
- borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
923
- color: T.itemTextBase, background: "transparent",
924
- border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
925
- {label}
926
- </button>
927
- ))}
928
- <button role="menuitem"
929
- style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", padding: "",
930
- borderRadius: "var(--semantic-layout-units-cornerradius-base)", fontFamily: "inherit", fontSize: "var(--semantic-type-font-size-s)",
931
- color: T.signOut, background: "transparent",
932
- border: "none", width: "100%", textAlign: "left", cursor: "pointer" }}>
933
- Sign out
934
- </button>
935
- </div>
936
- )}
1229
+ {openPanel === "profile" && <TopNavProfileMenu user={user} />}
937
1230
  </div>
938
1231
 
939
1232
  {/* Full-width search takeover, shown whenever search is open, on every
@@ -949,13 +1242,12 @@ export function TopNav({
949
1242
  Escape still closes. */}
950
1243
  {useTakeover && (
951
1244
  <div
952
- // data-theme="light" because the takeover IS the bar, and the bar
953
- // resolves its chrome in light. Without it the takeover sat inside the
954
- // bar's own data-theme="midnight" region, where Fill/Surface/Chrome is
955
- // #152343 rather than #2d4889: so opening search visibly darkened the
956
- // whole bar. Measured 2026-10-02: bar rgb(45,72,137), takeover
957
- // rgb(21,35,67).
958
- data-theme="light"
1245
+ // NO data-theme. The takeover IS the bar, so it paints
1246
+ // Fill/Surface/Chrome in the page's own mode, exactly as the bar does.
1247
+ // It used to force data-theme="light", left over from when the bar
1248
+ // itself carried "midnight"; once the bar followed the page, that
1249
+ // override made opening search switch the bar to the Light chrome on
1250
+ // any dark page. Removed 2026-11-02.
959
1251
  style={{
960
1252
  position: "absolute", inset: 0, zIndex: 200,
961
1253
  background: "var(--semantic-color-fill-surface-chrome)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,