@exxatdesignux/ui 0.2.16 → 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 (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -12,7 +12,12 @@
12
12
  */
13
13
 
14
14
  import * as React from "react"
15
- import Link from "next/link"
15
+ import {
16
+ PageBreadcrumbBack,
17
+ PageBreadcrumbTrail,
18
+ type PageBreadcrumbBackProps,
19
+ type PageBreadcrumbTrailItem,
20
+ } from "@/components/page-breadcrumb-trail"
16
21
  import { Separator } from "@/components/ui/separator"
17
22
  import { SidebarTrigger } from "@/components/ui/sidebar"
18
23
  import { Kbd, KbdGroup } from "@/components/ui/kbd"
@@ -25,19 +30,26 @@ import { AskLeoToggle } from "@/components/ask-leo-sidebar"
25
30
  import { useModKeyLabel } from "@/hooks/use-mod-key-label"
26
31
  import { cn } from "@/lib/utils"
27
32
 
28
- export interface BreadcrumbItem {
29
- label: string
30
- href?: string
31
- }
33
+ export type BreadcrumbItem = PageBreadcrumbTrailItem
34
+ export type SiteHeaderBackLink = Pick<PageBreadcrumbBackProps, "label" | "href">
32
35
 
33
36
  export interface SiteHeaderProps {
34
- /** Current page title (last breadcrumb segment) */
37
+ /** Current page title (last breadcrumb segment in trail mode). */
35
38
  title?: string
36
39
  /** Full breadcrumb trail — each item can be a link or plain text. Title is appended automatically as the last segment. */
37
40
  breadcrumbs?: BreadcrumbItem[]
41
+ /**
42
+ * Back-icon variant — parent link only (no `title` segment in the header).
43
+ * Prefer when the page `<h1>` carries the current title (e.g. New question composer).
44
+ */
45
+ back?: SiteHeaderBackLink
38
46
  }
39
47
 
40
- export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps) {
48
+ export function SiteHeader({
49
+ title = "Dashboard",
50
+ breadcrumbs,
51
+ back,
52
+ }: SiteHeaderProps) {
41
53
  const mod = useModKeyLabel()
42
54
  const [isStuck, setIsStuck] = React.useState(false)
43
55
 
@@ -51,7 +63,13 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
51
63
  return (
52
64
  <div
53
65
  className={cn(
54
- "sticky top-0 z-60 transition-colors",
66
+ // Sticky page chrome sits BELOW every Radix overlay (DropdownMenu /
67
+ // Popover / Select / Dialog / Sheet / Tooltip / Drawer all render at
68
+ // z-50). Previously `z-60` here caused the school/product switcher
69
+ // dropdown to open behind the breadcrumb. `z-30` keeps the header
70
+ // above page content (charts, tables, scrolled rows) but below
71
+ // floating overlays.
72
+ "sticky top-0 z-30 transition-colors",
55
73
  isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
56
74
  )}
57
75
  >
@@ -78,29 +96,16 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
78
96
  className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-auto"
79
97
  />
80
98
 
81
- {/* Breadcrumb trail */}
82
- <nav aria-label="Breadcrumb" className="flex items-center gap-1.5 min-w-0 overflow-hidden">
83
- {breadcrumbs?.map((crumb, i) => (
84
- <span key={i} className="flex items-center gap-1.5 shrink-0">
85
- {crumb.href ? (
86
- <Link
87
- href={crumb.href}
88
- className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
89
- >
90
- {crumb.label}
91
- </Link>
92
- ) : (
93
- <span className="font-sans text-sm text-muted-foreground tracking-normal">
94
- {crumb.label}
95
- </span>
96
- )}
97
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
98
- </span>
99
- ))}
100
- <span className="font-sans text-sm font-medium text-foreground tracking-normal truncate">
101
- {title}
102
- </span>
103
- </nav>
99
+ {back ? (
100
+ <PageBreadcrumbBack {...back} className="min-w-0 flex-1" />
101
+ ) : (
102
+ <PageBreadcrumbTrail
103
+ variant="header"
104
+ items={breadcrumbs}
105
+ currentPage={title}
106
+ className="flex-1"
107
+ />
108
+ )}
104
109
 
105
110
  <div className="ml-auto shrink-0">
106
111
  <AskLeoToggle />
@@ -3,45 +3,40 @@
3
3
  import Link from "next/link"
4
4
  import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
5
5
  import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
6
+ import { DataRowList } from "@/components/data-views/data-row-list"
6
7
 
7
8
  export function SitesListView({ rows }: { rows: SiteDirectoryRow[] }) {
8
- if (rows.length === 0) {
9
- return (
10
- <div className="px-4 py-16 text-center lg:px-6">
11
- <p className="text-sm text-muted-foreground">No sites match your search.</p>
12
- </div>
13
- )
14
- }
15
-
16
9
  return (
17
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
18
- {rows.map(site => (
19
- <li key={site.id}>
20
- <Link
21
- href={site.url}
22
- className="block rounded-xl text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
10
+ <DataRowList<SiteDirectoryRow>
11
+ rows={rows}
12
+ getRowId={site => site.id}
13
+ emptyState="No sites match your search."
14
+ ariaLabel="Sites"
15
+ renderRow={site => (
16
+ <Link
17
+ href={site.url}
18
+ className="block rounded-xl text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
19
+ >
20
+ <ListPageBoardCard
21
+ layout="row"
22
+ interactive
23
+ rowContainerClassName="flex flex-row items-center gap-3"
24
+ leading={
25
+ <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-brand/10 text-brand">
26
+ <i className="fa-light fa-hospital text-sm" aria-hidden="true" />
27
+ </span>
28
+ }
29
+ rowEnd={
30
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
31
+ }
23
32
  >
24
- <ListPageBoardCard
25
- layout="row"
26
- interactive
27
- rowContainerClassName="flex flex-row items-center gap-3"
28
- leading={
29
- <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-brand/10 text-brand">
30
- <i className="fa-light fa-hospital text-sm" aria-hidden="true" />
31
- </span>
32
- }
33
- rowEnd={
34
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
35
- }
36
- >
37
- <div className="space-y-0.5">
38
- <p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
39
- <p className="truncate text-xs text-muted-foreground">{site.id}</p>
40
- </div>
41
- </ListPageBoardCard>
42
- </Link>
43
- </li>
44
- ))}
45
- </ul>
33
+ <div className="space-y-0.5">
34
+ <p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
35
+ <p className="truncate text-xs text-muted-foreground">{site.id}</p>
36
+ </div>
37
+ </ListPageBoardCard>
38
+ </Link>
39
+ )}
40
+ />
46
41
  )
47
42
  }
@@ -10,6 +10,7 @@ import Link from "next/link"
10
10
  import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
11
11
  import { DataTable, DataTableToolbar } from "@/components/data-table"
12
12
  import { useTableState } from "@/components/data-table/use-table-state"
13
+ import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
13
14
  import type { ColumnDef } from "@/components/data-table/types"
14
15
  import type { DataListViewType } from "@/lib/data-list-view"
15
16
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
@@ -193,6 +194,25 @@ export const SitesTable = React.forwardRef<
193
194
 
194
195
  const tableState = useTableState<SiteDirectoryRow>(sites, columns, { key: "name", dir: "asc" })
195
196
 
197
+ // Persist this hub's table lifecycle (sort / search / filters / column
198
+ // visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
199
+ const lifecycleColumnKeys = React.useMemo(
200
+ () => new Set(columns.map(c => c.key)),
201
+ [columns],
202
+ )
203
+ useTableStateLifecycle({
204
+ namespace: "sites",
205
+ tabId: "main",
206
+ tableState,
207
+ columnKeys: lifecycleColumnKeys,
208
+ extras: { conditionalRules },
209
+ onLoadExtras: e => {
210
+ if (e && Array.isArray(e.conditionalRules)) {
211
+ setConditionalRules(e.conditionalRules as ConditionalRule[])
212
+ }
213
+ },
214
+ })
215
+
196
216
  React.useImperativeHandle(
197
217
  ref,
198
218
  () => ({
@@ -200,6 +220,10 @@ export const SitesTable = React.forwardRef<
200
220
  tableState.setSheetOpen(true)
201
221
  },
202
222
  }),
223
+ // `tableState` is freshly returned each render by useTableState; depending
224
+ // on it would re-create the imperative handle on every render. Only the
225
+ // React setter is needed (and is referentially stable).
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
227
  [tableState.setSheetOpen],
204
228
  )
205
229
 
@@ -250,7 +250,7 @@ export function TablePropertiesDrawer({
250
250
  showOverlay={false}
251
251
  // w-[min(20rem,calc(100vw-1rem))]: cap to viewport width - 1rem at narrow/zoomed viewports
252
252
  // so the drawer never overflows horizontally. Use 100svh so height is correct on mobile.
253
- className="w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
253
+ className="z-[80] w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
254
254
  style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100svh - 1rem)" }}
255
255
  >
256
256
 
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as DataListClient).
4
+ * Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as PlacementsClient).
5
5
  * Imports from `@/components/data-views` for shared list-page + view types.
6
6
  */
7
7
 
@@ -2,11 +2,13 @@
2
2
 
3
3
  /**
4
4
  * TeamListView — full-width rows for team roster (same data as DataTable / board).
5
+ * Shell from generic `DataRowList`; row body stays team-specific (avatar,
6
+ * name, role, email, status badge).
5
7
  */
6
8
 
7
- import * as React from "react"
8
9
  import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
9
10
  import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
11
+ import { DataRowList } from "@/components/data-views/data-row-list"
10
12
  import {
11
13
  TEAM_MEMBER_STATUS_BADGE_CLASS,
12
14
  TEAM_MEMBER_STATUS_ICON,
@@ -14,42 +16,6 @@ import {
14
16
  } from "@/lib/list-status-badges"
15
17
  import type { TeamMember } from "@/lib/mock/team"
16
18
 
17
- function TeamListRow({
18
- member,
19
- onRowActivate,
20
- }: {
21
- member: TeamMember
22
- onRowActivate?: (member: TeamMember) => void
23
- }) {
24
- return (
25
- <li>
26
- <ListPageBoardCard
27
- layout="row"
28
- rowContainerClassName="flex flex-row items-center gap-3"
29
- onClick={onRowActivate ? () => onRowActivate(member) : undefined}
30
- leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
31
- rowEnd={
32
- <div className="flex shrink-0 items-center gap-2">
33
- <ListHubStatusBadge
34
- surface="board"
35
- label={TEAM_MEMBER_STATUS_LABEL[member.status]}
36
- tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
37
- icon={TEAM_MEMBER_STATUS_ICON[member.status]}
38
- />
39
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
40
- </div>
41
- }
42
- >
43
- <div className="space-y-0.5">
44
- <p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
45
- <p className="text-xs text-muted-foreground">{member.role}</p>
46
- <p className="truncate text-xs text-muted-foreground">{member.email}</p>
47
- </div>
48
- </ListPageBoardCard>
49
- </li>
50
- )
51
- }
52
-
53
19
  export function TeamListView({
54
20
  members,
55
21
  onRowActivate,
@@ -57,19 +23,37 @@ export function TeamListView({
57
23
  members: TeamMember[]
58
24
  onRowActivate?: (member: TeamMember) => void
59
25
  }) {
60
- if (members.length === 0) {
61
- return (
62
- <div className="px-4 py-16 text-center lg:px-6">
63
- <p className="text-sm text-muted-foreground">No team members match your filters.</p>
64
- </div>
65
- )
66
- }
67
-
68
26
  return (
69
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
70
- {members.map(m => (
71
- <TeamListRow key={m.id} member={m} onRowActivate={onRowActivate} />
72
- ))}
73
- </ul>
27
+ <DataRowList<TeamMember>
28
+ rows={members}
29
+ getRowId={m => m.id}
30
+ emptyState="No team members match your filters."
31
+ ariaLabel="Team members"
32
+ renderRow={member => (
33
+ <ListPageBoardCard
34
+ layout="row"
35
+ rowContainerClassName="flex flex-row items-center gap-3"
36
+ onClick={onRowActivate ? () => onRowActivate(member) : undefined}
37
+ leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
38
+ rowEnd={
39
+ <div className="flex shrink-0 items-center gap-2">
40
+ <ListHubStatusBadge
41
+ surface="board"
42
+ label={TEAM_MEMBER_STATUS_LABEL[member.status]}
43
+ tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
44
+ icon={TEAM_MEMBER_STATUS_ICON[member.status]}
45
+ />
46
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
47
+ </div>
48
+ }
49
+ >
50
+ <div className="space-y-0.5">
51
+ <p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
52
+ <p className="text-xs text-muted-foreground">{member.role}</p>
53
+ <p className="truncate text-xs text-muted-foreground">{member.email}</p>
54
+ </div>
55
+ </ListPageBoardCard>
56
+ )}
57
+ />
74
58
  )
75
59
  }
@@ -13,6 +13,7 @@ import {
13
13
  TEAM_MEMBER_STATUS_ICON,
14
14
  TEAM_MEMBER_STATUS_LABEL,
15
15
  } from "@/lib/list-status-badges"
16
+ import { mailtoHref } from "@/lib/mailto"
16
17
  import type { TeamMember } from "@/lib/mock/team"
17
18
  import { DataTable, DataTableToolbar } from "@/components/data-table"
18
19
  import {
@@ -37,6 +38,7 @@ import type { DataListViewType } from "@/lib/data-list-view"
37
38
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
38
39
  import type { ColumnDef } from "@/components/data-table/types"
39
40
  import { useTableState } from "@/components/data-table/use-table-state"
41
+ import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
40
42
  import { TablePropertiesDrawerButton } from "@/components/table-properties"
41
43
  import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
42
44
  import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
@@ -154,7 +156,7 @@ function TeamFinderDetail({
154
156
  <i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
155
157
  </dt>
156
158
  <dd className="text-[13px]">
157
- <a href={`mailto:${member.email}`} className="text-interactive-foreground hover:underline">{member.email}</a>
159
+ <a href={mailtoHref(member.email)} className="text-interactive-foreground hover:underline">{member.email}</a>
158
160
  </dd>
159
161
  </div>
160
162
  <div className="flex flex-col gap-0.5">
@@ -277,7 +279,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
277
279
  operators: ["contains", "not_contains"],
278
280
  },
279
281
  cell: row => (
280
- <a href={`mailto:${row.email}`} className="text-sm text-primary hover:underline truncate block">
282
+ <a href={mailtoHref(row.email)} className="text-sm text-primary hover:underline truncate block">
281
283
  {row.email}
282
284
  </a>
283
285
  ),
@@ -341,7 +343,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
341
343
  </Button>
342
344
  </DropdownMenuTrigger>
343
345
  <DropdownMenuContent align="end">
344
- <DropdownMenuItem onClick={() => window.open(`mailto:${row.email}`)}>
346
+ <DropdownMenuItem onClick={() => window.open(mailtoHref(row.email))}>
345
347
  <i className="fa-light fa-envelope" aria-hidden="true" />
346
348
  Email
347
349
  </DropdownMenuItem>
@@ -400,6 +402,26 @@ export const TeamTable = React.forwardRef<
400
402
 
401
403
  const tableState = useTableState(members, columns, { key: "name", dir: "asc" })
402
404
 
405
+ // Persist this hub's table lifecycle (sort / search / filters / column
406
+ // visibility / etc.) to localStorage. See `lib/table-state-lifecycle` for
407
+ // the centralised hook; pass `extras` for any non-table state.
408
+ const lifecycleColumnKeys = React.useMemo(
409
+ () => new Set(columns.map(c => c.key)),
410
+ [columns],
411
+ )
412
+ useTableStateLifecycle({
413
+ namespace: "team",
414
+ tabId: "main",
415
+ tableState,
416
+ columnKeys: lifecycleColumnKeys,
417
+ extras: { conditionalRules },
418
+ onLoadExtras: e => {
419
+ if (e && Array.isArray(e.conditionalRules)) {
420
+ setConditionalRules(e.conditionalRules as ConditionalRule[])
421
+ }
422
+ },
423
+ })
424
+
403
425
  const dashboardKpi = React.useMemo(
404
426
  () => ({
405
427
  metrics: teamKpiMetrics(tableState.rows),
@@ -501,6 +523,10 @@ export const TeamTable = React.forwardRef<
501
523
  openPropertiesDrawer: () => {
502
524
  tableState.setSheetOpen(true)
503
525
  },
526
+ // `tableState` is freshly returned each render by useTableState; depending on
527
+ // it would re-create the imperative handle on every render. Only the React
528
+ // setter is needed (and is referentially stable).
529
+ // eslint-disable-next-line react-hooks/exhaustive-deps
504
530
  }), [tableState.setSheetOpen])
505
531
 
506
532
  const teamPanelFinderGroups = React.useMemo(
@@ -34,10 +34,16 @@ export function NestedSecondaryPanelShell({
34
34
  "transition-[width,margin,opacity] duration-200 ease-linear",
35
35
  open
36
36
  ? cn(
37
+ // Match the primary sidebar: fill the full viewport height
38
+ // (minus our 0.5rem top + 0.5rem bottom margin from `m-2` →
39
+ // 1rem on desktop where the panel is `md:sticky md:top-2`;
40
+ // 2rem on mobile where the panel scrolls inline and we leave
41
+ // a little more breathing room). No upper cap so tall screens
42
+ // get a fully-extended rail.
37
43
  "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
38
44
  compact
39
- ? "w-12 min-w-12 max-w-12 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]"
40
- : "w-64 min-w-64 max-w-64 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]",
45
+ ? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
46
+ : "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
41
47
  )
42
48
  : "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
43
49
  className,
@@ -37,8 +37,32 @@ type Cloud = {
37
37
  delay: number
38
38
  }
39
39
 
40
- function rand(min: number, max: number) {
41
- return min + Math.random() * (max - min)
40
+ /**
41
+ * Tiny deterministic PRNG (mulberry32). We use a seeded RNG instead of
42
+ * `Math.random()` so the SVG attributes emitted on the server match the
43
+ * client's first paint — otherwise React reports a hydration mismatch and
44
+ * has to re-paint every drifting `<motion.circle>` on mount, which is both
45
+ * a perf cost and a visible jump.
46
+ */
47
+ function mulberry32(seed: number): () => number {
48
+ let s = seed >>> 0
49
+ return () => {
50
+ s = (s + 0x6d2b79f5) >>> 0
51
+ let t = s
52
+ t = Math.imul(t ^ (t >>> 15), t | 1)
53
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
54
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
55
+ }
56
+ }
57
+
58
+ function hashString(str: string): number {
59
+ // Cheap FNV-1a-style hash. Stable across SSR + CSR for the same input.
60
+ let h = 2166136261
61
+ for (let i = 0; i < str.length; i++) {
62
+ h ^= str.charCodeAt(i)
63
+ h = Math.imul(h, 16777619)
64
+ }
65
+ return h >>> 0
42
66
  }
43
67
 
44
68
  export function DotPattern({
@@ -59,33 +83,33 @@ export function DotPattern({
59
83
  const maskId = `${id}-mask`
60
84
  const gradId = `${id}-grad`
61
85
 
62
- const clouds = React.useMemo<Cloud[]>(
63
- () =>
64
- Array.from({ length: glowCount }).map((_, i) => {
65
- // Drift diagonally: bottom-right → top-left. Start/end partly off-canvas
66
- // so the cloud enters and exits softly without a visible edge.
67
- const startX = rand(85, 120)
68
- const endX = rand(-20, 15)
69
- const midX = (startX + endX) / 2 + rand(-6, 6)
86
+ const clouds = React.useMemo<Cloud[]>(() => {
87
+ const rng = mulberry32(hashString(`${id}|${glowCount}`))
88
+ const rand = (min: number, max: number) => min + rng() * (max - min)
89
+ return Array.from({ length: glowCount }).map((_, i) => {
90
+ // Drift diagonally: bottom-right top-left. Start/end partly off-canvas
91
+ // so the cloud enters and exits softly without a visible edge.
92
+ const startX = rand(85, 120)
93
+ const endX = rand(-20, 15)
94
+ const midX = (startX + endX) / 2 + rand(-6, 6)
70
95
 
71
- const startY = rand(85, 115)
72
- const endY = rand(-15, 10)
73
- const midY = (startY + endY) / 2 + rand(-4, 4)
96
+ const startY = rand(85, 115)
97
+ const endY = rand(-15, 10)
98
+ const midY = (startY + endY) / 2 + rand(-4, 4)
74
99
 
75
- const duration = rand(8, 12)
76
- // Offset clouds by half a cycle so one is arriving as the other leaves.
77
- const delay = -(i / glowCount) * duration
100
+ const duration = rand(8, 12)
101
+ // Offset clouds by half a cycle so one is arriving as the other leaves.
102
+ const delay = -(i / glowCount) * duration
78
103
 
79
- return {
80
- key: i,
81
- xs: [`${startX}%`, `${midX}%`, `${endX}%`],
82
- ys: [`${startY}%`, `${midY}%`, `${endY}%`],
83
- duration,
84
- delay,
85
- }
86
- }),
87
- [glowCount],
88
- )
104
+ return {
105
+ key: i,
106
+ xs: [`${startX}%`, `${midX}%`, `${endX}%`],
107
+ ys: [`${startY}%`, `${midY}%`, `${endY}%`],
108
+ duration,
109
+ delay,
110
+ }
111
+ })
112
+ }, [glowCount, id])
89
113
 
90
114
  return (
91
115
  <svg
@@ -618,14 +618,34 @@ function InteractiveIcon({ sz, reduced }: { sz: SZ; reduced: boolean }) {
618
618
  const onDown = React.useCallback(() => setPressed(true), [])
619
619
  const onUp = React.useCallback(() => setPressed(false), [])
620
620
 
621
+ // Track click-effect timers so unmounting (Ask Leo sidebar close) doesn't
622
+ // leave timers running that then call setState on an unmounted component.
623
+ const clickTimersRef = React.useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
624
+ React.useEffect(() => {
625
+ const set = clickTimersRef.current
626
+ return () => {
627
+ for (const t of set) clearTimeout(t)
628
+ set.clear()
629
+ }
630
+ }, [])
631
+
632
+ const ringIdRef = React.useRef(0)
621
633
  const onClick = React.useCallback(() => {
622
634
  if (reduced) return
623
635
  setCast(true)
624
- setTimeout(() => setCast(false), 720)
636
+ const tCast = setTimeout(() => {
637
+ clickTimersRef.current.delete(tCast)
638
+ setCast(false)
639
+ }, 720)
640
+ clickTimersRef.current.add(tCast)
625
641
 
626
- const id = Date.now() + Math.random()
642
+ const id = ++ringIdRef.current
627
643
  setRings(prev => [...prev, id])
628
- setTimeout(() => setRings(prev => prev.filter(r => r !== id)), 800)
644
+ const tRing = setTimeout(() => {
645
+ clickTimersRef.current.delete(tRing)
646
+ setRings(prev => prev.filter(r => r !== id))
647
+ }, 800)
648
+ clickTimersRef.current.add(tRing)
629
649
 
630
650
  spawnBurst(6)
631
651
  }, [reduced, spawnBurst])