@exxatdesignux/ui 0.2.15 → 0.2.17

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  10. package/package.json +3 -3
  11. package/src/components/ui/banner.tsx +2 -0
  12. package/src/components/ui/chart.tsx +57 -2
  13. package/src/components/ui/sidebar.tsx +1 -0
  14. package/src/globals.css +21 -2
  15. package/src/theme.css +4 -2
  16. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  17. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  18. package/template/AGENTS.md +23 -18
  19. package/template/app/(app)/data-list/page.tsx +2 -2
  20. package/template/app/(app)/question-bank/layout.tsx +27 -7
  21. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  22. package/template/app/globals.css +136 -2
  23. package/template/app/layout.tsx +41 -5
  24. package/template/components/app-sidebar.tsx +141 -59
  25. package/template/components/ask-leo-sidebar.tsx +1 -4
  26. package/template/components/brand-color-picker.tsx +344 -0
  27. package/template/components/compliance-list-view.tsx +33 -51
  28. package/template/components/compliance-table.tsx +24 -0
  29. package/template/components/data-table/index.tsx +68 -24
  30. package/template/components/data-table/pagination.tsx +0 -1
  31. package/template/components/data-table/types.ts +4 -1
  32. package/template/components/data-table/use-table-state.ts +243 -94
  33. package/template/components/data-views/data-row-list.tsx +183 -0
  34. package/template/components/data-views/finder-panel-view.tsx +2 -2
  35. package/template/components/data-views/index.ts +26 -3
  36. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  37. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  38. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  39. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  40. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  41. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  42. package/template/components/export-drawer.tsx +1 -1
  43. package/template/components/exxat-product-logo.tsx +173 -379
  44. package/template/components/folder-details-shell.tsx +1 -1
  45. package/template/components/hub-tree-panel-view.tsx +88 -80
  46. package/template/components/invite-collaborators-drawer.tsx +5 -3
  47. package/template/components/key-metrics.tsx +116 -51
  48. package/template/components/new-placement-form.tsx +4 -2
  49. package/template/components/new-question-composer.tsx +2208 -0
  50. package/template/components/page-breadcrumb-trail.tsx +131 -0
  51. package/template/components/page-header.tsx +21 -11
  52. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  53. package/template/components/placement-detail.tsx +1 -1
  54. package/template/components/placements-board-view.tsx +1 -1
  55. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  56. package/template/components/placements-list-view.tsx +18 -132
  57. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  58. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  59. package/template/components/placements-table-columns.tsx +2 -2
  60. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  61. package/template/components/product-switcher.tsx +26 -11
  62. package/template/components/product-wordmark.tsx +285 -0
  63. package/template/components/question-bank-client.tsx +130 -70
  64. package/template/components/question-bank-hub-client.tsx +108 -115
  65. package/template/components/question-bank-list-view.tsx +30 -54
  66. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  67. package/template/components/question-bank-page-header.tsx +18 -2
  68. package/template/components/question-bank-secondary-nav.tsx +12 -228
  69. package/template/components/question-bank-table.tsx +30 -5
  70. package/template/components/rotations-empty-state.tsx +3 -0
  71. package/template/components/secondary-panel.tsx +24 -4
  72. package/template/components/settings-appearance-card.tsx +584 -141
  73. package/template/components/site-header.tsx +56 -32
  74. package/template/components/sites-list-view.tsx +31 -36
  75. package/template/components/sites-table.tsx +24 -0
  76. package/template/components/table-properties/drawer.tsx +1 -1
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +29 -3
  80. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  81. package/template/components/templates/list-page.tsx +1 -3
  82. package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
  83. package/template/components/ui/dot-pattern.tsx +50 -26
  84. package/template/components/ui/leo-icon.tsx +23 -3
  85. package/template/contexts/product-context.tsx +51 -7
  86. package/template/contexts/system-banner-context.tsx +112 -4
  87. package/template/docs/collaboration-access-pattern.md +2 -0
  88. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  89. package/template/eslint.config.mjs +18 -0
  90. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/data-list-persistence.ts +57 -257
  93. package/template/lib/dev-log.test.ts +6 -5
  94. package/template/lib/exxat-palette.json +1462 -0
  95. package/template/lib/exxat-palette.ts +136 -0
  96. package/template/lib/list-page-table-properties.ts +1 -1
  97. package/template/lib/list-status-badges.ts +1 -1
  98. package/template/lib/mailto.ts +29 -0
  99. package/template/lib/mock/navigation.tsx +30 -1
  100. package/template/lib/placement-board-card-layout.ts +1 -1
  101. package/template/lib/product-brand.ts +268 -0
  102. package/template/lib/question-bank-authoring.ts +308 -0
  103. package/template/lib/question-bank-nav.ts +70 -0
  104. package/template/lib/raf-throttle.ts +45 -0
  105. package/template/lib/table-state-lifecycle.ts +474 -0
  106. package/template/next.config.mjs +156 -0
  107. package/template/package.json +6 -6
  108. package/template/stores/app-store.ts +46 -1
  109. package/template/components/command-menu-01.tsx +0 -133
  110. package/template/components/command-menu-02.tsx +0 -386
