@exxatdesignux/ui 0.2.16 → 0.2.18

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 (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  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-kpi-flat-band/SKILL.md +38 -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 +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  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 +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
package/CHANGELOG.md CHANGED
@@ -15,6 +15,32 @@ After the user bumps `@exxatdesignux/ui`, do this in order:
15
15
 
16
16
  ---
17
17
 
18
+ ## [0.2.18] - 2026-05-19
19
+
20
+ ### Fixed
21
+
22
+ - **`Sidebar`**: Read `sidebar_state` on the server for `defaultOpen` so the documents **Resources** heading and rail chrome match the first client paint (fixes hydration warnings). Skip redundant cookie restore when state already matches.
23
+ - **`TablePropertiesDrawer`**: Portaled **Add filter / sort / rule** menus use `z-[90]` above the properties sheet (`z-[80]`); `Sheet` and dropdowns use `modal={false}`; filter updates apply synchronously (no `startTransition` deferral). Drawer button routes column/display handlers through refs for the portaled sheet.
24
+
25
+ ### Changed
26
+
27
+ - **Starter `template/`** and **consumer extras**: KPI flat band + shell surface elevation patterns/skills; table-properties and hub parity with `apps/web`.
28
+
29
+ ### Chore (monorepo)
30
+
31
+ - Package **`version`** set to **0.2.18** for npm publish (tag **`ui-v0.2.18`**).
32
+
33
+ ## [0.2.17] - 2026-05-18
34
+
35
+ ### Changed
36
+
37
+ - **Starter `template/`**: Synced from `apps/web` — Placements hub rename (`placements-*` vs legacy `data-list-*`), Question bank **new question** route, brand palette / appearance, `DataRowList` and folder tree primitives, table-state lifecycle helpers, and hub navigation polish.
38
+ - **Consumer extras**: Cursor skills and pattern docs refreshed (`vendor:consumer-extras`), including **mono IDs** skill/rule.
39
+
40
+ ### Chore (monorepo)
41
+
42
+ - Package **`version`** set to **0.2.17** for npm publish (tag **`ui-v0.2.17`**).
43
+
18
44
  ## [0.2.16] - 2026-05-15
19
45
 
20
46
  ### Changed
@@ -21,7 +21,7 @@ description: >
21
21
  - **Stack:** Next.js 16 (App Router), React, TypeScript, Tailwind CSS, shadcn/ui primitives, Font Awesome icons
22
22
  - **App root:** `exxat-ds/app/(app)/` — route group that wraps all authenticated pages
23
23
  - **Single source of truth:** `exxat-ds/AGENTS.md` for full prose explanations; this skill is the actionable summary
24
- - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
24
+ - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-mono-ids`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
25
25
  - **Question bank folder-scoped header (rule + doc):** **`.cursor/rules/exxat-question-bank-hub-header.mdc`** and **`docs/question-bank-hub-header-pattern.md`** — pair with **`exxat-primary-nav-secondary-panel`** when URL **`scope=folder`** drives the hub title.
26
26
  - **Consumer repos (npm install of `@exxatdesignux/ui`):** After a version bump, read **`node_modules/@exxatdesignux/ui/CHANGELOG.md`**, run **`npx --package=@exxatdesignux/ui@latest exxat-ui sync-extras`** so **`docs/exxat-ds/consumer-upgrade-checklist.md`** and Cursor skills match the tarball, and diff the host app against **`node_modules/@exxatdesignux/ui/template/`** for anything new to port (routes, re-exports, AGENTS). Use **`exxat-ui changelog`**, **`exxat-ui update`**, and **`exxat-ui doctor`** for CLI guidance.
27
27
 
@@ -89,16 +89,161 @@ To add a primary nav item, append to `NAV_PRIMARY`:
89
89
 
90
90
  | Concern | Pattern |
91
91
  |--------|---------|
92
- | **Product (One / Prism)** | **`ExxatProductLogo`** (`components/exxat-product-logo.tsx`) for the header control and **`ProductSwitcher`** — **not** logo.dev rasters unless product explicitly changes that. |
92
+ | **Product (One / Prism)** | **`ExxatProductLogo`** (`components/exxat-product-logo.tsx`) for the header control and **`ProductSwitcher`** — **not** logo.dev rasters unless product explicitly changes that. The logo is now **generated from a config**, not hand-built SVG paths — see **§3.4** to add a new product. |
93
93
  | **School/program menu width** | **`DropdownMenuContent`** defaults to **intrinsic width** (**`min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]`** via **`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`** in **`@exxatdesignux/ui/lib/dropdown-menu-surface`**) — pure CSS, no **`ResizeObserver`**. The **school / program** switcher still uses an explicit wider surface (**`!w-max min-w-72 max-w-[min(100vw-2rem,28rem)]`**) so dense rows stay readable. |
94
94
  | **School/program copy** | **Do not truncate** school or program names in the switcher; wrap (**`break-words`**, **`whitespace-normal`**, **`items-start`** on multi-line rows). The selected-school summary shows **school name + current program**. |
95
95
  | **Team switcher trigger** | **`SidebarMenuButton` `size="lg"`** uses **`h-12`** + **`overflow-hidden`**, which **clips** a second line (program). When the sidebar is **expanded** or **mobile**, add **`h-auto min-h-12`** and **`overflow-x-clip overflow-y-visible`**. On **icon rail**, hide label rows with **`group-data-[collapsible=icon]:hidden`** (tooltip still exposes the full string). Icon mode defaults **`size-8` + `p-2`** (~16px inner) **clips** school logos — override **`!size-9`**, **`!p-0`**, **`overflow-visible`**. Omit header **chevrons** next to logos if they look like stray chrome. |
96
96
  | **Motion / Animate UI** | [Animate UI](https://animate-ui.com/docs) — open **copy-first** animated components (Motion + Tailwind). This repo uses **`motion/react`** + **`lib/motion-ui.ts`** presets; pull more animations from their registry into `components/` when needed. |
97
- | **Nav items with children** | **Expanded:** **`Collapsible`**. **Desktop icon rail:** **`Popover`** listing child links. **Do not** pass **`tooltip={…}`** on **`SidebarMenuButton`** that is the **direct** child of **`CollapsibleTrigger asChild`** the tooltip wrapper inserts an extra **`Tooltip` root** and breaks Radix **`Slot`** (**`React.Children.only`**). Compose **`Tooltip` > `TooltipTrigger` > `CollapsibleTrigger` > `SidebarMenuButton`** without the **`tooltip` prop**, or use the popover branch only. |
97
+ | **Nav items with children** | See **§3.2** below for the full parent children pattern (collapsible vs popover, active-state rules, chevron + slide animation, reduced motion). |
98
98
  | **Profile (mock)** | **`stockPortraitUrl()`** from **`lib/stock-portrait.ts`**; **`AvatarImage`** **`referrerPolicy="no-referrer"`** for external URLs. |
99
99
 
100
100
  **Reference:** `components/app-sidebar.tsx`, `components/nav-user.tsx`, `components/product-switcher.tsx`.
101
101
 
102
+ ### 3.2 Parent ↔ children nav (collapsible vs popover vs secondary panel)
103
+
104
+ A primary nav row that owns sub-routes has **three possible shapes** — pick exactly one per item:
105
+
106
+ | Shape | When to use | Where it lives |
107
+ |---|---|---|
108
+ | **A. Collapsible children** (e.g. Question Bank → All / My / Favorites / Folders) | Small finite child list that benefits from inline browsing (≤ 40 items, no extra page chrome). | `NavLinkItem.children` rendered by **`CollapsibleNavItem`** in `app-sidebar.tsx`. |
109
+ | **B. Secondary panel** (separate nested rail) | Same nav row needs **scoped search / tree / metrics** alongside the hub content. | `NavLinkItem.secondaryPanel = "<id>"` + `PANELS[id]` — see companion skill `exxat-primary-nav-secondary-panel`. |
110
+ | **C. Both A + B on one row** (Question Bank does this) | Most cases when a hub has a sub-list AND a rich rail. The sidebar still shows the collapsible children; clicking the parent route also opens the secondary panel via `useAutoPanel`. | Combine A + B; the active-state and animation rules in §3.2 still apply to the sidebar children. |
111
+
112
+ #### Active-state rules — **the single biggest mistake to avoid**
113
+
114
+ 1. **Expanded sidebar (full rail):** parent stays **visually neutral** when a child is active — **never double-highlight**. `isCollapsibleParentMenuButtonActive` returns `false` if `anyChildActive` so the active child row carries `data-active` alone.
115
+ 2. **Collapsed sidebar (icon rail):** the parent icon is the **only** affordance, so it lights up when **any child** route is active. Implementation: `iconRailActive = isAnyChildActive` is fed into `SidebarMenuButton.isActive` and selects `item.iconActive` (`fa-solid`) over `item.icon` (`fa-light`).
116
+ 3. **Tooltip / aria copy** in icon mode names the parent (e.g. "Question bank — open subpages"); the **popover** content lists children with their own active state so users see which sub-route is selected.
117
+
118
+ ```tsx
119
+ // Inside CollapsibleNavItem
120
+ const isAnyChildActive = item.children?.some(c => isCollapsibleChildActive(...))
121
+ const parentMenuButtonActive = isCollapsibleParentMenuButtonActive(pathname, item) // false when anyChildActive
122
+ const iconRailActive = isAnyChildActive
123
+ ```
124
+
125
+ #### Chevron + content animation (shadcn / Radix collapsible)
126
+
127
+ The collapsible rotates a chevron and slides the children in/out, mirroring shadcn's `Accordion`/`Collapsible` motion. **Three pieces wired together:**
128
+
129
+ 1. **`group/collapsible`** on the **`SidebarMenuItem`** that wraps the trigger — opts the chevron into `group-data-[state=open]/collapsible:rotate-90` (or whatever direction the icon needs). Without `group/collapsible` the chevron never rotates.
130
+ 2. **`CollapsibleContent`** uses Radix's `--radix-collapsible-content-height` CSS var via the shared keyframes:
131
+ ```tsx
132
+ <CollapsibleContent className="overflow-hidden
133
+ data-[state=open]:[animation:collapsible-down_200ms_ease-out]
134
+ data-[state=closed]:[animation:collapsible-up_200ms_ease-out]
135
+ motion-reduce:animate-none
136
+ group-data-[collapsible=icon]:hidden">
137
+ <SidebarMenuSub>...</SidebarMenuSub>
138
+ </CollapsibleContent>
139
+ ```
140
+ `overflow-hidden` is **required** so the height clip is visible during the animation. `motion-reduce:animate-none` honours `prefers-reduced-motion` (WCAG 2.3.3).
141
+ 3. **Keyframes in `app/globals.css`** (shared, reused by `Accordion` too):
142
+ ```css
143
+ @keyframes collapsible-down { from { height: 0 } to { height: var(--radix-collapsible-content-height) } }
144
+ @keyframes collapsible-up { from { height: var(--radix-collapsible-content-height) } to { height: 0 } }
145
+ ```
146
+
147
+ #### Icon-rail popover (collapsed sidebar)
148
+
149
+ When `state === "collapsed"` (or `isMobile === false` on a tablet icon rail), `CollapsibleNavItem` renders a **`Popover`** anchored to the parent icon, listing children as full rows. **Do not** pass `tooltip={…}` to a `SidebarMenuButton` that is the **direct** child of `CollapsibleTrigger asChild` — the tooltip wrapper inserts an extra `Tooltip` root and breaks Radix `Slot` (`React.Children.only`). Compose `Tooltip > TooltipTrigger > CollapsibleTrigger > SidebarMenuButton` without the `tooltip` prop, or use the popover branch only.
150
+
151
+ #### Hydration
152
+
153
+ `CollapsibleNavItem` is an isolated component with its own controlled `open` state initialised in `useEffect`. **Do not** pass `defaultOpen` based on pathname at render time — server and client resolve it differently and Radix throws a hydration mismatch.
154
+
155
+ #### Cap
156
+
157
+ The data shape supports any number of children, but the collapsible variant is rendered only when `childCount <= 40`. Beyond that, model the children as a **secondary panel** (shape B) so the user gets search + scroll.
158
+
159
+ **Reference:** `components/app-sidebar.tsx` (`CollapsibleNavItem`, `isCollapsibleParentMenuButtonActive`, `isCollapsibleChildActive`), `app/globals.css` (`@keyframes collapsible-down/up`), `lib/mock/navigation.tsx` (`NavLinkItem.children`).
160
+
161
+ ### 3.3 Secondary panel auto-collapse on high zoom
162
+
163
+ `SecondaryPanelProvider` (`components/secondary-panel.tsx`) reads **`useSidebarReflowZoom()`** (browser zoom ≥ 200% **or** very short viewport — same WCAG 1.4.10 signal the primary sidebar uses) and **auto-collapses the nested rail to its icon variant on entering high zoom**. The user can re-expand once collapsed; the next zoom-out → zoom-in cycle re-collapses. `openPanel` also opens directly in compact mode when high zoom is active so freshly-navigated panels don't briefly flash expanded.
164
+
165
+ Any future secondary-panel-like rail should reuse `useSidebarReflowZoom` rather than inventing a parallel zoom hook.
166
+
167
+ **Reference:** `components/secondary-panel.tsx` (`SecondaryPanelProvider`), `hooks/use-sidebar-reflow-zoom.ts`.
168
+
169
+ ### 3.4 Product wordmark + brand registry
170
+
171
+ The product logo ("**Exxat** *One*" / "**Exxat** *Prism*") is **generated from a `ProductBrandConfig`** in `lib/product-brand.ts`, not from hand-built SVG paths. The suffix word renders as real **Ivy Presto** italic text (`var(--font-heading)`, Adobe Fonts kit `wuk5wqn` preloaded in `app/layout.tsx`); the circular mark is the same Exxat "E" geometry, recolored from the brand's gradient.
172
+
173
+ **To add a new product:**
174
+
175
+ ```ts
176
+ // lib/my-product-brand.ts
177
+ import { defineProductBrand, registerProductBrand } from "@/lib/product-brand"
178
+
179
+ export const EXXAT_PULSE = defineProductBrand({
180
+ id: "exxat-pulse",
181
+ prefix: "Exxat", // optional, defaults to "Exxat"
182
+ suffix: "Pulse", // rendered in Ivy Presto italic
183
+ brandColor: "#00A8E8", // any CSS color — drives suffix text + mark fill
184
+ markGradient: ["#0083C7", "#3FC6FF"], // optional 2-stop linear gradient on the mark
185
+ markShadow: "#006FAA", // optional inner shadow behind the "E" cut-out
186
+ })
187
+
188
+ registerProductBrand(EXXAT_PULSE)
189
+ ```
190
+
191
+ Then render anywhere via either the **generic** primitives or the **Exxat** convenience wrappers:
192
+
193
+ ```tsx
194
+ import { ProductWordmark, ProductMark, ProductLogo } from "@/components/product-wordmark"
195
+ import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo"
196
+
197
+ // Generic — works for any registered brand
198
+ <ProductLogo config={EXXAT_PULSE} className="h-7" variant="mutedSuffix" />
199
+ <ProductMark config={EXXAT_PULSE} className="size-7" />
200
+ <ProductWordmark config={EXXAT_PULSE} className="h-7" />
201
+
202
+ // Existing Exxat call-sites unchanged
203
+ <ExxatProductLogo product="exxat-one" variant="mutedSuffix" className="h-7" />
204
+ <ExxatProductMark product="exxat-prism" className="size-7" />
205
+ ```
206
+
207
+ **Visual contract (so new brands look like real logos, not styled text):**
208
+
209
+ 1. **Prefix** uses Inter `font-extrabold` (800) `tracking-tight` in deep slate (`#273441`) / soft grey on dark (`#A8B2BA`). Always.
210
+ 2. **Suffix** — **official Exxat brand spec from Figma** — `var(--font-heading)` (IvyPresto Text), **upright `font-semibold` (600)** with `tracking-[-0.03em]` (Figma "letter spacing -3 %"), tinted with `brandColor`. **Not italic**, **not Bold/ExtraBold** — IvyPresto's Bodoni-lineage SemiBold already has the thick verticals that read as a logo, and pushing to 700/800 makes the letterforms visually heavier than the brand asset (`ExxatOne_WordmarkLogo_WithMinClearSpace.png`). Avoid `medium` (500) / `regular` (400) — those read as inline text, not as a wordmark.
211
+ 3. **Size relationship** — the `ExxatProductLogo` outer span pins `text-base leading-none` (16 px inherited) so the wordmark's `text-[1.78em]` resolves to ~28 px font / ~20 px cap-height regardless of host surface (sidebar `text-sm`, dropdown `text-base`, etc.). The mark renders at **`h-full`** of the outer height — the original hand-built `viewBox="0 0 766 164"` already bakes breathing room around the circle artwork, so giving the mark the full outer height reproduces the brand asset's 1.56:1 mark-to-cap ratio (caps ≈ 20 px against a 32 px mark at `h-8`). Shrinking the mark (e.g. `h-[88%]`) makes it visually smaller than the wordmark span and inverts the intended hierarchy. Use **`h-8`** (32 px) as the default sidebar / dropdown / switcher height; `h-7` is too tight for the new wordmark scale and produces a sub-30 px mark.
212
+ 4. **Cap-to-mark centring** — the wordmark adds `translate-y-[0.09em]` to compensate for the cap-midpoint vs em-box-midpoint offset (Inter / Ivy Presto put cap glyphs in the upper portion of the line box). Without this, `items-center` aligns the *spans* but not the *visible cap* and mark — the wordmark visually rides ~3 px above the mark. Keep the translate when forking.
213
+ 5. **`variant="mutedSuffix"`** (sidebar / switcher) keeps the brand color in **light** mode and only tints to `--muted-foreground` in **dark** mode. Don't mute the suffix in light mode — it loses brand recognition.
214
+ 6. **Mark** stays the canonical Exxat "E" geometry so existing pixel-aligned layouts (sidebar header avatar slot, switcher dropdown rows) keep working when you swap brands; only colors change.
215
+ 7. **Host span** uses `overflow-visible` because the wordmark's `1.78em` line-box can overshoot the parent `h-X` by ~1 px — let it render, don't clip.
216
+ 8. **Don't pass `object-*` classes** to the logo `className` — `ExxatProductLogo` is a `<span>`, not a replaced element, so `object-contain` / `object-left` are silently dropped. Use width / max-width constraints instead.
217
+ 9. **`ProductMark` has no size default** — callers MUST set explicit dimensions (`size-7`, `h-full w-auto`, etc.). A `size-*` default would silently lose against a downstream `h-full / w-auto` whenever `tailwind-merge` failed to recognise the shorthand-to-pair equivalence, and the mark would render at the default size (28 px) instead of the parent height (32 px in h-8). `ExxatProductLogo` passes `h-full w-auto`; the icon-rail / collapsed-sidebar usages pass `size-7`. Keep the explicit size.
218
+
219
+ **Where to extend the registry:** brand configs live alongside `lib/product-brand.ts` for the two built-ins. Co-locate new product configs near their feature (e.g. `app/(app)/<product>/_lib/brand.ts`) and call `registerProductBrand` at module import time so `ProductSwitcher` / `getProductBrand(id)` resolve it without ordering issues.
220
+
221
+ **MUST NOT:** add new hand-traced SVG paths for an additional product. Author a `ProductBrandConfig` instead.
222
+
223
+ **Reference:** `lib/product-brand.ts`, `components/product-wordmark.tsx`, `components/exxat-product-logo.tsx`, `components/product-switcher.tsx`.
224
+
225
+ ### 3.5 Appearance preview tiles (Theme / Contrast)
226
+
227
+ `components/settings-appearance-card.tsx` renders the Theme (Light / Dark / System) and Contrast (Normal / High / Windows / System) pickers using a shared **`ChromeIllustration`** SVG helper — a polished mini browser window (Mac-style traffic lights, sidebar with brand-tinted mark + 5 nav rows, header bar with search pill + avatar, KPI card + mini bar-chart card + list rows).
228
+
229
+ Two knobs to make new variants without forking the geometry:
230
+
231
+ - **`tokens: ChromeTokens`** — palette per labeled mode. Override individual fills via `{...CHROME_LIGHT, shellStroke: "..."}`. Don't reach for `var(--background)` — preview tiles must show their target mode regardless of the active theme so users can compare before committing.
232
+ - **`strokeBoost: number`** — multiplier applied to every border weight. `1.8` is the high-contrast value used by both **High** and **Windows** tiles.
233
+
234
+ For "System" variants, use the **`SplitSystemSvg`** helper. It renders `ChromeIllustration` twice in the **same** 96 × 56 viewBox, each clipped to a triangular half (top-left triangle = "light", bottom-right triangle = "dark") via SVG `<clipPath>` polygons. The result is **one window with a diagonal theme split** — the macOS / iOS "Auto" pattern — not two adjacent windows.
235
+
236
+ ```tsx
237
+ <SplitSystemSvg
238
+ light={{ tokens: CHROME_LIGHT, sidebar: split.light, sidebarMark: split.markLight }}
239
+ dark={{ tokens: CHROME_DARK, sidebar: split.dark, sidebarMark: split.markDark }}
240
+ />
241
+ ```
242
+
243
+ **MUST NOT** invent ad-hoc rect-stacks for new appearance options, or render two side-by-side mini-windows for a single "System" tile — extend `ChromeIllustration` or compose `SplitSystemSvg`.
244
+
245
+ **Reference:** `components/settings-appearance-card.tsx` (`ChromeIllustration`, `SplitSystemSvg`, `CHROME_LIGHT`, `CHROME_DARK`, `SPLIT_SIDEBAR`).
246
+
102
247
  ---
