@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
@@ -5,11 +5,19 @@
5
5
  *
6
6
  * ✓ SidebarTrigger wrapped in Tooltip — icon-only button (WCAG 4.1.2, 1.1.1)
7
7
  * ✓ <header role="banner"> landmark for AT navigation (WCAG 1.3.6)
8
- * ✓ No bottom border (per design spec)
8
+ * ✓ Sticky at top when stuck, the rounded breadcrumb sits on the app bg and a
9
+ * bottom separator appears to anchor it; transparent at rest so the rounded
10
+ * corners blend into the inset card.
9
11
  * ✓ Uses Inter (font-sans) — Ivy Presto is reserved for PageHeader <h1> only
10
12
  */
11
13
 
12
- import Link from "next/link"
14
+ import * as React from "react"
15
+ import {
16
+ PageBreadcrumbBack,
17
+ PageBreadcrumbTrail,
18
+ type PageBreadcrumbBackProps,
19
+ type PageBreadcrumbTrailItem,
20
+ } from "@/components/page-breadcrumb-trail"
13
21
  import { Separator } from "@/components/ui/separator"
14
22
  import { SidebarTrigger } from "@/components/ui/sidebar"
15
23
  import { Kbd, KbdGroup } from "@/components/ui/kbd"
@@ -20,26 +28,54 @@ import {
20
28
  } from "@/components/ui/tooltip"
21
29
  import { AskLeoToggle } from "@/components/ask-leo-sidebar"
22
30
  import { useModKeyLabel } from "@/hooks/use-mod-key-label"
31
+ import { cn } from "@/lib/utils"
23
32
 
24
- export interface BreadcrumbItem {
25
- label: string
26
- href?: string
27
- }
33
+ export type BreadcrumbItem = PageBreadcrumbTrailItem
34
+ export type SiteHeaderBackLink = Pick<PageBreadcrumbBackProps, "label" | "href">
28
35
 
29
36
  export interface SiteHeaderProps {
30
- /** Current page title (last breadcrumb segment) */
37
+ /** Current page title (last breadcrumb segment in trail mode). */
31
38
  title?: string
32
39
  /** Full breadcrumb trail — each item can be a link or plain text. Title is appended automatically as the last segment. */
33
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
34
46
  }
35
47
 
36
- export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps) {
48
+ export function SiteHeader({
49
+ title = "Dashboard",
50
+ breadcrumbs,
51
+ back,
52
+ }: SiteHeaderProps) {
37
53
  const mod = useModKeyLabel()
54
+ const [isStuck, setIsStuck] = React.useState(false)
55
+
56
+ React.useEffect(() => {
57
+ const onScroll = () => setIsStuck(window.scrollY > 0)
58
+ onScroll()
59
+ window.addEventListener("scroll", onScroll, { passive: true })
60
+ return () => window.removeEventListener("scroll", onScroll)
61
+ }, [])
38
62
 
39
63
  return (
64
+ <div
65
+ className={cn(
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",
73
+ isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
74
+ )}
75
+ >
40
76
  <header
41
77
  role="banner"
42
- className="flex h-(--header-height) shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
78
+ className="flex h-(--header-height) shrink-0 items-center gap-2 bg-background rounded-t-xl transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
43
79
  >
44
80
  <div className="flex w-full items-center gap-1 ps-4 pe-2 lg:gap-2 lg:ps-6 lg:pe-2">
45
81
  <Tooltip>
@@ -60,34 +96,22 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
60
96
  className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-auto"
61
97
  />
62
98
 
63
- {/* Breadcrumb trail */}
64
- <nav aria-label="Breadcrumb" className="flex items-center gap-1.5 min-w-0 overflow-hidden">
65
- {breadcrumbs?.map((crumb, i) => (
66
- <span key={i} className="flex items-center gap-1.5 shrink-0">
67
- {crumb.href ? (
68
- <Link
69
- href={crumb.href}
70
- className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
71
- >
72
- {crumb.label}
73
- </Link>
74
- ) : (
75
- <span className="font-sans text-sm text-muted-foreground tracking-normal">
76
- {crumb.label}
77
- </span>
78
- )}
79
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
80
- </span>
81
- ))}
82
- <span className="font-sans text-sm font-medium text-foreground tracking-normal truncate">
83
- {title}
84
- </span>
85
- </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
+ )}
86
109
 
87
110
  <div className="ml-auto shrink-0">
88
111
  <AskLeoToggle />
89
112
  </div>
90
113
  </div>
91
114
  </header>
115
+ </div>
92
116
  )
