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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * TopNav.Global, Pathway Design System
3
+ *
4
+ * Importable React component module. Source of truth for the TopNav.Global
5
+ * implementation; the standalone demo (top-nav.html) and Storybook stories
6
+ * both consume this.
7
+ *
8
+ * Spec: components/top-nav/top-nav-spec.md
9
+ * Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40007067-6508
10
+ */
11
+
12
+ import React, { useState, useEffect, useRef } from "react";
13
+ import { ActionIcon } from "../icon/action-icon.jsx";
14
+ import { Icon } from "../icon/icon.jsx";
15
+
16
+ // TopNav.Search is the canonical nested search control. Import it: never
17
+ // reimplement it here. (See CLAUDE.md §10.1 "import, don't reinvent": the prior
18
+ // in-file duplicate drifted to hardcoded hex and the wrong token mode.)
19
+ import { TopNavSearch, SearchInput } from "../search/search.jsx";
20
+ // The OrgSwitcher "Open" dropdown lives in the org-switcher component (Figma
21
+ // node 40007336:9453). TopNav renders it when the org trigger is open.
22
+ import { OrgSwitcherPanel, DEMO_ORGS } from "../org-switcher/org-switcher.jsx";
23
+ import { ModuleIcon, MODULE_LABELS, MODULES } from "../module-icon/module-icon.jsx";
24
+
25
+ // ─── DESIGN TOKENS ─────────────────────────────────────────────────────────────
26
+ // Bound to live semantic CSS variables (CLAUDE.md §6). The TopNav bar is a dark
27
+ // brand-blue surface, so every CONTROL on it resolves through the DARK-MODE token
28
+ // set (tertiary / primaryinverse / mono). The brand bg, the accent avatar, and the
29
+ // white dropdown panels use the LIGHT-MODE set. Each var() carries the previously
30
+ // hand-copied hex as a fallback, so rendering is unchanged if a var is unavailable;
31
+ // values were verified against get_variable_defs on Figma node 40007067:5284.
32
+ // Both helpers emit the MODELESS name. The mode is no longer baked into the
33
+ // property: it comes from the nearest [data-theme] ancestor, which is why the bar
34
+ // carries data-theme="midnight" and the dropdown panels carry data-theme="light".
35
+ // SCD and SCL are kept as separate names purely to document INTENT at each call
36
+ // site: SCD marks "this sits on the dark bar", SCL marks "this sits on a light
37
+ // panel". They resolve identically; the wrapper does the work.
38
+ // MOTION THE COMPONENT OWNS. The dropdown keyframe tnDropIn is defined in the
39
+ // STORY file, not here, which is why the search takeover had no motion at all:
40
+ // nothing had ever defined one for it, and a component cannot rely on its
41
+ // consumer to supply a keyframe it needs. Reported 2026-10-02.
42
+ //
43
+ // The bar EXPANDS from the control it replaces. clip-path reveals it leftward
44
+ // from the right-hand edge, where the collapsed search icon sits, so the field
45
+ // is uncovered rather than slid into place. Nothing inside translates or
46
+ // scales, so the placeholder and the back arrow stay sharp for the whole
47
+ // transition, which a scaleX would smear. The inset starts at the collapsed
48
+ // control's own width, 48, so the reveal begins exactly where the icon was.
49
+ if (typeof document !== "undefined" && !document.getElementById("pds-topnav-motion")) {
50
+ const el = document.createElement("style");
51
+ el.id = "pds-topnav-motion";
52
+ el.textContent =
53
+ "@keyframes tnSearchExpand{" +
54
+ "from{clip-path:inset(0 0 0 calc(100% - 48px));opacity:.6}" +
55
+ "to{clip-path:inset(0 0 0 0);opacity:1}}" +
56
+ "@media (prefers-reduced-motion: reduce){" +
57
+ "@keyframes tnSearchExpand{from{clip-path:inset(0);opacity:1}to{clip-path:inset(0);opacity:1}}}";
58
+ document.head.appendChild(el);
59
+ }
60
+
61
+ const TOK = (p) => `var(--semantic-color-${p})`;
62
+ const SCD = TOK; // on the dark bar
63
+ const SCL = TOK; // on a light panel
64
+ // Layout helpers. `r` reads Responsive: Layout, the only collection with
65
+ // breakpoint modes: layout-responsive.css emits ONE property name and switches
66
+ // its value by media query, so the chrome's padding no longer needs a JS
67
+ // breakpoint branch. `u` reads the single-valued semantic scale.
68
+ const r = (name) => `var(--responsive-layout-${name})`;
69
+ const u = (name) => `var(--semantic-layout-units-${name})`;
70
+ // Contextual: Layout & Units. TopNav/Height lives HERE, not in Responsive:
71
+ // Layout, because the bar is the same height at every breakpoint, only its
72
+ // padding and gap change. navH used to read the height through the RESPONSIVE
73
+ // helper, asking for a property that no emitted file declares. The two-argument
74
+ // helper form hid it, because the reference checker skips a call it cannot
75
+ // evaluate statically: the height resolved to a hard-coded 60px fallback for as
76
+ // long as that fallback existed, and would have painted nothing the moment it
77
+ // was removed. Corrected 2026-09-30 while removing the fallbacks.
78
+ const c = (name) => `var(--contextual-layout-units-${name})`;
79
+ export const T = {
80
+ navBg: SCL("fill-surface-chrome"),
81
+ // GHOST IS THIS BAR'S FAMILY. Everything that sits on the brand chrome and
82
+ // answers a pointer is Action/Ghost: the org switcher, the module switcher,
83
+ // the collapsed search, the side nav control, the action icons, the avatar
84
+ // button. All of it was Action/Primary/Subtle, which is a family for LIGHT
85
+ // surfaces, so the whole bar was quietly wearing the wrong tier. Reported
86
+ // 2026-10-02. Measured against Figma the same day:
87
+ //
88
+ // Fill/Action/Ghost/Rest #b6c6ec @ 8%
89
+ // Fill/Action/Ghost/Hover #b6c6ec @ 16%
90
+ // Fill/Action/Ghost/Pressed #22386b @ 70%
91
+ // Stroke/Action/Ghost/Rest #b6c6ec @ 36%
92
+ // Stroke/Action/Ghost/Hover #b6c6ec @ 50%
93
+ // Stroke/Action/Ghost/Pressed #b6c6ec @ 70%
94
+ //
95
+ // Ghost is the right family here for the reason it is the WRONG family on a
96
+ // light page: its foreground is near-white in both modes, which is exactly
97
+ // what a control on a permanently dark bar needs.
98
+ //
99
+ // The AVATAR is the single exception and stays Action/Primary/Subtle: it is a
100
+ // filled identity chip, not a ghost control. See avatarBg/avatarText below.
101
+ //
102
+ // THERE IS NO RESTING FILL on the org switcher or the module switcher. Figma's
103
+ // State=Base has no fill on Container.Main at all, for either of them; the
104
+ // fill arrives on Hover and stays through Pressed and Open. The org switcher
105
+ // does carry a resting STROKE; the module switcher carries nothing at rest.
106
+ // The collapsed search is different again: it is the one control with a
107
+ // resting ghost fill, because it reads as a field rather than a label.
108
+ orgStroke: SCD("stroke-action-ghost-rest"),
109
+ orgStrokeHover: SCD("stroke-action-ghost-hover"),
110
+ orgStrokePressed: SCD("stroke-action-ghost-pressed"),
111
+ searchFill: SCD("fill-action-ghost-rest"),
112
+ controlHover: SCD("fill-action-ghost-hover"),
113
+ controlPressed: SCD("fill-action-ghost-pressed"),
114
+ noLogoBg: SCD("fill-action-secondary-rest"),
115
+ // FOREGROUNDS ARE GHOST TOO. Every glyph and label on the bar is
116
+ // Foreground/Action/Ghost: #e2e9f7 rest, #eef2fb hover, #f9fafd pressed in
117
+ // Light, and #e8eafe / #f0f1ff / #f8f9ff in Midnight. Reported 2026-10-02.
118
+ //
119
+ // They were Foreground/Static/Neutral/Base and /Bold. On screen that looked
120
+ // acceptable, which is exactly why it survived: the bar carries
121
+ // data-theme="midnight" permanently, and inside a midnight region the neutral
122
+ // ladder resolves LIGHT (base is #e8eafe), so a near-black Light-mode value
123
+ // came out near-white by accident. The colour was right by side effect and the
124
+ // token was wrong on its face. Ghost names the thing directly: a control
125
+ // foreground for a dark, on-brand surface, near-white in BOTH modes, so it no
126
+ // longer depends on the data-theme trick to be legible.
127
+ //
128
+ // Before that it was foreground-static-neutral-MONO, which is an inversion
129
+ // anchor rather than a rung: #ffffff in Light and #181b2b in Midnight, so the
130
+ // label, the notification icons and the more-actions icon were all painting
131
+ // #181b2b on #2d4889. It went unnoticed because the one element anybody
132
+ // looked at, the Home module mark, was a hand-drawn SVG with fill="white"
133
+ // baked in, so exactly one thing on the bar was legible and it was legible
134
+ // for the wrong reason.
135
+ //
136
+ // Code Connect does not protect against any of this: it publishes a code
137
+ // snippet from this repo into Figma's dev panel, one way, and never compares
138
+ // a token value in the file against a token value in the design.
139
+ monoBase: SCD("foreground-action-ghost-rest"),
140
+ monoHover: SCD("foreground-action-ghost-hover"),
141
+ monoPressed: SCD("foreground-action-ghost-pressed"),
142
+ // Org switcher label and chevron. Ghost has no weight axis, so the label's
143
+ // emphasis comes from font-weight at the call site, not from a bolder colour.
144
+ orgText: SCD("foreground-action-ghost-rest"),
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"),
153
+ avatarBg: SCD("fill-action-primary-subtle-rest"),
154
+ avatarText: SCD("foreground-action-primary-on-subtle-rest"),
155
+ // White dropdown-menu surface, tracks fill-neutral-light (now warm-neutral-0).
156
+ panelBg: SCL("fill-static-neutral-faint"),
157
+ activeItem: SCL("fill-action-primary-subtle-rest"),
158
+ itemText: SCL("foreground-static-neutral-bold"),
159
+ itemTextBase: SCL("foreground-static-neutral-base"),
160
+ itemMeta: SCL("foreground-static-neutral-base"),
161
+ signOut: SCL("foreground-action-status-negative-on-subtle-rest"),
162
+ // Token gaps (no semantic token exists yet, flagged P2 in the pipeline report):
163
+ panelBorder: SCL("stroke-static-neutral-base"),
164
+ // The dropdown panels are a LIFT, and Elevation already encodes the whole
165
+ // shadow per theme. The two-layer rgba stack it replaces was a light-mode
166
+ // value that stayed light-mode on a dark surface.
167
+ panelShadow: "var(--elevation-lift)",
168
+ panelDivider: SCL("stroke-static-neutral-faint"),
169
+ };
170
+
171
+ // ─── LAYOUT VALUES ─────────────────────────────────────────────────────────────
172
+ export const L = {
173
+ // TopNav is chrome that DOES change across breakpoints, so its padding, height
174
+ // and gap live in Responsive: Layout and resolve by media query. One name each,
175
+ // no JS branch. Horizontal padding is 16 / 12 / 8 desktop / tablet / mobile,
176
+ // measured from Container.Main in Figma; vertical is 6 and the gap 8 at every
177
+ // breakpoint. Before 2026-09-17 these were six hardcoded numbers here, and the
178
+ // tablet height said 54 while the token said 56.
179
+ navPadH: r("topnav-padding-horizontal"),
180
+ navPadV: r("topnav-padding-vertical"),
181
+ navH: c("topnav-height"),
182
+ navGap: r("topnav-gap-horizontal"),
183
+ deskOrgMax: 316, mobOrgMax: 120,
184
+ touchTarget: 48, modInnerH: 36,
185
+ orgAvatarNav: 20, orgAvatarSm: 24, orgAvatarPanel: 32,
186
+ searchPill: 32, avatarSize: 32,
187
+ // ICON GEOMETRY, read off the leaf nodes of Figma TopNav.Global 40007067:6508
188
+ // on 2026-10-02 rather than inferred from container padding:
189
+ // ModuleSwitcher Container.RowEnd 20x20 box, expand_more 12x12
190
+ // Org Switcher Container.RowEnd 24x24 box, expand_more 12x12
191
+ // TopNav.Actions Container.Icon 28x28 box, glyph 14x14
192
+ // TopNav.Search Container.Icon 32x32 box, search 16x16
193
+ // The chevron glyph is the SAME 12 in both switchers; only the box differs,
194
+ // which is why one constant serves both.
195
+ // Org Switcher Container.Main padding, Figma 6,6,6,12.
196
+ modPadL: u("padding-xxtight"), // 6 ModuleSwitcher Container.Main, left only
197
+ profilePad: u("padding-xxxtight"), // 4 TopNav.Profile
198
+ orgPadV: u("padding-xxtight"), // 6
199
+ orgPadL: u("padding-tight"), // 12
200
+ orgPadMobileH: u("padding-xxxxtight"),// 2
201
+ chevronGlyph: 12,
202
+ modChevronBox: 20,
203
+ orgChevronBox: 24,
204
+ actionIconBox: 28, actionIconGlyph: 14,
205
+ // Figma's Action Icon is 44, not the bar's 48. It is the one control on the
206
+ // bar whose touch target is the AA minimum rather than the nav's own height.
207
+ actionTarget: u("accessibility-touch-target-aa-width"),
208
+ // The search takeover's back arrow. AA touch target (44) rather than the
209
+ // nav's own 48, because it sits inside the bar's padding and 48 would crowd
210
+ // the field; the glyph is sized independently and centred in the target.
211
+ takeoverExit: u("accessibility-touch-target-aa-width"),
212
+ takeoverExitGlyph: 24,
213
+ radius: u("cornerradius-base"),
214
+ radiusSm: u("cornerradius-small"),
215
+ };
216
+
217
+ // ─── ABBREVIATION UTILITIES ────────────────────────────────────────────────────
218
+ const SKIP_WORDS = new Set(["the","a","an","of","in","at","for","and","or","but"]);
219
+
220
+ export function abbreviateOrg(name) {
221
+ if (!name) return "???";
222
+ const sig = name.replace(/-/g," ").split(/\s+/).filter(w => !SKIP_WORDS.has(w.toLowerCase()));
223
+ if (sig.length >= 3) return (sig[0][0]+sig[1][0]+sig[2][0]).toUpperCase();
224
+ if (sig.length === 2) return (sig[0][0]+sig[1][0]+sig[1][0]).toUpperCase();
225
+ return sig[0].slice(0,3).toUpperCase();
226
+ }
227
+
228
+ const PLACE_SUFFIXES = [
229
+ "ville","field","burg","burgh","berg","town","port","ford",
230
+ "wood","land","dale","view","gate","bridge","worth","shire",
231
+ ];
232
+
233
+ export function abbreviateCampus(campus) {
234
+ if (!campus) return "";
235
+ const sig = campus.replace(/-/g," ").split(/\s+/).filter(w => !SKIP_WORDS.has(w.toLowerCase()));
236
+ if (!sig.length) return campus.slice(0,2).toUpperCase();
237
+ if (sig.length >= 2) return (sig[0][0]+sig[1][0]).toUpperCase();
238
+ const w = sig[0], wl = w.toLowerCase();
239
+ for (const sfx of PLACE_SUFFIXES) {
240
+ if (wl.endsWith(sfx) && w.length > sfx.length+1) return (w[0]+sfx[0]).toUpperCase();
241
+ }
242
+ return (w[0]+w[w.length-1]).toUpperCase();
243
+ }
244
+
245
+ export function mobileLabel(orgName, campusName) {
246
+ const org = abbreviateOrg(orgName);
247
+ return campusName ? `${org} | ${abbreviateCampus(campusName)}` : org;
248
+ }
249
+
250
+ // ─── FIGMA CUSTOM SVG ICONS ────────────────────────────────────────────────────
251
+ // These are custom Figma-exported SVG paths. Do NOT replace with Material Symbols.
252
+ // Source: get_design_context on TopNav.Global desktop/tablet/mobile + no-logo variant.
253
+
254
+ // Module=Home, Style=Flat, Color=White, node I40007067:5241;40006803:50605;40006853:34204
255
+ /**
256
+ * @deprecated Use `<ModuleIcon module="home" />`.
257
+ *
258
+ * Was a hand-drawn 44x44 path traced from Module=Home, Style=Flat, Color=White,
259
+ * carried here because the repo had no module artwork. Module.Icon now ships all
260
+ * twelve marks from the Figma component set, so keeping a second copy of one of
261
+ * them would be keeping a copy that drifts. Delegates rather than being deleted
262
+ * outright, because it is an exported name.
263
+ */
264
+ export function HomeModuleIcon({ size = 22 }) {
265
+ return <ModuleIcon module="home" size={size} color="mono" />;
266
+ }
267
+
268
+ /**
269
+ * Org avatar placeholder shown when an org has no logo on file.
270
+ * Figma: church icon, node 40007243:73426, positioned at inset 4.17% 8.33% 8.33% 8.33%
271
+ * within the 16px inner avatar frame.
272
+ */
273
+ export function OrgAvatarPlaceholder() {
274
+ return (
275
+ <div style={{ position: "absolute", inset: "4.17% 8.33% 8.33% 8.33%" }}>
276
+ <svg
277
+ viewBox="0 0 13.3333 14" width="100%" height="100%"
278
+ fill="none" xmlns="http://www.w3.org/2000/svg"
279
+ aria-hidden="true" style={{ display: "block" }}
280
+ >
281
+ <path d={CHURCH_ICON_PATH} fill="white" fillOpacity="0.7" />
282
+ </svg>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ // ─── MATERIAL SYMBOL HELPER ────────────────────────────────────────────────────
288
+ // Used for all icons EXCEPT the custom Figma SVGs above.
289
+ /* Icon is imported, not redeclared, and that fixes a real defect rather than
290
+ just removing duplication.
291
+ The copy that used to live here hardcoded `'opsz' 20` no matter what `size`
292
+ was. This file draws glyphs at 11, 12, 14 and 22, so every one of them
293
+ except the 20s rendered at the wrong optical size: a 12px chevron at opsz 20
294
+ draws visibly too light, which is exactly the defect that shipped in the
295
+ collapsed SideNav rail and was fixed there months ago. The shared component
296
+ tracks opsz to the frame size. */
297
+
298
+ // ─── MODULE DATA ───────────────────────────────────────────────────────────────
299
+ /**
300
+ * The modules, in Figma's Module.Icon variant order.
301
+ *
302
+ * This list used to name five Material Symbols (home, group, volunteer_activism,
303
+ * event, mail) for five entries, one of which ("events") is not an Amplify
304
+ * module at all. So the switcher showed generic glyphs where the design has
305
+ * bespoke marks, and seven modules were missing entirely.
306
+ *
307
+ * There is no `icon` field any more. The mark is the module: ModuleIcon takes
308
+ * the id directly, so there is nothing to keep in step.
309
+ */
310
+ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[id] }));
311
+
312
+ // ─── 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);
317
+
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") {
344
+ return (
345
+ <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
+ ))}
367
+ </div>
368
+ );
369
+ }
370
+
371
+ 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>
385
+ </div>
386
+ );
387
+ }
388
+
389
+ // ─── TopNavProfile ─────────────────────────────────────────────────────────────
390
+ export function TopNavProfile({ user, open, onToggle, mobile = false }) {
391
+ const [hov, setHov] = useState(false);
392
+ return (
393
+ <div style={{ position: "relative", display: "flex", alignItems: "center",
394
+ justifyContent: "center", minHeight: L.touchTarget, minWidth: L.touchTarget, padding: "var(--semantic-layout-units-padding-xxxxtight)"}}>
395
+ <button
396
+ onClick={onToggle}
397
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
398
+ aria-haspopup="true" aria-expanded={open}
399
+ aria-label={`Account: ${user.name}`}
400
+ style={{
401
+ display: "flex", alignItems: "center", justifyContent: "center",
402
+ // Figma TopNav.Profile is 48x48 with pad 4,4,4,4. This was 44 with 6.
403
+ width: L.touchTarget, height: L.touchTarget, boxSizing: "border-box",
404
+ background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
405
+ border: "none", borderRadius: "50%", cursor: "pointer", padding: L.profilePad,
406
+ transition: "background var(--motion-duration-2) var(--motion-easing-standard)",
407
+ }}
408
+ >
409
+ <div style={{
410
+ width: L.avatarSize, height: L.avatarSize, borderRadius: "50%",
411
+ 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",
416
+ }}>
417
+ {user.avatarUrl
418
+ ? <img src={user.avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
419
+ : user.initials
420
+ }
421
+ </div>
422
+ </button>
423
+ </div>
424
+ );
425
+ }
426
+
427
+ // ─── OrgSwitcher ──────────────────────────────────────────────────────────────
428
+ export function OrgSwitcher({ org, open, onToggle, mobile = false }) {
429
+ const [hov, setHov] = useState(false);
430
+ const [imgFailed, setImgFailed] = useState(false);
431
+
432
+ const hasLogo = org.logoUrl && !imgFailed;
433
+
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)"};
450
+
451
+ return (
452
+ <div style={{ position: "relative", padding: mobile ? "4px 2px" : 4,
453
+ maxWidth: mobile ? L.mobOrgMax : L.deskOrgMax }}>
454
+ <button
455
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
456
+ onClick={onToggle}
457
+ aria-haspopup="true" aria-expanded={open}
458
+ aria-label={`Switch organisation: ${org.name}${org.campus ? ", "+org.campus : ""}`}
459
+ 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}`,
468
+ borderRadius: L.radius,
469
+ // Rest is bare: stroke only, no fill. See the Ghost note in T.
470
+ 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)",
474
+ }}
475
+ >
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. */}
479
+ <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 */}
482
+ {hasLogo && (
483
+ <div style={{ width: L.orgAvatarSm, height: L.orgAvatarSm, padding: "var(--semantic-layout-units-padding-xxxxtight)",
484
+ display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
485
+ <div style={{
486
+ width: L.orgAvatarNav, height: L.orgAvatarNav, borderRadius: L.radiusSm,
487
+ border: `1px solid ${T.orgStroke}`,
488
+ background: "transparent",
489
+ display: "flex", alignItems: "center", justifyContent: "center",
490
+ overflow: "hidden", position: "relative", flexShrink: 0,
491
+ }}>
492
+ <img
493
+ src={org.logoUrl} alt=""
494
+ onError={() => setImgFailed(true)}
495
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
496
+ />
497
+ </div>
498
+ </div>
499
+ )}
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}
527
+ </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
+ )}
534
+ </div>
535
+ {/* Chevron */}
536
+ <div style={{
537
+ display: "flex", alignItems: "center", justifyContent: "center",
538
+ width: 16, height: 16, marginRight: 2,
539
+ 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>
551
+ </button>
552
+ </div>
553
+ );
554
+ }
555
+
556
+ // ─── ModuleSwitcher ────────────────────────────────────────────────────────────
557
+ /**
558
+ * ModuleSwitcher (app switcher).
559
+ *
560
+ * type, "interactive" (default) | "static" (Figma variant property "Type")
561
+ * "interactive": switch-module control, chevron + hover + pressed + button
562
+ * semantics + aria-haspopup. Used in every module.
563
+ * "static": non-interactive current-module label: NO chevron, NO hover,
564
+ * NO pressed, NOT a button. Used on the Amplify Dashboard, where the module
565
+ * switcher only indicates the current location (there is nowhere to switch
566
+ * to from the dashboard).
567
+ * See top-nav-spec.md §ModuleSwitcher properties + usage-by-context.
568
+ */
569
+ export function ModuleSwitcher({ modules, activeId, open, onToggle, breakpoint = "desktop", type = "interactive" }) {
570
+ const [hov, setHov] = useState(false);
571
+ const active = modules.find(m => m.id === activeId) || modules[0];
572
+ const showLabel = breakpoint === "desktop";
573
+ const isStatic = type === "static";
574
+
575
+ // Icon + label: identical in both variants.
576
+ const inner = (
577
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxtight)",
578
+ paddingRight: showLabel && !isStatic ? 2 : 0 }}>
579
+ {/* The module mark. Mono on the bar: the chrome is brand blue, so the
580
+ mark inherits currentColor and reads as near-white, which is what the
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",
583
+ justifyContent: "center", flexShrink: 0, color: T.monoBase }}>
584
+ <ModuleIcon module={active.id} size={24} color="mono" />
585
+ </div>
586
+ {/* Label, desktop only */}
587
+ {showLabel && (
588
+ <span style={{ fontSize: "var(--semantic-type-font-size-s)", fontWeight: 500, lineHeight: "var(--semantic-type-line-height-s-single)",
589
+ letterSpacing: "var(--semantic-type-letter-spacing-spacious)", whiteSpace: "nowrap", overflow: "hidden",
590
+ textOverflow: "ellipsis", maxWidth: 160, color: T.monoBase }}>
591
+ {active.label}
592
+ </span>
593
+ )}
594
+ </div>
595
+ );
596
+
597
+ // STATIC, Amplify Dashboard: current-module label, not a control.
598
+ // No chevron, no hover/pressed, no button role/affordance.
599
+ if (isStatic) {
600
+ return (
601
+ <div style={{ position: "relative", padding: "4px 2px" }}>
602
+ <div
603
+ aria-label={`Current module: ${active.label}`}
604
+ style={{
605
+ display: "flex", alignItems: "center",
606
+ maxHeight: L.modInnerH, minHeight: L.modInnerH,
607
+ // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
608
+ // This was a uniform 4 on all four sides.
609
+ padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
610
+ background: "transparent", border: "1px solid transparent",
611
+ color: T.monoBase, fontFamily: "inherit", cursor: "default",
612
+ }}
613
+ >
614
+ {inner}
615
+ </div>
616
+ </div>
617
+ );
618
+ }
619
+
620
+ // INTERACTIVE (default), switch-module control.
621
+ return (
622
+ <div style={{ position: "relative", padding: "4px 2px" }}>
623
+ <button
624
+ onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
625
+ onClick={onToggle}
626
+ aria-haspopup="listbox" aria-expanded={open}
627
+ aria-label={`Switch module: ${active.label}`}
628
+ style={{
629
+ display: "flex", alignItems: "center",
630
+ maxHeight: L.modInnerH, minHeight: L.modInnerH,
631
+ // Figma ModuleSwitcher Container.Main is pad 0,0,0,6: left only.
632
+ // This was a uniform 4 on all four sides.
633
+ padding: `0 0 0 ${L.modPadL}`, borderRadius: L.radius,
634
+ background: open ? T.controlPressed : hov ? T.controlHover : "transparent",
635
+ // No stroke in any state, Figma's ModuleSwitcher has none, unlike the
636
+ // org switcher next to it. Transparent rather than none so the two
637
+ // controls keep the same box height.
638
+ border: "1px solid transparent",
639
+ cursor: "pointer", color: T.monoBase, fontFamily: "inherit",
640
+ transition: "background var(--motion-duration-3) var(--motion-easing-standard), border-color var(--motion-duration-3) var(--motion-easing-standard)",
641
+ }}
642
+ >
643
+ {inner}
644
+ {/* Chevron. Figma ModuleSwitcher Container.RowEnd is a 20x20 box holding a
645
+ 12x12 expand_more instance, read off node 40007067:6508 on
646
+ 2026-10-02. The glyph was right at 12; the box said 16. */}
647
+ <div style={{
648
+ width: L.modChevronBox, height: L.modChevronBox, display: "flex", alignItems: "center",
649
+ justifyContent: "center", marginLeft: 2,
650
+ transform: open ? "rotate(180deg)" : "none",
651
+ transition: "transform var(--motion-duration-4) var(--motion-easing-standard)",
652
+ }}>
653
+ <Icon name="expand_more" size={12} style={{ color: T.monoBase }} />
654
+ </div>
655
+ </button>
656
+ </div>
657
+ );
658
+ }
659
+
660
+ // ─── TopNav ────────────────────────────────────────────────────────────────────
661
+ /**
662
+ * TopNav.Global component.
663
+ *
664
+ * Props:
665
+ * modules, Array<{id, label, icon}> (default: DEFAULT_MODULES)
666
+ * activeModuleId, string
667
+ * org, { id, name, campus?, initials, logoUrl?, bg? }
668
+ * logoUrl absent or undefined → nav trigger shows org name only
669
+ * (no avatar/placeholder). logoUrl, when provided, renders the logo
670
+ * in the nav trigger AND in the org-panel dropdown.
671
+ * user, { name, initials, email, avatarUrl? }
672
+ * breakpoint, "desktop" | "tablet" | "mobile"
673
+ * onModuleSelect, (id: string) => void
674
+ * onOrgSelect, () => void
675
+ * onSearchOpen, () => void
676
+ * onSideNavToggle, () => void (mobile only)
677
+ * onNotifications, () => void
678
+ * onMore, () => void
679
+ * className, string
680
+ */
681
+ export function TopNav({
682
+ modules = DEFAULT_MODULES,
683
+ activeModuleId = "home",
684
+ org = { id: "shc", name: "Sacred Heart Church-ITD", campus: "Knoxville", initials: "SH" },
685
+ orgs = DEMO_ORGS, // list shown in the OrgSwitcher "Open" dropdown panel
686
+ user = { name: "Jo Lopez", initials: "JL", email: "jo@sacredheart.org" },
687
+ breakpoint = "desktop",
688
+ moduleSwitcherType = "interactive", // "interactive" | "static" (Amplify Dashboard), Figma "Type"
689
+ initialOpenPanel = null, // "module" | "org" | "profile" | null, seeds the open panel (demo/snapshot use)
690
+ onModuleSelect,
691
+ onOrgSelect,
692
+ onSearchOpen,
693
+ onSideNavToggle,
694
+ onNotifications,
695
+ onMore,
696
+ className = "",
697
+ }) {
698
+ const [openPanel, setOpenPanel] = useState(initialOpenPanel); // "module"|"org"|"profile"|null
699
+ const [currentModuleId, setModuleId] = useState(activeModuleId);
700
+ const [searchExpanded, setSearchExpanded] = useState(false);
701
+ const [searchQuery, setSearchQuery] = useState(""); // shared so the value persists across the collapsed icon ↔ takeover
702
+ const navRef = useRef(null);
703
+
704
+ const isMobile = breakpoint === "mobile";
705
+ const isTablet = breakpoint === "tablet";
706
+
707
+ const toggle = (p) => setOpenPanel(x => x === p ? null : p);
708
+ const close = () => setOpenPanel(null);
709
+
710
+ useEffect(() => {
711
+ const h = (e) => { if (!navRef.current?.contains(e.target)) close(); };
712
+ document.addEventListener("mousedown", h);
713
+ return () => document.removeEventListener("mousedown", h);
714
+ }, []);
715
+
716
+ useEffect(() => {
717
+ const h = (e) => { if (e.key === "Escape") { close(); setSearchExpanded(false); } };
718
+ document.addEventListener("keydown", h);
719
+ return () => document.removeEventListener("keydown", h);
720
+ }, []);
721
+
722
+ const handleModuleSelect = (id) => {
723
+ setModuleId(id);
724
+ close();
725
+ onModuleSelect?.(id);
726
+ };
727
+
728
+ // Search always takes over the whole bar when open (all breakpoints), until
729
+ // it is closed. There is no inline-expand state, opening search overlays the
730
+ // entire TopNav so it can never collide with the OrgSwitcher or other controls.
731
+ const useTakeover = searchExpanded;
732
+
733
+ return (
734
+ // TWO ELEMENTS, AND THE SPLIT IS THE POINT.
735
+ //
736
+ // The bar is brand-coloured, not dark-themed. Those are different things and
737
+ // conflating them was a real bug: data-theme="midnight" used to sit on the
738
+ // <nav> itself, and a [data-theme] applies to the element it is on as well as
739
+ // its descendants, so the bar re-resolved its OWN background through Midnight
740
+ // and painted Fill/Surface/Chrome as #152343. In Figma the bar is #2d4889 and
741
+ // carries no mode override at all, so on a Light page the repo was showing a
742
+ // colour the design does not contain.
743
+ //
744
+ // So the background is read OUT here, in the page's own theme, and the dark
745
+ // region starts INSIDE. The nav keeps position:relative, so the absolutely
746
+ // positioned dropdown panels still anchor to it and nothing about layout
747
+ // moves.
748
+ <div
749
+ style={{
750
+ background: "var(--semantic-color-fill-surface-chrome)",
751
+ position: "relative", zIndex: 100,
752
+ }}
753
+ >
754
+ <nav
755
+ ref={navRef}
756
+ // NO data-theme HERE, AND THAT IS THE FIX FOR THE WHOLE BAR.
757
+ //
758
+ // This carried data-theme="midnight", on the reasoning that a dark bar is
759
+ // a dark region. It is not. Figma renders this bar in LIGHT, and the bar
760
+ // is dark because Fill/Surface/Chrome is itself a dark navy in Light, not
761
+ // because the region is inverted. Measured off node 40007067:6508 on
762
+ // 2026-10-02, reading the resolved paints as the file renders them:
763
+ //
764
+ // bar Container.Main #2d4889 Fill/Surface/Chrome, LIGHT
765
+ // avatar Avatar #eef2fb Fill/Action/Primary/Subtle/Rest, LIGHT
766
+ // initials "JL" #345499 Foreground/Action/Primary/On Subtle/Rest, LIGHT
767
+ //
768
+ // Forcing midnight made every token on the bar resolve to its Midnight
769
+ // value while the bar's own background, which sits outside this element,
770
+ // resolved Light. So the avatar came out #22386b instead of #eef2fb, and
771
+ // the initials with it. It stayed hidden on the Ghost tokens only because
772
+ // those are near-white in both modes, which is also why fixing the tier
773
+ // earlier today did not fix the colours: the tier was right and the mode
774
+ // was wrong.
775
+ //
776
+ // The dropdown panels keep their own data-theme="light" and are unaffected,
777
+ // since light is now the ambient mode rather than an opt-out.
778
+ aria-label="Global navigation"
779
+ className={className}
780
+ style={{
781
+ background: "transparent",
782
+ display: "flex", alignItems: "center", justifyContent: "space-between",
783
+ // Figma TopNav.Global is 60 TALL, full stop. maxHeight alone let the
784
+ // vertical padding add to the box, so the bar rendered 64. Measured
785
+ // 2026-10-02. height + border-box makes 60 mean 60.
786
+ height: L.navH, maxHeight: L.navH, boxSizing: "border-box",
787
+ padding: `${L.navPadV} ${L.navPadH}`, gap: L.navGap,
788
+ position: "relative", overflow: "visible",
789
+ fontFamily: "'Red Hat Text', sans-serif",
790
+ }}
791
+ >
792
+ {/* ── Slot.RowStart ── */}
793
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
794
+
795
+ {/* SideNav.Control, mobile only. Figma set 40007082:7703: three State
796
+ variants wrapping an `Action Icon` at Size=Large, so a 48x48 target,
797
+ a 32 state layer and a 16 glyph. This was a hand-rolled button with
798
+ a 22px glyph and no state layer at all, which is 6px too big with
799
+ nothing to hover. It now uses the shared ActionIcon, so the three
800
+ states come from the one component the design nests. */}
801
+ {isMobile && (
802
+ <ActionIcon
803
+ name="menu"
804
+ size="Large"
805
+ label="Open navigation menu"
806
+ color={T.monoBase}
807
+ onClick={onSideNavToggle}
808
+ />
809
+ )}
810
+
811
+ {/* ModuleSwitcher */}
812
+ <ModuleSwitcher
813
+ modules={modules}
814
+ activeId={currentModuleId}
815
+ open={openPanel === "module"}
816
+ onToggle={() => toggle("module")}
817
+ breakpoint={breakpoint}
818
+ type={moduleSwitcherType}
819
+ />
820
+
821
+ {/* Module dropdown: never opens in static mode (non-interactive) */}
822
+ {openPanel === "module" && moduleSwitcherType !== "static" && (
823
+ <ul role="listbox" data-theme="light" aria-label="Switch module"
824
+ style={{
825
+ position: "absolute", top: "calc(100% + 4px)", left: L.navPadH,
826
+ width: 243, background: T.panelBg,
827
+ border: `1px solid ${T.panelBorder}`, borderRadius: L.radius,
828
+ boxShadow: T.panelShadow, padding: "var(--semantic-layout-units-padding-xxxtight)", zIndex: 300,
829
+ margin: 0, listStyle: "none",
830
+ animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
831
+ }}
832
+ >
833
+ {modules.map(m => (
834
+ <li key={m.id} role="option" aria-selected={m.id === currentModuleId}>
835
+ <button
836
+ onClick={() => handleModuleSelect(m.id)}
837
+ style={{
838
+ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-medium)",
839
+ padding: "", borderRadius: "var(--semantic-layout-units-cornerradius-base)", width: "100%", textAlign: "left",
840
+ color: m.id === currentModuleId ? T.itemText : T.itemTextBase,
841
+ fontSize: "var(--semantic-type-font-size-s)", fontWeight: m.id === currentModuleId ? 500 : 400,
842
+ background: m.id === currentModuleId ? T.activeItem : "transparent",
843
+ border: "none", fontFamily: "inherit", cursor: "pointer",
844
+ }}
845
+ >
846
+ {/* Full colour here: the panel is a light surface and the
847
+ identity colour is what makes a module recognisable. */}
848
+ <ModuleIcon module={m.id} size={18} />
849
+ {m.label}
850
+ </button>
851
+ </li>
852
+ ))}
853
+ </ul>
854
+ )}
855
+
856
+ {/* OrgSwitcher */}
857
+ <OrgSwitcher
858
+ org={org}
859
+ open={openPanel === "org"}
860
+ onToggle={() => { toggle("org"); onOrgSelect?.(); }}
861
+ mobile={isMobile}
862
+ />
863
+
864
+ {/* Org panel: the real OrgSwitcher "Open" dropdown (Figma 40007336:9453) */}
865
+ {openPanel === "org" && (
866
+ <div style={{
867
+ position: "absolute", top: "calc(100% + 4px)",
868
+ left: isMobile ? L.navPadH : `calc(${L.navPadH} + 40px)`, zIndex: 300,
869
+ animation: "tnDropIn var(--motion-duration-4) var(--motion-easing-spring) both",
870
+ }}>
871
+ <OrgSwitcherPanel
872
+ orgs={orgs}
873
+ activeOrgId={org.id}
874
+ onSelect={(o) => { close(); onOrgSelect?.(o); }}
875
+ />
876
+ </div>
877
+ )}
878
+ </div>
879
+
880
+ {/* ── Slot.RowEnd ── */}
881
+ <div style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)", flexShrink: 0 }}>
882
+
883
+ {/* Collapsed search pill. Opening it triggers the full-bar takeover
884
+ overlay below (never an inline expand), so it cannot overlap the
885
+ OrgSwitcher. */}
886
+ <TopNavSearch
887
+ expanded={false}
888
+ onExpandChange={setSearchExpanded}
889
+ breakpoint={breakpoint}
890
+ onSearchOpen={onSearchOpen}
891
+ searchProps={{ value: searchQuery, onChange: setSearchQuery, onClear: () => setSearchQuery("") }}
892
+ />
893
+
894
+ <TopNavActions breakpoint={breakpoint} onNotifications={onNotifications} onMore={onMore} />
895
+
896
+ <TopNavProfile
897
+ user={user}
898
+ open={openPanel === "profile"}
899
+ onToggle={() => toggle("profile")}
900
+ mobile={isMobile}
901
+ />
902
+
903
+ {/* 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
+ )}
937
+ </div>
938
+
939
+ {/* Full-width search takeover, shown whenever search is open, on every
940
+ breakpoint. Fills the entire bar (absolute inset:0) so it can never
941
+ collide with the OrgSwitcher.
942
+ Exit is a BACK ARROW to the LEFT of the field, outside it. It used to
943
+ be a ✕ to the right, which sat beside the field's own ✕ for clearing
944
+ typed text: two glyphs of the same shape, inches apart, one throwing
945
+ the query away and one throwing the whole search away. The arrow is a
946
+ different shape, on the opposite side, and says "go back" rather than
947
+ "close". The field's leading glyph is now a plain search mark with no
948
+ action, because giving it a second exit was the same confusion again.
949
+ Escape still closes. */}
950
+ {useTakeover && (
951
+ <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"
959
+ style={{
960
+ position: "absolute", inset: 0, zIndex: 200,
961
+ background: "var(--semantic-color-fill-surface-chrome)",
962
+ animation: "tnSearchExpand var(--motion-duration-4) var(--motion-easing-emphasized) both",
963
+ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-tight)",
964
+ padding: `0 ${L.navPadH}`,
965
+ }}
966
+ >
967
+ {/* Exit, to the LEFT and outside the field, so it can never be read
968
+ as the field's own clear control */}
969
+ <button
970
+ type="button"
971
+ aria-label="Back"
972
+ onClick={() => setSearchExpanded(false)}
973
+ style={{
974
+ display: "flex", alignItems: "center", justifyContent: "center",
975
+ width: L.takeoverExit, height: L.takeoverExit, flexShrink: 0,
976
+ background: "transparent", border: "none", cursor: "pointer",
977
+ borderRadius: L.radius, color: T.monoBase,
978
+ }}
979
+ >
980
+ <Icon name="arrow_back" size={L.takeoverExitGlyph} style={{ color: T.monoBase }} />
981
+ </button>
982
+ <div style={{ flex: 1, minWidth: 0 }}>
983
+ <SearchInput
984
+ value={searchQuery}
985
+ onChange={setSearchQuery}
986
+ onClear={() => setSearchQuery("")}
987
+ inputRef={(el) => el && el.focus()}
988
+ placeholder="Search…"
989
+ />
990
+ </div>
991
+ </div>
992
+ )}
993
+ </nav>
994
+ </div>
995
+ );
996
+ }
997
+
998
+ // Canonical compound name: TopNav.Search (the nested search control). The bare
999
+ // `TopNavSearch` export is kept for backward-compatible named imports.
1000
+ TopNav.Search = TopNavSearch;
1001
+ export { TopNavSearch };
1002
+
1003
+ export default TopNav;