@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.
- package/CHANGELOG.md +23 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
- package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/src/globals.css +21 -2
- package/src/theme.css +4 -2
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +23 -18
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +27 -7
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +136 -2
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +141 -59
- package/template/components/ask-leo-sidebar.tsx +1 -4
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/finder-panel-view.tsx +2 -2
- package/template/components/data-views/index.ts +26 -3
- package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/data-views/outline-tree-menu.tsx +157 -0
- package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +173 -379
- package/template/components/folder-details-shell.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +88 -80
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +116 -51
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +21 -11
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -11
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +130 -70
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-page-header.tsx +18 -2
- package/template/components/question-bank-secondary-nav.tsx +12 -228
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +24 -4
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +56 -32
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
- package/template/components/templates/list-page.tsx +1 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/collaboration-access-pattern.md +2 -0
- package/template/docs/question-bank-hub-header-pattern.md +25 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/mock/navigation.tsx +30 -1
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +70 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +6 -6
- package/template/stores/app-store.ts +46 -1
- package/template/components/command-menu-01.tsx +0 -133
- package/template/components/command-menu-02.tsx +0 -386
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,29 @@ After the user bumps `@exxatdesignux/ui`, do this in order:
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## [0.2.17] - 2026-05-18
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- **Consumer extras**: Cursor skills and pattern docs refreshed (`vendor:consumer-extras`), including **mono IDs** skill/rule.
|
|
24
|
+
|
|
25
|
+
### Chore (monorepo)
|
|
26
|
+
|
|
27
|
+
- Package **`version`** set to **0.2.17** for npm publish (tag **`ui-v0.2.17`**).
|
|
28
|
+
|
|
29
|
+
## [0.2.16] - 2026-05-15
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **Tokens**: `globals.css` / `theme.css` refinements and starter **`template/`** parity with the web app (layout, Question bank hub chrome, navigation).
|
|
34
|
+
- **Consumer extras**: Cursor skills + pattern docs refreshed for collaboration / Question bank hub header.
|
|
35
|
+
|
|
36
|
+
### Chore (monorepo)
|
|
37
|
+
|
|
38
|
+
- **Dependabot**: version updates for `apps/web`, `packages/ui`, workspace root, and grouped Next.js + GitHub Actions bumps (see repo `.github/dependabot.yml`).
|
|
39
|
+
- **Web app**: Next.js **16.2.6** (security patches), primary shell scroll fixes, `SiteHeader` chrome alignment.
|
|
40
|
+
|
|
18
41
|
## [0.2.15] - 2026-05-13
|
|
19
42
|
|
|
20
43
|
### Fixed
|
|
@@ -8,7 +8,8 @@ user-invocable: true
|
|
|
8
8
|
|
|
9
9
|
**Handbook:** `apps/web/AGENTS.md` §4.7
|
|
10
10
|
**Narrative:** `apps/web/docs/collaboration-access-pattern.md`
|
|
11
|
-
**Cursor rule:** `.cursor/rules/exxat-collaboration-access.mdc`
|
|
11
|
+
**Cursor rule:** `.cursor/rules/exxat-collaboration-access.mdc`
|
|
12
|
+
**Related (Question bank folder scope + ⋯ Customize folder):** `.cursor/rules/exxat-question-bank-hub-header.mdc` · `docs/question-bank-hub-header-pattern.md`
|
|
12
13
|
|
|
13
14
|
## Wiring checklist
|
|
14
15
|
|
|
@@ -19,6 +20,7 @@ user-invocable: true
|
|
|
19
20
|
5. **Access maps** — `lib/collaborator-access.ts` for Owner / Editor / Commenter / Viewer, invite options, and **`COLLABORATION_HEADER_ADD_LABEL`**.
|
|
20
21
|
6. **Header** — empty roster → outline **Add collaborator**; non-empty → face rail; both open the invite sheet.
|
|
21
22
|
7. **Invite sheet** — `InviteCollaboratorsDrawer`: export-style **`Sheet`**, combined email + access menu, grouped roster (name → email → role tags → access badge).
|
|
23
|
+
8. **Question bank — folder URL scope** — When **`?scope=folder`**, **`QuestionBankPageHeader`** **⋯** also lists **Customize folder**; **`QuestionBankNewFolderSheet`** on **`QuestionBankClient`** — **`.cursor/rules/exxat-question-bank-hub-header.mdc`**, **`docs/question-bank-hub-header-pattern.md`**.
|
|
22
24
|
|
|
23
25
|
## MUST NOT
|
|
24
26
|
|
|
@@ -21,7 +21,8 @@ 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
|
+
- **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.
|
|
25
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.
|
|
26
27
|
|
|
27
28
|
---
|
|
@@ -88,16 +89,161 @@ To add a primary nav item, append to `NAV_PRIMARY`:
|
|
|
88
89
|
|
|
89
90
|
| Concern | Pattern |
|
|
90
91
|
|--------|---------|
|
|
91
|
-
| **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. |
|
|
92
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. |
|
|
93
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**. |
|
|
94
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. |
|
|
95
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. |
|
|
96
|
-
| **Nav items with children** |
|
|
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). |
|
|
97
98
|
| **Profile (mock)** | **`stockPortraitUrl()`** from **`lib/stock-portrait.ts`**; **`AvatarImage`** **`referrerPolicy="no-referrer"`** for external URLs. |
|
|
98
99
|
|
|
99
100
|
**Reference:** `components/app-sidebar.tsx`, `components/nav-user.tsx`, `components/product-switcher.tsx`.
|
|
100
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
|
+
|
|
101
247
|
---
|
|
102
248
|
|
|
103
249
|
## 4. Primary Hub Pages — Mandatory Pattern
|
|
@@ -255,6 +401,8 @@ Use `PageHeader` from `@/components/page-header` for the content-area header (be
|
|
|
255
401
|
|
|
256
402
|
When a hub is **shared**, use **`PageHeader` `variant="collaboration"`**: **empty roster** → outline **Add collaborator**; **non-empty** → face rail (faces / **`+N`** open the invite sheet). **Invite people** also lives under the entity header **⋯ More** and opens **`InviteCollaboratorsDrawer`** via **`CollaborationAccessFlow`** when possible. Library access (Owner / Editor / Commenter / Viewer) comes from **`lib/collaborator-access.ts`**; directory tags (Faculty, Program coordinator, Director) use **`PageHeaderCollaborator.roles`**.
|
|
257
403
|
|
|
404
|
+
**Question bank library — folder URL scope:** When **`?scope=folder&folderId=`** applies, **⋯ More** must also offer **Customize folder** (**`QuestionBankPageHeader`** **`onCustomizeFolder`**) and the **`QuestionBankNewFolderSheet`** must be mounted on **`QuestionBankClient`** so it works on every **`ListPageTemplate`** view tab. **`.cursor/rules/exxat-question-bank-hub-header.mdc`** · **`docs/question-bank-hub-header-pattern.md`** (app: **`apps/web/docs/...`**).
|
|
405
|
+
|
|
258
406
|
**Handbook:** `apps/web/AGENTS.md` §4.7 · **Doc:** `docs/collaboration-access-pattern.md` · **Skill:** `.cursor/skills/exxat-collaboration-access/SKILL.md` · **Reference:** Question bank header + client.
|
|
259
407
|
|
|
260
408
|
---
|
|
@@ -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
|