93
117
  }
@@ -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(
@@ -3,6 +3,8 @@
3
3
  import * as React from "react"
4
4
 
5
5
  import { ListPageViewFrame } from "@/components/data-views"
6
+ import { DotPattern } from "@/components/ui/dot-pattern"
7
+ import { cn } from "@/lib/utils"
6
8
 
7
9
  export interface DedicatedSearchLandingTemplateProps {
8
10
  /** Page title — string or rich node (e.g. styled heading). */
@@ -20,6 +22,67 @@ export interface DedicatedSearchLandingTemplateProps {
20
22
  const DEFAULT_GUTTER =
21
23
  "mx-auto flex min-h-[min(72vh,36rem)] w-full min-w-0 flex-col justify-center gap-0 px-6 py-8 sm:px-8 sm:py-10 md:px-12 md:py-12 lg:px-16"
22
24
 
25
+ /** Feather into page white / header so the hero never reads as a hard horizontal slab. */
26
+ const HERO_BACKDROP_MASK =
27
+ "[mask-image:linear-gradient(to_bottom,transparent_0%,black_5%,black_95%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,black_5%,black_95%,transparent_100%)]"
28
+
29
+ /**
30
+ * Soft blurred blobs using only Ask Leo surface tints (`--leo-surface-tint-a|b` in `globals.css`).
31
+ */
32
+ function DedicatedSearchLandingBackdrop() {
33
+ return (
34
+ <div
35
+ aria-hidden
36
+ className={cn(
37
+ "pointer-events-none absolute inset-0 -z-10 select-none overflow-hidden",
38
+ HERO_BACKDROP_MASK,
39
+ )}
40
+ >
41
+ <div
42
+ className="absolute -left-[20%] -top-[30%] h-[min(54vmin,27rem)] w-[min(54vmin,27rem)] rounded-full blur-[76px]"
43
+ style={{
44
+ background: "radial-gradient(circle at 42% 36%, var(--leo-surface-tint-b) 0%, transparent 68%)",
45
+ }}
46
+ />
47
+ <div
48
+ className="absolute -right-[12%] top-[2%] h-[min(46vmin,23rem)] w-[min(46vmin,23rem)] rounded-full blur-[68px]"
49
+ style={{
50
+ background: "radial-gradient(circle at 48% 48%, var(--leo-surface-tint-a) 0%, transparent 66%)",
51
+ }}
52
+ />
53
+ <div
54
+ className="absolute bottom-[-16%] left-[14%] h-[min(50vmin,25rem)] w-[min(50vmin,25rem)] rounded-full blur-[84px]"
55
+ style={{
56
+ background: "radial-gradient(circle at 44% 40%, var(--leo-surface-tint-b) 0%, transparent 70%)",
57
+ }}
58
+ />
59
+ <div
60
+ className="absolute bottom-[4%] right-[6%] h-[min(40vmin,20rem)] w-[min(40vmin,20rem)] rounded-full blur-[60px]"
61
+ style={{
62
+ background: "radial-gradient(circle at 52% 44%, var(--leo-surface-tint-a) 0%, transparent 72%)",
63
+ }}
64
+ />
65
+ <div
66
+ className="absolute left-[36%] top-[32%] h-[min(38vmin,19rem)] w-[min(38vmin,19rem)] -translate-x-1/2 rounded-full blur-[74px]"
67
+ style={{
68
+ background: "radial-gradient(circle at 50% 50%, var(--leo-surface-tint-b) 0%, transparent 68%)",
69
+ }}
70
+ />
71
+
72
+ {/* Static dot field — same primitive as `AiThinkingOverlay` (no motion here). */}
73
+ <DotPattern
74
+ width={15}
75
+ height={15}
76
+ cr={0.65}
77
+ className={cn(
78
+ "absolute inset-0 opacity-[0.34] mix-blend-multiply dark:opacity-[0.22] dark:mix-blend-soft-light",
79
+ "fill-[color-mix(in_oklch,var(--brand-color)_14%,var(--background))]",
80
+ )}
81
+ />
82
+ </div>
83
+ )
84
+ }
85
+
23
86
  /**
24
87
  * Centered dedicated-search landing — empty `?q=` shell (hero title + composer + optional trailing).
25
88
  */
@@ -32,27 +95,30 @@ export function DedicatedSearchLandingTemplate({
32
95
  gutterClassName = DEFAULT_GUTTER,
33
96
  }: DedicatedSearchLandingTemplateProps) {
34
97
  return (
35
- <ListPageViewFrame
36
- maxWidthClassName={maxWidthClassName}
37
- className={frameClassName}
38
- gutterClassName={gutterClassName}
39
- >
40
- <header className="min-w-0">
41
- {typeof title === "string" ? (
42
- <h1
43
- className="text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl"
44
- style={{ fontFamily: "var(--font-heading)" }}
45
- >
46
- {title}
47
- </h1>
48
- ) : (
49
- title
50
- )}
51
- </header>
98
+ <div className="relative isolate min-w-0 w-full overflow-hidden">
99
+ <DedicatedSearchLandingBackdrop />
100
+ <ListPageViewFrame
101
+ maxWidthClassName={maxWidthClassName}
102
+ className={cn("relative z-10", frameClassName)}
103
+ gutterClassName={gutterClassName}
104
+ >
105
+ <header className="min-w-0">
106
+ {typeof title === "string" ? (
107
+ <h1
108
+ className="text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl"
109
+ style={{ fontFamily: "var(--font-heading)" }}
110
+ >
111
+ {title}
112
+ </h1>
113
+ ) : (
114
+ title
115
+ )}
116
+ </header>
52
117
 
53
- <div className="min-w-0 mt-6 sm:mt-8">{composer}</div>
118
+ <div className="min-w-0 mt-6 sm:mt-8">{composer}</div>
54
119
 
55
- {trailing ? <div className="min-w-0 mt-10 sm:mt-12 md:mt-14 lg:mt-16">{trailing}</div> : null}
56
- </ListPageViewFrame>
120
+ {trailing ? <div className="min-w-0 mt-10 sm:mt-12 md:mt-14 lg:mt-16">{trailing}</div> : null}
121
+ </ListPageViewFrame>
122
+ </div>
57
123
  )
58
124
  }
@@ -320,9 +320,7 @@ export function ListPageTemplate({
320
320
  const count = getTabCount?.(tab.filterId)
321
321
  const tabInner = (
322
322
  <>
323
- {isActive ? (
324
- <i className={`fa-light ${tab.icon} text-xs`} aria-hidden="true" />
325
- ) : null}
323
+ <i className={cn("fa-light shrink-0 text-xs", tab.icon)} aria-hidden="true" />
326
324
  {tab.label}
327
325
  {count !== undefined && (
328
326
  <span
@@ -14,8 +14,8 @@ export interface NestedSecondaryPanelShellProps {
14
14
  }
15
15
 
16
16
  /**
17
- * Shared chrome for a nested hub rail — full width vs icon rail, aligned with primary sidebar tokens.
18
- * Domain panels render their header + nav inside `children`.
17
+ * Shared chrome for a nested hub rail — full width vs icon rail.
18
+ * Fill uses `--secondary-panel-bg` (soft brand wash on `--background`).
19
19
  */
20
20
  export function NestedSecondaryPanelShell({
21
21
  open,
@@ -34,15 +34,20 @@ export function NestedSecondaryPanelShell({
34
34
  "transition-[width,margin,opacity] duration-200 ease-linear",
35
35
  open
36
36
  ? cn(
37
- "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative md:sticky md:top-2",
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.
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,
44
50
  )}
45
- style={open ? { backgroundColor: "var(--secondary-panel-bg, #FAFBFF)" } : undefined}
46
51
  >
47
52
  <div
48
53
  className={cn(