103
248
 
104
249
  ## 4. Primary Hub Pages — Mandatory Pattern
@@ -406,7 +551,7 @@ import { DropdownMenuItem, Shortcut } from "@/components/ui/dropdown-menu"
406
551
  | Duplicate | ⌘/Ctrl + D |
407
552
  | Review / Info | ⌘/Ctrl + I |
408
553
  | Remove / Delete item | ⌘/Ctrl + ⌫ |
409
- | Add view (1..n) | ⌘/Ctrl + ⇧/Shift + 1..9 |
554
+ | Add view (1..n) | **1..9** (plain digit; `dataListViewAddShortcut`) |
410
555
  | **Submit a workflow** (Create, Save, Export, Apply) | **Enter** ⏎ — scoped to the open form/drawer/dialog |
411
556
  | **Cancel / dismiss** a workflow | **Esc** (Radix Dialog/Sheet/AlertDialog already bind this) |
412
557
  | **Advance a multi-step wizard** | ⌘/Ctrl + Enter (plain Enter must not submit mid-flow) |
@@ -0,0 +1,142 @@
1
+ # WCAG 2.1 AA — Full Accessibility Checklist
2
+
3
+ This is the single source of truth for all accessibility requirements in Exxat DS. Every component, edit, or feature must pass ALL applicable sections before it is considered done.
4
+
5
+ ---
6
+
7
+ ## 1. Interactive Elements
8
+
9
+ - Every `<button>` has visible text OR `aria-label`
10
+ - Icon-only buttons: `aria-label` + wrap with `<Tip>` (from `@/components/ui/tip`) — no exceptions
11
+ - Stateful buttons describe current state: `aria-label="Sort ascending — click to sort descending"`
12
+ - Links describe destination (no "click here"); new-window links: "(opens in new tab)"
13
+ - No nested interactive elements (button inside button, anchor inside button)
14
+ - Custom clickable `<div>`/`<span>`: `role="button"` + `tabIndex={0}` + `onKeyDown` (Enter/Space)
15
+ - Disabled: use native `disabled` or `aria-disabled="true"`; keep discoverable by screen readers
16
+
17
+ ## 2. Keyboard
18
+
19
+ - ALL interactions reachable by keyboard alone — zero mouse-only features
20
+ - Tab order follows visual reading order
21
+ - Skip link → `#main-content` (verify after layout changes)
22
+ - `Enter`/`Space` activate buttons; `Escape` closes modals/popovers; `Arrow keys` in composite widgets
23
+ - `focus-visible:` ring on ALL interactive elements (≥ 2px, 3:1 contrast against adjacent colors)
24
+ - Hidden elements (opacity-0): `group-focus-within:opacity-100` for keyboard visibility
25
+ - Modal: focus trapped inside, returns to trigger on close
26
+ - Multi-step forms: move focus to step heading on advance (`tabIndex={-1}` + `.focus()`)
27
+ - Popover/Dropdown: `initialFocus` on first interactive child
28
+
29
+ ## 3. Forms & Inputs
30
+
31
+ - Every `<input>`/`<textarea>`/`<select>` has `<label>` (visible or `sr-only`) OR `aria-label`
32
+ - Labels use `htmlFor` matching input `id`; placeholder is NOT a label
33
+ - Errors: `aria-describedby` → error element + `aria-invalid="true"` + visible descriptive text
34
+ - Required fields: `aria-required="true"` or HTML `required`
35
+ - Grouped controls: `<fieldset>` + `<legend>` or `role="group"` + `aria-labelledby`
36
+ - Dates: ALWAYS Calendar + Popover picker; format MM/DD/YYYY; `initialFocus` on Calendar open
37
+ - Toggle/Switch: `role="switch"` + `aria-checked` + `<label htmlFor>`
38
+
39
+ ## 4. Semantic Structure
40
+
41
+ - One `<main>` per page with `id="main-content"` + `tabIndex={-1}` (required for skip link)
42
+ - Heading hierarchy: one `<h1>` (via `PageHeader`), logical `<h2>` → `<h3>` (no skipping)
43
+ - `SiteHeader` title in the breadcrumb bar is NOT an `<h1>`
44
+ - Multiple `<nav>` elements: each must have `aria-label`
45
+ - Modal/Sheet/Dialog: `DialogTitle`/`SheetTitle`/`DrawerTitle` ALWAYS present — use `sr-only` if visually hidden
46
+ - `<aside>` panels: `aria-label` (e.g. "Ask Leo assistant", "Rotation navigation")
47
+ - Data tables: `<table>` + `<thead>` + `<th scope="col">`; sortable columns: `aria-sort` on `<th>`
48
+ - Listbox: `role="listbox"` + `role="option"` + `aria-selected`
49
+
50
+ ## 5. Tooltips
51
+
52
+ - Use `<Tip>` from `@/components/ui/tip` — NEVER the HTML `title` attribute
53
+ - Open on BOTH hover AND keyboard focus
54
+ - Every icon-only button gets `<Tip>` — no exceptions
55
+ - Tooltip content is supplementary; never the sole source of critical information
56
+
57
+ ## 6. Color & Contrast
58
+
59
+ - **Normal text**: 4.5:1 ratio
60
+ - **Large text** (≥18px regular / ≥14px bold): 3:1
61
+ - **UI components** (borders, focus rings): 3:1
62
+ - Status conveyed NEVER by color alone — always include text label or icon alongside
63
+ - Error states: red + icon + descriptive text (not just red color)
64
+ - Decorative icons: `aria-hidden="true"`
65
+ - All ratios apply in BOTH light AND dark themes
66
+ - Hover states visibly distinct in dark mode (brand-tinted `--accent`, not grey)
67
+ - Test against all themes: Lavender × Prism × light × dark × high-contrast
68
+
69
+ ## 7. Dynamic Content
70
+
71
+ - Count changes (filter results, badge updates): `aria-live="polite"` on the element
72
+ - Toast/snackbar notifications: `role="status"` or `aria-live="polite"`
73
+ - Loading states: `aria-busy="true"` on the loading container
74
+ - Progress indicators: `role="progressbar"` + `aria-valuenow/min/max`
75
+
76
+ ## 8. Images & Media
77
+
78
+ - Informative images: descriptive `alt` text
79
+ - Decorative images: `alt=""` or `aria-hidden="true"`
80
+ - SVG icons accompanying text: `aria-hidden="true"`
81
+ - Standalone SVG (no adjacent text): `role="img"` + `aria-label`
82
+
83
+ ## 9. ARIA Roles — Critical Rules
84
+
85
+ ### Tabs
86
+ - `role="tablist"` → only `role="tab"` (or equivalent tab semantics) as direct children
87
+ - **Never** put `role="button"`, menus (`aria-haspopup`), remove buttons, or other controls **inside** the `tablist` container
88
+ - Tab panels: `role="tabpanel"` + `aria-labelledby`
89
+
90
+ ### View Switchers (tabs + per-tab settings + remove)
91
+ - These are composite toolbar widgets — use `role="toolbar"` + `aria-label`
92
+ - Use `aria-pressed` on toggle-style controls within the toolbar
93
+ - Do NOT misuse `tablist`/`tab` for mixed-control toolbars
94
+
95
+ ### Touch Targets (WCAG 2.2 — 2.5.8)
96
+ - Interactive controls: minimum **24×24 CSS pixels**, OR 24px spacing so hit areas don't overlap
97
+ - Icon-only buttons: `size-6` or `min-h-6 min-w-6` — **never** `size-4` as the sole target
98
+
99
+ ## 10. Component-Specific Requirements
100
+
101
+ | Component | Requirements |
102
+ |-----------|-------------|
103
+ | Sidebar nav | `aria-current="page"` on active link; badges include value in `aria-label`; collapsed state → tooltip shows label |
104
+ | DataTable header column menu | `group-focus-within/th:opacity-100`; wrap trigger with `<Tip label="Column options">` |
105
+ | Sort button | `<Tip label="Sort by {col}">` + `aria-sort` attribute on `<th>` |
106
+ | Selection checkbox | `aria-label="Select {row name}"` |
107
+ | Tabs | `role="tablist"` / `role="tab"` / `role="tabpanel"` + `aria-selected` + arrow key navigation |
108
+ | Dropdown | `aria-haspopup` + `aria-expanded` (Radix handles automatically) |
109
+ | Filter pill | Keyboard-navigable; Escape closes the popover |
110
+ | Modal/Dialog | Focus trap + Escape closes + `aria-hidden` on background (Radix handles automatically) |
111
+ | Sheet/Drawer | `SheetTitle` required; floating style defined in component reuse rules |
112
+ | Ask Leo sidebar | `<aside aria-label="Ask Leo assistant">` |
113
+
114
+ ## 11. Pre-Completion Testing Protocol
115
+
116
+ Run for EVERY task before marking done:
117
+
118
+ 1. **Keyboard:** Tab through all changed elements — can you reach and activate every control without a mouse?
119
+ 2. **Focus ring:** Visible on every interactive element after tabbing to it?
120
+ 3. **Tooltips:** Every icon-only button has `<Tip>`?
121
+ 4. **Labels:** Every input has a label? Every button has text or `aria-label`?
122
+ 5. **Color contrast:** Text ≥ 4.5:1, UI ≥ 3:1? (Check with browser devtools or axe)
123
+ 6. **Dark mode:** Repeat contrast check in dark theme
124
+ 7. **200% zoom:** Layout usable at 200% browser zoom?
125
+ 8. **axe audit:** Run axe (DevTools extension) on any page where you touched views toolbar, tabs, or primary list surfaces
126
+
127
+ ## Quick Reference Card
128
+
129
+ ```
130
+ Icon button → aria-label + <Tip>
131
+ Toggle/Switch → role="switch" + aria-checked
132
+ Dropdown → aria-haspopup + aria-expanded (Radix auto)
133
+ Tab → role="tab" + aria-selected
134
+ Sort column → aria-sort on <th>
135
+ Live count → aria-live="polite"
136
+ Error message → id + aria-describedby on input
137
+ Date input → Calendar + Popover + initialFocus
138
+ Modal → DialogTitle (required) + focus trap
139
+ Progress → role="progressbar" + aria-valuenow/min/max
140
+ Tablist → only tab-role children (no buttons/menus inside)
141
+ View switcher → role="toolbar" + aria-label + aria-pressed
142
+ ```
@@ -0,0 +1,169 @@
1
+ # Coach Marks — Implementation Guide
2
+
3
+ > **Use coach marks for onboarding flows and feature discovery.** Every tour is defined once, targets elements by CSS selector, and is managed centrally from the Settings page.
4
+
5
+ ---
6
+
7
+ ## Architecture
8
+
9
+ | Component | Location | Purpose |
10
+ |-----------|----------|---------|
11
+ | `CoachMark` | `@/components/ui/coach-mark` | Selector-targeted popover with spotlight overlay, brand-colored background |
12
+ | `useCoachMark` | `@/hooks/use-coach-mark` | Flow state manager — step navigation, localStorage persistence, element targeting |
13
+ | `CoachMarkStep` | `@/hooks/use-coach-mark` (type) | Step definition — target selector, side, align, title, description, optional image |
14
+ | Coach mark registry | `@/lib/coach-mark-registry` | Central definition of all flows for the Settings page |
15
+ | Settings page | `@/components/settings-client` + `app/(app)/settings/page.tsx` | UI to view, reset, and preview all coach mark flows |
16
+
17
+ ---
18
+
19
+ ## How It Works
20
+
21
+ 1. **Selector-based targeting** — each step has a `target` CSS selector (e.g. `[aria-label='Properties']`). The coach mark finds the element, scrolls it into view, and positions a popover next to it.
22
+ 2. **Spotlight overlay** — a semi-transparent backdrop with an SVG mask cutout highlights the target element with a ring.
23
+ 3. **Brand background** — the popover uses `bg-brand-deep text-white` for high visibility. Buttons are white/inverted.
24
+ 4. **localStorage persistence** — once completed or skipped, a flow is marked as dismissed and won't show again unless reset from Settings.
25
+ 5. **Per-step positioning** — each step can specify its own `side` and `align` for optimal placement relative to the target.
26
+
27
+ ---
28
+
29
+ ## Adding a New Coach Mark Flow
30
+
31
+ ### Step 1 — Define the steps
32
+
33
+ ```tsx
34
+ import type { CoachMarkStep } from "@/hooks/use-coach-mark"
35
+
36
+ const MY_TOUR_STEPS: CoachMarkStep[] = [
37
+ {
38
+ id: "step-1",
39
+ target: "[aria-label='My Widget']", // CSS selector for the target element
40
+ side: "bottom", // popover side: top | bottom | left | right
41
+ align: "start", // popover alignment: start | center | end
42
+ title: "Meet My Widget",
43
+ description: "This widget helps you do X. Click to explore.",
44
+ },
45
+ {
46
+ id: "step-2",
47
+ target: "button[aria-label='Settings']",
48
+ side: "left",
49
+ align: "center",
50
+ title: "Customise Settings",
51
+ description: "Open settings to configure Y and Z.",
52
+ image: "https://example.com/screenshot.jpg", // optional hero image
53
+ imageAlt: "Settings panel screenshot", // required when image is provided
54
+ },
55
+ ]
56
+ ```
57
+
58
+ ### Step 2 — Wire the hook and component
59
+
60
+ ```tsx
61
+ import { CoachMark } from "@/components/ui/coach-mark"
62
+ import { useCoachMark } from "@/hooks/use-coach-mark"
63
+
64
+ function MyPageClient() {
65
+ const tour = useCoachMark({
66
+ flowId: "my-feature-tour", // unique ID — used as localStorage key
67
+ steps: MY_TOUR_STEPS,
68
+ delay: 1200, // ms before first appearance
69
+ })
70
+
71
+ return (
72
+ <>
73
+ <CoachMark state={tour} />
74
+ {/* rest of your page */}
75
+ </>
76
+ )
77
+ }
78
+ ```
79
+
80
+ **Key points:**
81
+ - `CoachMark` renders via portal — place it anywhere, it does NOT wrap children
82
+ - The component handles element lookup, scrolling, spotlight overlay, and positioning
83
+ - On flow completion, `localStorage` marks the flow as dismissed
84
+
85
+ ### Step 3 — Register in the coach mark registry
86
+
87
+ Add your flow to `lib/coach-mark-registry.ts`:
88
+
89
+ ```ts
90
+ {
91
+ id: "my-feature-tour",
92
+ name: "My Feature Tour",
93
+ description: "Introduces the main controls and settings for My Feature.",
94
+ page: "My Feature",
95
+ pageUrl: "/my-feature",
96
+ stepCount: 2,
97
+ }
98
+ ```
99
+
100
+ This makes it appear in the Settings page where users can reset or preview it.
101
+
102
+ ---
103
+
104
+ ## Variants
105
+
106
+ | Variant | How to use |
107
+ |---------|-----------|
108
+ | **Single step** | Pass a 1-item array to `steps` — no step indicator shown, button says "Got it" |
109
+ | **Multi-step flow** | Pass 2+ items — shows step dots, Skip, Back, Next buttons |
110
+ | **With image** | Set `image` + `imageAlt` on the step — hero image appears above the content |
111
+ | **Without image** | Omit `image` — text-only popover |
112
+
113
+ ---
114
+
115
+ ## Target Selector Best Practices
116
+
117
+ Use stable, semantic selectors that survive refactors:
118
+
119
+ | Prefer | Avoid |
120
+ |--------|-------|
121
+ | `[aria-label='Properties']` | `.css-class-name` |
122
+ | `[role='toolbar'][aria-label='Views']` | `div:nth-child(3)` |
123
+ | `button[aria-label='Search']` | `#auto-generated-id` |
124
+ | `h1` | `.page-header > div > h1` |
125
+
126
+ If no stable selector exists, add a `data-coach-mark="step-name"` attribute to the target element.
127
+
128
+ ---
129
+
130
+ ## Existing Flows
131
+
132
+ | Flow ID | Page | Steps | What it covers |
133
+ |---------|------|-------|---------------|
134
+ | `dashboard-tour` | Dashboard | 4 | Welcome, Key Metrics, AI Insights, Ask Leo |
135
+ | `placements-views-tour` | Placements | 6 | View tabs, view settings, add view, search, filter, Properties |
136
+
137
+ ---
138
+
139
+ ## Settings Page
140
+
141
+ The Settings page at `/settings` (`components/settings-client.tsx`) provides:
142
+
143
+ - **List of all registered flows** from `lib/coach-mark-registry.ts`
144
+ - **Status** — Completed vs Active per flow
145
+ - **Reset** — clears localStorage so the tour replays on next visit
146
+ - **Preview** — resets the flow and navigates to its page
147
+ - **Reset all** — clears all coach mark dismissals
148
+
149
+ ---
150
+
151
+ ## Utilities (exported from `use-coach-mark`)
152
+
153
+ | Function | Purpose |
154
+ |----------|---------|
155
+ | `getAllCoachMarkKeys()` | List all dismissed coach mark flow IDs |
156
+ | `resetCoachMarkFlow(flowId)` | Clear dismissal for one flow |
157
+ | `resetAllCoachMarks()` | Clear all dismissals |
158
+
159
+ ---
160
+
161
+ ## Rules
162
+
163
+ 1. **Always register new flows** in `lib/coach-mark-registry.ts` so they appear in Settings
164
+ 2. **Use CSS selectors based on ARIA attributes** — they're stable and semantic
165
+ 3. **Brand background is mandatory** — coach marks use `bg-brand-deep text-white`, not `bg-popover`
166
+ 4. **No wrapping children** — `CoachMark` targets elements by selector, never wraps them
167
+ 5. **Keep flows short** — 3–6 steps per flow is ideal; split longer tours into separate flows
168
+ 6. **Add `data-coach-mark` attributes** if no stable selector exists on the target element
169
+ 7. **Set appropriate `delay`** — 800–1200ms gives the page time to render before the first step appears