@lovett/ui 0.0.9 → 0.0.11

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 (55) hide show
  1. package/dist/index.d.ts +823 -135
  2. package/dist/index.js +2048 -358
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +44 -2
  5. package/dist/theme-v2.css +228 -0
  6. package/dist/tokens.css +123 -8
  7. package/package.json +1 -1
  8. package/src/__tests__/anchor.test.tsx +422 -0
  9. package/src/__tests__/combobox.test.tsx +677 -0
  10. package/src/__tests__/dropdown-menu.test.tsx +418 -0
  11. package/src/__tests__/helpers/geometry.ts +58 -0
  12. package/src/__tests__/layer-stack.test.tsx +228 -0
  13. package/src/__tests__/modal.test.tsx +180 -6
  14. package/src/__tests__/popover.test.tsx +460 -0
  15. package/src/__tests__/select.test.tsx +543 -0
  16. package/src/__tests__/tooltip.test.tsx +355 -0
  17. package/src/calculator-shell-v2.tsx +19 -39
  18. package/src/code-block.tsx +15 -26
  19. package/src/combobox.tsx +796 -0
  20. package/src/dropdown-menu.tsx +142 -152
  21. package/src/icons/brand.tsx +81 -2
  22. package/src/index.ts +111 -0
  23. package/src/lib/anchor.ts +427 -0
  24. package/src/lib/focus.ts +32 -0
  25. package/src/lib/layer-stack.ts +188 -0
  26. package/src/lib/refs.ts +31 -0
  27. package/src/metric-card.tsx +57 -22
  28. package/src/modal.tsx +149 -9
  29. package/src/page-shell.tsx +91 -2
  30. package/src/popover.tsx +407 -0
  31. package/src/segmented-pill.tsx +33 -10
  32. package/src/select.tsx +646 -0
  33. package/src/stat-row.tsx +108 -70
  34. package/src/styles.css +44 -2
  35. package/src/theme-v2.css +7 -245
  36. package/src/tokens.css +123 -8
  37. package/src/tooltip.tsx +297 -0
  38. package/src/react-syntax-highlighter-prism.d.ts +0 -34
  39. package/src/v2/README.md +0 -208
  40. package/src/v2/__demo__/showcase.tsx +0 -1045
  41. package/src/v2/action.tsx +0 -91
  42. package/src/v2/callout.tsx +0 -76
  43. package/src/v2/document-section.tsx +0 -82
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +0 -113
  46. package/src/v2/icons.tsx +0 -165
  47. package/src/v2/index.ts +0 -147
  48. package/src/v2/layout.tsx +0 -293
  49. package/src/v2/progress-track.tsx +0 -89
  50. package/src/v2/stat-tile.tsx +0 -129
  51. package/src/v2/states.tsx +0 -271
  52. package/src/v2/status-pill.tsx +0 -74
  53. package/src/v2/theme.css +0 -1861
  54. package/src/v2/timeline.tsx +0 -81
  55. package/src/v2/tokens.ts +0 -228
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Dismiss-layer stack — the ONE "topmost layer wins" registry shared by
3
+ * Modal, DropdownMenu and Popover (and, next, ContextMenu / Select /
4
+ * Combobox / Tooltip / CommandPalette).
5
+ *
6
+ * Extracted from modal.tsx's module-level `openPanels` array so every
7
+ * dismissable surface participates in the same order. Before this, a
8
+ * DropdownMenu opened from inside a Modal answered Escape at the same time
9
+ * the Modal did — two document listeners, one keypress, both closed. Now a
10
+ * single document `keydown` listener dispatches Escape to the top of the
11
+ * stack ONLY, then stops propagation so window-level handlers (the chat
12
+ * conversation drawer's Escape, lens hotkey maps) do not treat a consumed
13
+ * Escape as theirs — which is what DropdownMenu's own listener already did.
14
+ *
15
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
16
+ * every picker, sheet and palette needs one dismiss order). First consumer
17
+ * is the workspace app: Modal (51 files), DropdownMenu (137 call sites) and
18
+ * Popover all register here.
19
+ *
20
+ * Two kinds. A `modal` layer also owns a focus trap, and that trap must not
21
+ * switch off because a `popover` opened above it — so Modal asks
22
+ * `isTopOfKind()` for Tab handling while Escape always goes to `isTop()`.
23
+ * Order is push order, which under React is effect order: a surface that
24
+ * mounts or opens later sits above one that opened earlier.
25
+ *
26
+ * Usage (a dismissable surface):
27
+ *
28
+ * const layer = useLayer({
29
+ * enabled: open,
30
+ * kind: 'popover',
31
+ * elementRef: contentRef,
32
+ * onEscape: () => setOpen(false),
33
+ * })
34
+ * useOutsideClick([triggerRef, contentRef], () => setOpen(false), {
35
+ * enabled: open,
36
+ * layer, // clicks inside a layer stacked ABOVE this one are not "outside"
37
+ * })
38
+ *
39
+ * `useEscapeKey(handler, { enabled })` is the one-liner for anything that
40
+ * only needs Escape.
41
+ */
42
+
43
+ import { useEffect, useMemo, useRef, type RefObject } from 'react'
44
+
45
+ export type LayerKind = 'modal' | 'popover'
46
+
47
+ export interface LayerHandle {
48
+ /** True while no layer of ANY kind sits above this one. */
49
+ isTop(): boolean
50
+ /** True while no layer of the SAME kind sits above this one. */
51
+ isTopOfKind(): boolean
52
+ /**
53
+ * True when `node` is inside the element of a layer stacked above this
54
+ * one — a nested Select's listbox, a Combobox inside a Popover. Outside-
55
+ * click handlers treat those as inside.
56
+ */
57
+ containsInLayerAbove(node: Node): boolean
58
+ }
59
+
60
+ interface LayerRecord {
61
+ readonly kind: LayerKind
62
+ readonly element: () => HTMLElement | null
63
+ readonly onEscape: (event: KeyboardEvent) => void
64
+ }
65
+
66
+ const stack: LayerRecord[] = []
67
+ let listening = false
68
+
69
+ function onDocumentKeyDown(event: KeyboardEvent): void {
70
+ if (event.key !== 'Escape') return
71
+ const top = stack[stack.length - 1]
72
+ if (!top) return
73
+ // Consumed here. Window-level listeners (hotkey maps, drawers) must not
74
+ // see an Escape that closed a layer — same contract DropdownMenu's own
75
+ // listener had before the stack existed.
76
+ event.stopPropagation()
77
+ top.onEscape(event)
78
+ }
79
+
80
+ function pushLayer(record: LayerRecord): () => void {
81
+ stack.push(record)
82
+ if (!listening) {
83
+ document.addEventListener('keydown', onDocumentKeyDown)
84
+ listening = true
85
+ }
86
+ return () => {
87
+ const at = stack.lastIndexOf(record)
88
+ if (at !== -1) stack.splice(at, 1)
89
+ if (stack.length === 0 && listening) {
90
+ document.removeEventListener('keydown', onDocumentKeyDown)
91
+ listening = false
92
+ }
93
+ }
94
+ }
95
+
96
+ export interface UseLayerOptions {
97
+ /** Register while true; pop when it turns false or the owner unmounts. */
98
+ enabled: boolean
99
+ kind: LayerKind
100
+ /**
101
+ * The surface's root element. Read lazily, so a ref that is filled in
102
+ * after the layer registers (portal content) still resolves.
103
+ */
104
+ elementRef?: RefObject<HTMLElement | null>
105
+ /** Called when this layer is topmost and Escape is pressed. */
106
+ onEscape: (event: KeyboardEvent) => void
107
+ }
108
+
109
+ /**
110
+ * Register a dismiss layer for as long as `enabled` holds. `onEscape` is
111
+ * read through a ref, so an inline arrow does not re-register the layer
112
+ * (and does not reorder it) on every render.
113
+ */
114
+ export function useLayer(options: UseLayerOptions): LayerHandle {
115
+ const { enabled, kind, elementRef, onEscape } = options
116
+
117
+ const onEscapeRef = useRef(onEscape)
118
+ useEffect(() => {
119
+ onEscapeRef.current = onEscape
120
+ })
121
+
122
+ const recordRef = useRef<LayerRecord | null>(null)
123
+
124
+ useEffect(() => {
125
+ if (!enabled) return
126
+ const record: LayerRecord = {
127
+ kind,
128
+ element: () => elementRef?.current ?? null,
129
+ onEscape: (event) => onEscapeRef.current(event),
130
+ }
131
+ recordRef.current = record
132
+ const pop = pushLayer(record)
133
+ return () => {
134
+ pop()
135
+ if (recordRef.current === record) recordRef.current = null
136
+ }
137
+ }, [enabled, kind, elementRef])
138
+
139
+ return useMemo<LayerHandle>(
140
+ () => ({
141
+ isTop: () => {
142
+ const record = recordRef.current
143
+ return record !== null && stack[stack.length - 1] === record
144
+ },
145
+ isTopOfKind: () => {
146
+ const record = recordRef.current
147
+ if (!record) return false
148
+ for (let i = stack.length - 1; i >= 0; i--) {
149
+ const layer = stack[i]
150
+ if (layer === record) return true
151
+ if (layer?.kind === record.kind) return false
152
+ }
153
+ return false
154
+ },
155
+ containsInLayerAbove: (node) => {
156
+ const record = recordRef.current
157
+ if (!record) return false
158
+ const at = stack.indexOf(record)
159
+ if (at === -1) return false
160
+ for (let i = at + 1; i < stack.length; i++) {
161
+ if (stack[i]?.element()?.contains(node)) return true
162
+ }
163
+ return false
164
+ },
165
+ }),
166
+ [],
167
+ )
168
+ }
169
+
170
+ export interface UseEscapeKeyOptions {
171
+ /** Default true. */
172
+ enabled?: boolean
173
+ /** Default `popover`. */
174
+ kind?: LayerKind
175
+ }
176
+
177
+ /**
178
+ * Escape-only participant in the layer stack. Fires `onEscape` only while
179
+ * this is the topmost layer; a layer that opens later takes over until it
180
+ * closes.
181
+ */
182
+ export function useEscapeKey(
183
+ onEscape: (event: KeyboardEvent) => void,
184
+ options: UseEscapeKeyOptions = {},
185
+ ): LayerHandle {
186
+ const { enabled = true, kind = 'popover' } = options
187
+ return useLayer({ enabled, kind, onEscape })
188
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ref plumbing for primitives that must hand one DOM node to several
3
+ * owners — the caller's forwarded ref, the component's own ref, and a
4
+ * positioning hook's callback ref.
5
+ *
6
+ * `assignRef` writes a node into any React ref shape. `composeRefs` returns
7
+ * ONE callback ref that fans out to all of them; memoize the result
8
+ * (`useMemo` on the inputs) so React does not detach/re-attach on every
9
+ * render.
10
+ */
11
+
12
+ import type { Ref, RefCallback } from 'react'
13
+
14
+ export function assignRef<T>(ref: Ref<T> | undefined, node: T | null): void {
15
+ if (!ref) return
16
+ if (typeof ref === 'function') {
17
+ // React 19 callback refs may return a cleanup; we call with `null` on
18
+ // detach instead, which every consumer of this package already handles.
19
+ ref(node)
20
+ return
21
+ }
22
+ ref.current = node
23
+ }
24
+
25
+ export function composeRefs<T>(
26
+ ...refs: ReadonlyArray<Ref<T> | undefined>
27
+ ): RefCallback<T> {
28
+ return (node) => {
29
+ for (const ref of refs) assignRef(ref, node)
30
+ }
31
+ }
@@ -11,9 +11,17 @@
11
11
  * Token discipline: all colors via public tokens. Tone variant gives
12
12
  * the value text an accent/success/warning/destructive/info color;
13
13
  * background is always neutral surface-overlay.
14
+ *
15
+ * `copyable` (ADR-145 amendment, 2026-09-04): the card becomes a button
16
+ * that copies `copyValue` (or the string `value`) and toasts
17
+ * "Copied <label>". Same hover/focus affordance as ValueChip and the
18
+ * copyable StatRow. Inert by default so a read-only KPI never advertises
19
+ * an interaction it doesn't have.
14
20
  */
15
21
 
16
22
  import type { ReactNode } from 'react'
23
+ import { cn } from './lib/utils'
24
+ import { copyText } from './lib/clipboard'
17
25
 
18
26
  export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
19
27
 
@@ -46,6 +54,11 @@ export interface MetricCardProps {
46
54
  labelPosition?: 'top' | 'bottom'
47
55
  /** Optional className override for the outer container. */
48
56
  className?: string
57
+ /** Make the whole card a copy-to-clipboard button. */
58
+ copyable?: boolean
59
+ /** What a copyable card writes. Defaults to `value` when it is a string;
60
+ * REQUIRED when `value` is a node (there is nothing else to copy). */
61
+ copyValue?: string
49
62
  }
50
63
 
51
64
  export function MetricCard({
@@ -55,6 +68,8 @@ export function MetricCard({
55
68
  icon,
56
69
  labelPosition = 'bottom',
57
70
  className,
71
+ copyable = false,
72
+ copyValue,
58
73
  }: MetricCardProps) {
59
74
  const labelEl =
60
75
  labelPosition === 'top' ? (
@@ -89,29 +104,49 @@ export function MetricCard({
89
104
  </div>
90
105
  )
91
106
 
107
+ const body =
108
+ labelPosition === 'top' ? (
109
+ <>
110
+ {labelEl}
111
+ {valueEl}
112
+ </>
113
+ ) : (
114
+ <>
115
+ {valueEl}
116
+ {labelEl}
117
+ </>
118
+ )
119
+ const surfaceClass = className ?? 'flex flex-col gap-1 px-4 py-3 border'
120
+ const surfaceStyle = {
121
+ background: 'rgb(var(--surface-overlay-soft))',
122
+ borderColor: 'rgb(var(--border))',
123
+ borderRadius: 'var(--radius-lg)',
124
+ } as const
125
+
126
+ const text = copyValue ?? (typeof value === 'string' ? value : undefined)
127
+ if (copyable && text !== undefined) {
128
+ return (
129
+ <button
130
+ type="button"
131
+ onClick={() => void copyText(text, label)}
132
+ title={`Copy ${label}`}
133
+ aria-label={`Copy ${label}: ${text}`}
134
+ className={cn(
135
+ surfaceClass,
136
+ 'text-left cursor-pointer transition-[border-color,box-shadow]',
137
+ 'hover:border-[rgb(var(--border-strong))] hover:[box-shadow:var(--shadow-sm)]',
138
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
139
+ )}
140
+ style={surfaceStyle}
141
+ >
142
+ {body}
143
+ </button>
144
+ )
145
+ }
146
+
92
147
  return (
93
- <div
94
- className={
95
- className ??
96
- 'flex flex-col gap-1 px-4 py-3 border'
97
- }
98
- style={{
99
- background: 'rgb(var(--surface-overlay-soft))',
100
- borderColor: 'rgb(var(--border))',
101
- borderRadius: 'var(--radius-lg)',
102
- }}
103
- >
104
- {labelPosition === 'top' ? (
105
- <>
106
- {labelEl}
107
- {valueEl}
108
- </>
109
- ) : (
110
- <>
111
- {valueEl}
112
- {labelEl}
113
- </>
114
- )}
148
+ <div className={surfaceClass} style={surfaceStyle}>
149
+ {body}
115
150
  </div>
116
151
  )
117
152
  }
package/src/modal.tsx CHANGED
@@ -2,12 +2,16 @@ import {
2
2
  Children,
3
3
  isValidElement,
4
4
  useEffect,
5
+ useId,
6
+ useRef,
5
7
  type HTMLAttributes,
6
8
  type ReactElement,
7
9
  type ReactNode,
8
10
  } from 'react'
9
11
  import { X } from 'lucide-react'
10
12
  import { cn } from './lib/utils'
13
+ import { tabbablesWithin } from './lib/focus'
14
+ import { useLayer } from './lib/layer-stack'
11
15
 
12
16
  /**
13
17
  * Modal — a centered floating panel on top of a backdrop. Built on
@@ -36,6 +40,42 @@ import { cn } from './lib/utils'
36
40
  * <Button>Confirm</Button>
37
41
  * </Modal.Footer>
38
42
  * </Modal>
43
+ *
44
+ * Accessibility. The panel is a `role="dialog" aria-modal="true"` region
45
+ * named by its own `<h2>` (`aria-labelledby`), or by the `ariaLabel` prop
46
+ * when a caller renders a titleless modal. Opening moves focus to the
47
+ * panel — so a screen reader announces the dialog's name — and closing
48
+ * RESTORES focus to whatever was focused before it opened. Tab and
49
+ * Shift+Tab wrap inside the panel; Escape closes.
50
+ *
51
+ * Callers that want a specific control focused on open mark it
52
+ * `data-autofocus`; anything else and the panel itself takes focus.
53
+ *
54
+ * WHY A FOCUS TRAP AND NOT `inert` ON THE APP ROOT. Modal renders INLINE
55
+ * in the React tree — it is a descendant of `#root`, not a portal — so
56
+ * `inert` on the app root would make the modal itself inert. Portaling it
57
+ * to `document.body` to unlock that would move all 59 consuming files off
58
+ * the `.cs-frame`-scoped token layer and out of their current stacking /
59
+ * inheritance context, which is a much larger change than an a11y fix
60
+ * should make. Inerting `document.body`'s other children is also wrong:
61
+ * `DropdownMenu`, `Popover`, `TagChipInput` and `FolderTreePicker` portal
62
+ * their content THERE, so a select inside a modal would go dead. Focus trap
63
+ * it is — the keyboard hole is closed; pointer and AT virtual-cursor access
64
+ * to the background remains, which is the documented residual.
65
+ *
66
+ * Escape and dismiss order come from the shared layer stack
67
+ * (`lib/layer-stack.ts`): the topmost open surface — modal, menu or
68
+ * popover — answers Escape alone.
69
+ */
70
+
71
+ /**
72
+ * Dismiss order lives in the shared layer stack (`lib/layer-stack.ts`),
73
+ * which this file used to own as a module-level `openPanels` array. Only
74
+ * the topmost layer answers Escape, so nested modals do not both close on
75
+ * one keypress — and now a DropdownMenu or Popover opened from inside a
76
+ * modal closes on its own first, leaving the modal open. The Tab trap asks
77
+ * for the topmost MODAL (`isTopOfKind`), so a popover above it does not
78
+ * switch the trap off.
39
79
  */
40
80
 
41
81
  interface ModalProps {
@@ -43,18 +83,106 @@ interface ModalProps {
43
83
  onClose: () => void
44
84
  title: string
45
85
  children: ReactNode
46
- className?: string
86
+ className?: string | undefined
87
+ /**
88
+ * Accessible name for a modal rendered with an empty `title`. Ignored
89
+ * when `title` is non-empty — the `<h2>` names the dialog then.
90
+ */
91
+ ariaLabel?: string | undefined
47
92
  }
48
93
 
49
- function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
94
+ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: ModalProps) {
95
+ const panelRef = useRef<HTMLDivElement | null>(null)
96
+ const titleId = useId()
97
+
98
+ // Focus: move in on open, restore on close. Keyed on `isOpen` ALONE —
99
+ // adding `onClose` here would re-run it on every render for the many
100
+ // callers that pass an inline arrow, snatching focus back to the panel
101
+ // mid-keystroke.
50
102
  useEffect(() => {
51
103
  if (!isOpen) return
52
- const handleEscape = (e: KeyboardEvent) => {
53
- if (e.key === 'Escape') onClose()
104
+ const panel = panelRef.current
105
+ if (!panel) return
106
+
107
+ const previous =
108
+ document.activeElement instanceof HTMLElement &&
109
+ document.activeElement !== document.body
110
+ ? document.activeElement
111
+ : null
112
+
113
+ const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
114
+ ;(initial ?? panel).focus()
115
+
116
+ return () => {
117
+ // Restoring is the half that gets forgotten: without it, closing a
118
+ // modal drops the caret to <body> and the next Tab restarts from
119
+ // the top of the page.
120
+ if (previous && previous.isConnected) previous.focus()
54
121
  }
55
- document.addEventListener('keydown', handleEscape)
56
- return () => document.removeEventListener('keydown', handleEscape)
57
- }, [isOpen, onClose])
122
+ }, [isOpen])
123
+
124
+ // Escape: the shared stack dispatches it to the topmost layer only, and
125
+ // reads `onClose` through a ref so an inline arrow does not re-register
126
+ // (or reorder) the layer on every render.
127
+ const layer = useLayer({
128
+ enabled: isOpen,
129
+ kind: 'modal',
130
+ elementRef: panelRef,
131
+ onEscape: () => onClose(),
132
+ })
133
+
134
+ // Tab trap. One document listener; the topmost-MODAL guard keeps nested
135
+ // modals from both reacting, and keeps this trap live while a popover
136
+ // sits above it.
137
+ useEffect(() => {
138
+ if (!isOpen) return
139
+ const panel = panelRef.current
140
+ if (!panel) return
141
+
142
+ const handleKeyDown = (e: KeyboardEvent) => {
143
+ if (e.key !== 'Tab') return
144
+ if (!layer.isTopOfKind()) return
145
+
146
+ const active = document.activeElement
147
+ // Focus is outside the panel — almost always a body-level portal
148
+ // (DropdownMenu / TagChipInput / FolderTreePicker) opened FROM the
149
+ // modal. Wrapping it back in would break that menu's own keyboard
150
+ // handling, so leave the event alone.
151
+ if (!(active instanceof HTMLElement) || !panel.contains(active)) return
152
+
153
+ const tabbables = tabbablesWithin(panel)
154
+ const first = tabbables[0]
155
+ const last = tabbables[tabbables.length - 1]
156
+ if (!first || !last) {
157
+ // Nothing to land on — keep focus on the panel rather than let
158
+ // Tab walk out into the page behind the backdrop.
159
+ e.preventDefault()
160
+ panel.focus()
161
+ return
162
+ }
163
+
164
+ // The panel holds focus itself right after open. Forward Tab can
165
+ // fall through to `first` naturally; Shift+Tab would leave the
166
+ // dialog, so it wraps to `last`.
167
+ if (active === panel) {
168
+ if (e.shiftKey) {
169
+ e.preventDefault()
170
+ last.focus()
171
+ }
172
+ return
173
+ }
174
+ if (e.shiftKey && active === first) {
175
+ e.preventDefault()
176
+ last.focus()
177
+ } else if (!e.shiftKey && active === last) {
178
+ e.preventDefault()
179
+ first.focus()
180
+ }
181
+ }
182
+
183
+ document.addEventListener('keydown', handleKeyDown)
184
+ return () => document.removeEventListener('keydown', handleKeyDown)
185
+ }, [isOpen, layer])
58
186
 
59
187
  if (!isOpen) return null
60
188
 
@@ -69,8 +197,17 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
69
197
  {/* Outer card — uses .ds-card-surface so the modal gets the
70
198
  same chrome as cards (border + multi-layer shadow + inner
71
199
  highlight in light mode). overflow-hidden clips children to
72
- the rounded corners; the tray inside handles its own scroll. */}
200
+ the rounded corners; the tray inside handles its own scroll.
201
+
202
+ tabIndex={-1} makes the panel programmatically focusable so it
203
+ can receive focus on open without joining the tab ring. */}
73
204
  <div
205
+ ref={panelRef}
206
+ role="dialog"
207
+ aria-modal="true"
208
+ aria-labelledby={title ? titleId : undefined}
209
+ aria-label={title ? undefined : ariaLabel}
210
+ tabIndex={-1}
74
211
  data-framed="true"
75
212
  className={cn(
76
213
  'ds-card-surface relative w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden',
@@ -89,7 +226,10 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
89
226
  than the text, the BUTTON set the row height and the title
90
227
  floated in slack it never asked for. */}
91
228
  <div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
92
- <h2 className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]">
229
+ <h2
230
+ id={titleId}
231
+ className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
232
+ >
93
233
  {title}
94
234
  </h2>
95
235
  <button
@@ -10,6 +10,7 @@ import {
10
10
  } from 'react'
11
11
  import { createPortal } from 'react-dom'
12
12
  import { Link } from 'react-router-dom'
13
+ import { House } from 'lucide-react'
13
14
  import { cn } from './lib/utils'
14
15
 
15
16
  export type Crumb = {
@@ -60,7 +61,24 @@ interface PageShellProps {
60
61
  // per mount, never per render).
61
62
  // ---------------------------------------------------------------------------
62
63
 
64
+ /**
65
+ * The app's home destination, rendered as the first breadcrumb.
66
+ *
67
+ * PER-APP AND OPT-IN, which is the whole reason it lives on the provider
68
+ * instead of being hardcoded in `Breadcrumbs`. `PageShell` is shared: the
69
+ * workspace and `apps/approvals` both render it, and approvals has no
70
+ * `/clients` route — a hardcoded home would have shipped it a dead link on
71
+ * every page. An app that wants a home crumb says where home is; an app that
72
+ * does not gets no icon and nothing breaks.
73
+ */
74
+ export interface HomeCrumb {
75
+ to: string
76
+ /** Accessible name for the icon-only link, e.g. "Clients". */
77
+ label: string
78
+ }
79
+
63
80
  interface PageHeaderSlotValue {
81
+ home: HomeCrumb | null
64
82
  hostEl: HTMLElement | null
65
83
  centerEl: HTMLElement | null
66
84
  scrollEl: HTMLElement | null
@@ -71,7 +89,14 @@ interface PageHeaderSlotValue {
71
89
 
72
90
  const PageHeaderSlotContext = createContext<PageHeaderSlotValue | null>(null)
73
91
 
74
- export function PageHeaderSlotProvider({ children }: { children: ReactNode }) {
92
+ export function PageHeaderSlotProvider({
93
+ children,
94
+ home = null,
95
+ }: {
96
+ children: ReactNode
97
+ /** Where the leading home crumb points. Omit to render no home crumb. */
98
+ home?: HomeCrumb | null
99
+ }) {
75
100
  const [hostEl, setHostEl] = useState<HTMLElement | null>(null)
76
101
  const [centerEl, setCenterEl] = useState<HTMLElement | null>(null)
77
102
  const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null)
@@ -84,6 +109,7 @@ export function PageHeaderSlotProvider({ children }: { children: ReactNode }) {
84
109
  return (
85
110
  <PageHeaderSlotContext.Provider
86
111
  value={{
112
+ home,
87
113
  hostEl,
88
114
  centerEl,
89
115
  scrollEl,
@@ -375,6 +401,50 @@ export function PageHeaderHost({
375
401
  )
376
402
  }
377
403
 
404
+ /**
405
+ * The app's home destination, or null when the app has not declared one.
406
+ *
407
+ * Exported because not every breadcrumb in the app goes through `PageShell` —
408
+ * the Generate lens renders its own trail beside an editable project title —
409
+ * and those forks should read the same destination rather than each hardcoding
410
+ * a path that then drifts.
411
+ */
412
+ export function useBreadcrumbHome(): HomeCrumb | null {
413
+ return useContext(PageHeaderSlotContext)?.home ?? null
414
+ }
415
+
416
+ /**
417
+ * The leading home crumb: an icon, not a word.
418
+ *
419
+ * WHY AN ICON REPLACES THE FIRST CRUMB RATHER THAN SITTING BESIDE IT. Thirty-one
420
+ * lens trails already began `Clients / …` pointing at `/clients`, which is
421
+ * exactly where home goes — so rendering both would put the same destination on
422
+ * screen twice in a row, once as a picture and once as a word. The icon takes
423
+ * that crumb's place when they agree and prepends when they do not (the tools
424
+ * trails start at `/tools`), so "first position" holds either way.
425
+ *
426
+ * It carries an `aria-label` and a `title`: an icon-only link has no accessible
427
+ * name otherwise, and the tooltip is what tells a first-time user that the house
428
+ * means the client list rather than some other notion of home.
429
+ */
430
+ export function HomeCrumbLink({ home }: { home: HomeCrumb }) {
431
+ return (
432
+ <Link
433
+ to={home.to}
434
+ aria-label={home.label}
435
+ title={home.label}
436
+ className="shrink-0 flex items-center transition-colors hover:text-[rgb(var(--foreground))]"
437
+ style={{ color: 'inherit' }}
438
+ >
439
+ {/* 15px beside 13px crumb text, at the 1.9 stroke the theme toggle in
440
+ this same header band uses — the app's chrome runs heavier than
441
+ Lucide's default, and matching the neighbour matters more than the
442
+ generic rule. */}
443
+ <House className="h-[15px] w-[15px]" strokeWidth={1.9} aria-hidden="true" />
444
+ </Link>
445
+ )
446
+ }
447
+
378
448
  function Breadcrumbs({
379
449
  parents,
380
450
  current,
@@ -382,12 +452,31 @@ function Breadcrumbs({
382
452
  parents: Crumb[]
383
453
  current: Crumb | undefined
384
454
  }) {
455
+ const home = useBreadcrumbHome()
456
+ // When the first crumb already IS home, the icon stands in for it rather
457
+ // than duplicating it. Compared by destination, not by label, because the
458
+ // label varies ("Clients", a brand name) while the route does not.
459
+ const visibleParents =
460
+ home && parents[0]?.to === home.to ? parents.slice(1) : parents
461
+
385
462
  return (
386
463
  <nav
387
464
  className="flex items-center gap-1.5 text-[13px] min-w-0"
388
465
  style={{ color: 'rgb(var(--text-tertiary))' }}
389
466
  >
390
- {parents.map((c, i) => (
467
+ {home && (
468
+ <>
469
+ <HomeCrumbLink home={home} />
470
+ {/* Separator only when something follows it. A page with no crumbs
471
+ at all would otherwise render a dangling "home /". */}
472
+ {(visibleParents.length > 0 || current) && (
473
+ <span className="shrink-0" style={{ color: 'rgb(var(--text-muted))' }}>
474
+ /
475
+ </span>
476
+ )}
477
+ </>
478
+ )}
479
+ {visibleParents.map((c, i) => (
391
480
  <span key={i} className="flex items-center gap-1.5 min-w-0">
392
481
  {c.to ? (
393
482
  <Link