@@ -52,6 +52,7 @@ import {
52
52
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
53
53
  import { Badge } from "@/components/ui/badge"
54
54
  import { StatusBadge } from "@/components/ui/status-badge"
55
+ import { Separator } from "@/components/ui/separator"
55
56
  import {
56
57
  Tooltip,
57
58
  TooltipContent,
@@ -70,6 +71,7 @@ import { NavUser } from "@/components/nav-user"
70
71
  import { useSecondaryPanel } from "@/components/secondary-panel"
71
72
  import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo"
72
73
  import { motionHeaderEnter } from "@/lib/motion-ui"
74
+ import { customProductBrandConfig, productBrandLabel } from "@/lib/product-brand"
73
75
  import {
74
76
  NAV_DOCUMENTS,
75
77
  NAV_DOCUMENTS_LABEL,
@@ -85,8 +87,6 @@ import {
85
87
  type NavSchool,
86
88
  type NavProgram,
87
89
  } from "@/lib/mock/navigation"
88
- import { QUESTION_BANK_ENTRY_PATH } from "@/lib/question-bank-nav"
89
-
90
90
  /** Path segment of a nav URL (strip `#fragment` for matching). */
91
91
  function navUrlPath(url: string): string {
92
92
  if (!url || url === "#") return ""
@@ -108,7 +108,8 @@ function normalizedLocationHash(locationHash: string): string {
108
108
  /**
109
109
  * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
110
110
  * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
111
- * and require an empty hash for the “default” row (`/settings` with no `#`).
111
+ * in each `href` those rows use the `frag !== null` branch below.
112
+ * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match.
112
113
  */
113
114
  function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
114
115
  const pathOnly = navUrlPath(url)
@@ -129,7 +130,8 @@ function isNavActive(pathname: string, url: string, locationHash = ""): boolean
129
130
  }
130
131
 
131
132
  if (pathOnly === "/") return pathname === "/" && h === ""
132
- if (pathname === pathOnly) return h === ""
133
+ /** Exact path match — ignore `location.hash` when the nav `href` has no `#…` fragment (QB view tabs use hash). */
134
+ if (pathname === pathOnly) return true
133
135
  // Design system library — active on hub and detail routes.
134
136
  if (pathOnly === "/library") {
135
137
  return pathname.startsWith("/library/")
@@ -165,6 +167,16 @@ function isCollapsibleChildActive(
165
167
 
166
168
  if (!isNavActive(pathname, child.url, locationHash)) return false
167
169
 
170
+ /** Hub entry (`/question-bank`) must not stay “active” on `/question-bank/library` etc. */
171
+ if (parent.primaryHubChildKey && child.key === parent.primaryHubChildKey) {
172
+ const hubPath = navUrlPath(parent.url)
173
+ if (hubPath) {
174
+ const normalized =
175
+ pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
176
+ if (normalized !== hubPath) return false
177
+ }
178
+ }
179
+
168
180
  const urls = children.map(c => c.url)
169
181
  const allSameUrl = urls.length > 1 && urls.every(u => u === urls[0])
170
182
  if (allSameUrl) {
@@ -176,6 +188,33 @@ function isCollapsibleChildActive(
176
188
  return true
177
189
  }
178
190
 
191
+ /**
192
+ * “Selected” styling on a collapsible **parent** row in the **expanded** sidebar.
193
+ *
194
+ * Rule: when any descendant child is the current destination, the parent stays
195
+ * visually NEUTRAL — the active child carries `data-active` on its own. The
196
+ * parent is only highlighted when no child matches but the parent URL still
197
+ * matches (edge case: route that isn't represented in the sub-list).
198
+ *
199
+ * Note: this is for the expanded view only. The collapsed icon rail uses
200
+ * `iconRailActive = isAnyChildActive` because the parent icon is the only
201
+ * visible affordance there (see `CollapsibleNavItem`).
202
+ */
203
+ function isCollapsibleParentMenuButtonActive(
204
+ pathname: string,
205
+ item: NavLinkItem,
206
+ locationHash: string,
207
+ ): boolean {
208
+ const children = item.children
209
+ if (!children?.length) return isNavActive(pathname, item.url, locationHash)
210
+
211
+ const anyChildActive = children.some(c =>
212
+ isCollapsibleChildActive(pathname, item, c, locationHash),
213
+ )
214
+ if (anyChildActive) return false
215
+ return isNavActive(pathname, item.url, locationHash)
216
+ }
217
+
179
218
  /** Accessible suffix for sidebar badges (badge is rendered outside the link node). */
180
219
  function badgeAccessibleSuffix(badge: number | string): string {
181
220
  if (typeof badge === "number") return `${badge} items`
@@ -183,30 +222,40 @@ function badgeAccessibleSuffix(badge: number | string): string {
183
222
  }
184
223
 
185
224
  /** Child row for expandable nav items — shared by inline sub-menu and collapsed-rail popover. */
186
- function SidebarNavChildLink({
187
- parent,
188
- child,
189
- pathname,
190
- locationHash,
191
- onNavigate,
192
- linkClassName,
193
- }: {
194
- parent: NavLinkItem
195
- child: NavLinkItem
196
- pathname: string
197
- locationHash: string
198
- onNavigate?: () => void
199
- /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
200
- linkClassName?: string
201
- }) {
225
+ const SidebarNavChildLink = React.forwardRef<
226
+ HTMLAnchorElement,
227
+ {
228
+ parent: NavLinkItem
229
+ child: NavLinkItem
230
+ pathname: string
231
+ locationHash: string
232
+ onNavigate?: () => void
233
+ /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
234
+ linkClassName?: string
235
+ } & Omit<React.ComponentPropsWithoutRef<typeof Link>, "href">
236
+ >(function SidebarNavChildLink(
237
+ {
238
+ parent,
239
+ child,
240
+ pathname,
241
+ locationHash,
242
+ onNavigate,
243
+ linkClassName,
244
+ className: incomingClassName,
245
+ onClick,
246
+ ...linkRest
247
+ },
248
+ ref,
249
+ ) {
202
250
  const { openPanel } = useSecondaryPanel()
203
251
  const childActive = isCollapsibleChildActive(pathname, parent, child, locationHash)
204
252
  const childPath = navUrlPath(child.url)
205
253
 
206
254
  return (
207
255
  <Link
256
+ ref={ref}
208
257
  href={child.url}
209
- className={cn("flex min-w-0 items-center gap-2", linkClassName)}
258
+ className={cn("flex min-w-0 items-center gap-2", linkClassName, incomingClassName)}
210
259
  aria-current={childActive ? "page" : undefined}
211
260
  onClick={e => {
212
261
  onNavigate?.()
@@ -218,15 +267,18 @@ function SidebarNavChildLink({
218
267
  e.preventDefault()
219
268
  openPanel(parent.secondaryPanel)
220
269
  }
270
+ onClick?.(e)
221
271
  }}
272
+ {...linkRest}
222
273
  >
223
274
  <span className="size-4 shrink-0 inline-flex items-center justify-center" aria-hidden="true">
224
- {child.icon}
275
+ {childActive && child.iconActive ? child.iconActive : child.icon}
225
276
  </span>
226
277
  <span className="min-w-0 flex-1 truncate">{child.title}</span>
227
278
  </Link>
228
279
  )
229
- }
280
+ })
281
+ SidebarNavChildLink.displayName = "SidebarNavChildLink"
230
282
 
231
283
  /**
232
284
  * CollapsibleNavItem — isolated component so each collapsible has its own
@@ -235,19 +287,26 @@ function SidebarNavChildLink({
235
287
  * server (SSR) vs the client (router not yet available).
236
288
  */
237
289
  function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: string }) {
238
- const locationHash = useLocationHash()
239
- const isActive = isNavActive(pathname, item.url, locationHash)
290
+ const locationHash = useLocationHash()
240
291
  const isAnyChildActive =
241
292
  item.children?.some(c => isCollapsibleChildActive(pathname, item, c, locationHash)) ?? false
293
+ const parentMenuButtonActive = isCollapsibleParentMenuButtonActive(pathname, item, locationHash)
242
294
  const { state, isMobile } = useSidebar()
243
- const { openPanel } = useSecondaryPanel()
244
295
  const [open, setOpen] = React.useState(false)
245
296
  const [flyoutOpen, setFlyoutOpen] = React.useState(false)
246
297
  const flyoutTitleId = React.useId()
247
298
  const iconRailCollapsed = state === "collapsed" && !isMobile
248
- const showActiveStyle = isActive || isAnyChildActive
299
+ // In the icon rail the parent icon is the ONLY visible thing for this item
300
+ // (no sub-list, no labels) — so it must reflect "I'm somewhere inside this
301
+ // section" by lighting up on any descendant route (e.g. `/question-bank/library`),
302
+ // not only on the parent URL itself. In the expanded view we keep the
303
+ // parent neutral and let the active child row carry `data-active` (see
304
+ // `isCollapsibleParentMenuButtonActive`).
305
+ const iconRailActive = isAnyChildActive
249
306
  const triggerIcon =
250
- showActiveStyle && item.iconActive ? item.iconActive : item.icon
307
+ (iconRailCollapsed ? iconRailActive : parentMenuButtonActive) && item.iconActive
308
+ ? item.iconActive
309
+ : item.icon
251
310
 
252
311
  React.useEffect(() => {
253
312
  setOpen(isAnyChildActive)
@@ -267,23 +326,22 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
267
326
  open={flyoutOpen}
268
327
  onOpenChange={next => {
269
328
  setFlyoutOpen(next)
270
- if (next && item.secondaryPanel) {
271
- openPanel(item.secondaryPanel)
272
- }
273
329
  }}
274
330
  >
275
331
  <Tooltip>
276
332
  <TooltipTrigger asChild>
277
333
  <PopoverTrigger asChild>
278
334
  <SidebarMenuButton
279
- isActive={showActiveStyle}
335
+ isActive={iconRailActive}
336
+ aria-current={iconRailActive ? "page" : undefined}
280
337
  aria-haspopup="dialog"
281
338
  aria-label={`${item.title} — open subpages`}
282
339
  >
283
340
  <span
341
+ key={iconRailActive ? "active" : "idle"}
284
342
  className={cn(
285
343
  "size-4 shrink-0 flex items-center justify-center",
286
- showActiveStyle &&
344
+ iconRailActive &&
287
345
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
288
346
  )}
289
347
  aria-hidden="true"
@@ -341,22 +399,22 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
341
399
  open={open}
342
400
  onOpenChange={next => {
343
401
  setOpen(next)
344
- if (next && item.secondaryPanel) {
345
- openPanel(item.secondaryPanel)
346
- }
347
402
  }}
348
403
  asChild
349
404
  >
350
- <SidebarMenuItem>
405
+ {/* `group/collapsible` lets descendant utilities react to the
406
+ Radix `data-state` (e.g. chevron rotate, content slide). Radix's
407
+ asChild merges the data-state onto this `<SidebarMenuItem>`. */}
408
+ <SidebarMenuItem className="group/collapsible">
351
409
  <Tooltip>
352
410
  <TooltipTrigger asChild>
353
411
  <CollapsibleTrigger asChild>
354
- <SidebarMenuButton isActive={showActiveStyle}>
412
+ <SidebarMenuButton isActive={parentMenuButtonActive}>
355
413
  <span
356
- key={showActiveStyle ? "active" : "idle"}
414
+ key={parentMenuButtonActive ? "active" : "idle"}
357
415
  className={cn(
358
416
  "size-4 shrink-0 flex items-center justify-center",
359
- showActiveStyle &&
417
+ parentMenuButtonActive &&
360
418
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
361
419
  )}
362
420
  aria-hidden="true"
@@ -365,7 +423,7 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
365
423
  </span>
366
424
  <span>{item.title}</span>
367
425
  <i
368
- className="fa-light fa-chevron-right ml-auto text-xs text-current transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
426
+ className="fa-light fa-chevron-right ml-auto text-xs text-current transition-transform duration-200 ease-out group-data-[state=open]/collapsible:rotate-90 motion-reduce:transition-none"
369
427
  aria-hidden="true"
370
428
  />
371
429
  </SidebarMenuButton>
@@ -375,7 +433,11 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
375
433
  {item.title}
376
434
  </TooltipContent>
377
435
  </Tooltip>
378
- <CollapsibleContent className="group-data-[collapsible=icon]:hidden">
436
+ {/* Slide the children open/closed using Radix's
437
+ `--radix-collapsible-content-height` CSS variable. `overflow-hidden`
438
+ is required so the height clip is visible during the animation.
439
+ Keyframes defined in `app/globals.css` (`collapsible-down/up`). */}
440
+ <CollapsibleContent className="overflow-hidden group-data-[collapsible=icon]:hidden data-[state=open]:[animation:collapsible-down_200ms_ease-out] data-[state=closed]:[animation:collapsible-up_200ms_ease-out] motion-reduce:animate-none">
379
441
  <SidebarMenuSub>
380
442
  {item.children.map(child => {
381
443
  const childActive = isCollapsibleChildActive(pathname, item, child, locationHash)
@@ -400,7 +462,7 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
400
462
  }
401
463
 
402
464
  function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: string }) {
403
- const { openPanel, closePanel } = useSecondaryPanel()
465
+ const { openPanel } = useSecondaryPanel()
404
466
  const locationHash = useLocationHash()
405
467
  return (
406
468
  <>
@@ -426,6 +488,12 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
426
488
  : undefined
427
489
  }
428
490
  onClick={e => {
491
+ // Reopen the panel when the user clicks a panel-driving row
492
+ // while ALREADY on its route — Next.js `<Link>` does not
493
+ // navigate to the same URL, so without this the panel could
494
+ // stay closed (e.g. after the user collapsed it manually).
495
+ // On first click (different route), default navigation runs
496
+ // and the route's `useAutoPanel` opens the panel itself.
429
497
  if (
430
498
  item.secondaryPanel &&
431
499
  itemPath &&
@@ -433,11 +501,7 @@ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: str
433
501
  !item.url.includes("#")
434
502
  ) {
435
503
  e.preventDefault()
436
- if (itemPath === QUESTION_BANK_ENTRY_PATH) {
437
- closePanel({ mainSidebar: "leave" })
438
- } else {
439
- openPanel(item.secondaryPanel)
440
- }
504
+ openPanel(item.secondaryPanel)
441
505
  }
442
506
  }}
443
507
  >
@@ -738,14 +802,26 @@ function TeamSwitcher() {
738
802
  // ─────────────────────────────────────────────────────────────────────────────
739
803
 
740
804
  const PRODUCTS: { id: Product; label: string }[] = [
741
- { id: "exxat-one", label: "Exxat One" },
742
- { id: "exxat-prism", label: "Exxat Prism" },
805
+ { id: "exxat-one", label: "Exxat One" },
806
+ { id: "exxat-prism", label: "Exxat Prism" },
807
+ { id: "exxat-assessment", label: "Exxat Assessment" },
808
+ { id: "exxat-custom", label: "Custom product" },
743
809
  ]
744
810
 
745
811
  function ProductLogoButton() {
746
- const { product, setProduct } = useProduct()
812
+ const { product, setProduct, customProductBrand, hiddenProductIds } = useProduct()
747
813
  const { state, isMobile } = useSidebar()
748
- const current = PRODUCTS.find(p => p.id === product) ?? PRODUCTS[0]
814
+ const products = React.useMemo(
815
+ () => PRODUCTS.flatMap(p => {
816
+ if (hiddenProductIds.includes(p.id)) return []
817
+ if (p.id !== "exxat-custom") return [p]
818
+ return customProductBrand
819
+ ? [{ ...p, label: productBrandLabel(customProductBrandConfig(customProductBrand)) }]
820
+ : []
821
+ }),
822
+ [customProductBrand, hiddenProductIds],
823
+ )
824
+ const current = products.find(p => p.id === product) ?? products[0]
749
825
  const iconRail = state === "collapsed" && !isMobile
750
826
  const expandedOrMobile = state === "expanded" || isMobile
751
827
 
@@ -768,11 +844,10 @@ function ProductLogoButton() {
768
844
  suppressHydrationWarning
769
845
  >
770
846
  {iconRail ? (
847
+ // Match the school selector footprint in the icon rail; the
848
+ // inner mark cutout uses the rail surface instead of a white fill.
771
849
  <span className="flex size-8 shrink-0 items-center justify-center">
772
- <ExxatProductMark
773
- product={current.id}
774
- className="size-7 max-h-none"
775
- />
850
+ <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
776
851
  </span>
777
852
  ) : (
778
853
  <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
@@ -783,7 +858,7 @@ function ProductLogoButton() {
783
858
  <ExxatProductLogo
784
859
  product={current.id}
785
860
  variant="mutedSuffix"
786
- className="h-7 w-auto max-w-[min(100%,260px)] object-left object-contain"
861
+ className="w-auto max-w-[min(100%,280px)] object-left object-contain"
787
862
  />
788
863
  </span>
789
864
  <span
@@ -810,7 +885,7 @@ function ProductLogoButton() {
810
885
  Switch product
811
886
  </DropdownMenuLabel>
812
887
  <DropdownMenuSeparator />
813
- {PRODUCTS.map(p => (
888
+ {products.map(p => (
814
889
  <DropdownMenuItem
815
890
  key={p.id}
816
891
  onClick={() => setProduct(p.id)}
@@ -820,7 +895,7 @@ function ProductLogoButton() {
820
895
  <ExxatProductLogo
821
896
  product={p.id}
822
897
  variant="mutedSuffix"
823
- className="h-7 w-auto shrink-0 max-w-[min(100%,200px)]"
898
+ className="w-auto shrink-0 max-w-[min(100%,260px)]"
824
899
  />
825
900
  {p.id === product && (
826
901
  <i className="fa-solid fa-check ml-auto text-brand text-xs" aria-hidden="true" />
@@ -901,6 +976,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
901
976
  <ProductLogoButton />
902
977
  </SidebarMenuItem>
903
978
  </SidebarMenu>
979
+ <div className="flex w-full justify-center px-2">
980
+ <Separator
981
+ orientation="horizontal"
982
+ decorative
983
+ className="my-1.5 h-px w-full max-w-none shrink-0 bg-sidebar-border group-data-[collapsible=icon]:w-8"
984
+ />
985
+ </div>
904
986
  <TeamSwitcher />
905
987
  </SidebarHeaderStack>
906
988
  </SidebarHeader>
@@ -188,8 +188,6 @@ export function AskLeoSidebar() {
188
188
  const routeContext = React.useMemo(() => getAskLeoRouteContext(pathname), [pathname])
189
189
  const isThinking = threadMessages.some((m) => m.pending)
190
190
 
191
- const pageTitle = pageContext?.title ?? routeContext.title
192
- const pageDescription = pageContext?.description ?? routeContext.description
193
191
  const suggestions =
194
192
  pageContext?.suggestions && pageContext.suggestions.length > 0
195
193
  ? pageContext.suggestions
@@ -286,8 +284,7 @@ export function AskLeoSidebar() {
286
284
  style={
287
285
  open
288
286
  ? {
289
- background:
290
- "linear-gradient(180deg, color-mix(in oklch, var(--brand-color) 4%, var(--background)) 0%, color-mix(in oklch, var(--brand-color) 8%, var(--background)) 100%)",
287
+ background: "var(--leo-surface-gradient)",
291
288
  }
292
289
  : undefined
293
290
  }