@podoba/react 0.0.7 → 0.0.9

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.
@@ -0,0 +1,134 @@
1
+ // @app/ui — request-changes modal (issue 13).
2
+ //
3
+ // PRESENTATIONAL "request changes" dialog built on the @app/ui <Dialog>
4
+ // (React Aria Modal — focus trap, Esc, aria-modal). The note is REQUIRED:
5
+ // confirm stays DISABLED until the textarea holds a non-empty (trimmed) value,
6
+ // mirroring the server contract (`request_changes` 422s on an empty note).
7
+ //
8
+ // APP-AGNOSTIC (hard rule #1): every string is a prop and the action is
9
+ // delegated via `onConfirm(note)`. The owning surface in apps/web wires the
10
+ // i18n strings + the REST mutation + invalidation, and surfaces the auto-created
11
+ // follow-up `changes` task returned by the command.
12
+ //
13
+ // a11y: the textarea is label-associated (htmlFor/id), marked `required` +
14
+ // `aria-invalid` while empty; the server error is role="alert"; confirm shows a
15
+ // pending state and is disabled while in flight or while the note is empty.
16
+
17
+ import { useId, useState } from 'react'
18
+ import { Button } from './button'
19
+ import { Dialog } from './dialog'
20
+
21
+ /** Strings the request-changes modal renders — supplied by the app (i18n). */
22
+ export interface RequestChangesModalLabels {
23
+ /** Dialog heading. */
24
+ title: string
25
+ /** Explanatory body copy under the title. */
26
+ body: string
27
+ /** Label for the required note textarea. */
28
+ noteLabel: string
29
+ /** Placeholder for the note textarea. */
30
+ notePlaceholder: string
31
+ /** Cancel button. */
32
+ cancel: string
33
+ /** Confirm (request changes) button — idle state. */
34
+ confirm: string
35
+ /** Confirm button — pending state. */
36
+ submitting: string
37
+ }
38
+
39
+ export interface RequestChangesModalProps {
40
+ /** Controlled open state. */
41
+ isOpen: boolean
42
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
43
+ onOpenChange: (isOpen: boolean) => void
44
+ /** Run the request_changes command with the (required, trimmed) note. */
45
+ onConfirm: (note: string) => void
46
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
47
+ isPending?: boolean
48
+ /** Server / mutation error message to surface inline (role="alert"). */
49
+ error?: string
50
+ /** App-supplied i18n strings. */
51
+ labels: RequestChangesModalLabels
52
+ /** Optional test id forwarded to the dialog. */
53
+ 'data-testid'?: string
54
+ }
55
+
56
+ /** Request-changes modal with a REQUIRED note (confirm disabled until non-empty). */
57
+ export function RequestChangesModal({
58
+ isOpen,
59
+ onOpenChange,
60
+ onConfirm,
61
+ isPending = false,
62
+ error,
63
+ labels,
64
+ 'data-testid': testId,
65
+ }: RequestChangesModalProps): React.ReactNode {
66
+ const noteId = useId()
67
+ // CONTROLLED textarea: the confirm button's disabled state depends on whether
68
+ // the note is non-empty, so the value drives dependent UI (unlike approve).
69
+ const [note, setNote] = useState('')
70
+ const isEmpty = note.trim().length === 0
71
+ const canConfirm = !isEmpty && !isPending
72
+
73
+ const handleConfirm = (): void => {
74
+ const trimmed = note.trim()
75
+ if (trimmed.length === 0) return
76
+ onConfirm(trimmed)
77
+ }
78
+
79
+ return (
80
+ <Dialog
81
+ title={labels.title}
82
+ data-testid={testId ?? 'request-changes-modal'}
83
+ isOpen={isOpen}
84
+ onOpenChange={(open) => {
85
+ if (!open) setNote('')
86
+ onOpenChange(open)
87
+ }}
88
+ >
89
+ <div className="flex flex-col gap-3">
90
+ <p className="text-sm text-fg-muted">{labels.body}</p>
91
+ <label htmlFor={noteId} className="text-sm font-medium text-fg">
92
+ {labels.noteLabel}
93
+ </label>
94
+ <textarea
95
+ id={noteId}
96
+ data-testid="request-changes-note"
97
+ value={note}
98
+ onChange={(event) => setNote(event.target.value)}
99
+ maxLength={10_000}
100
+ rows={4}
101
+ required
102
+ aria-invalid={isEmpty ? true : undefined}
103
+ placeholder={labels.notePlaceholder}
104
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
105
+ />
106
+ {error ? (
107
+ <p data-testid="request-changes-error" role="alert" className="text-sm text-danger">
108
+ {error}
109
+ </p>
110
+ ) : null}
111
+ <div className="mt-2 flex justify-end gap-2">
112
+ <Button
113
+ variant="secondary"
114
+ size="sm"
115
+ isDisabled={isPending}
116
+ onPress={() => onOpenChange(false)}
117
+ >
118
+ {labels.cancel}
119
+ </Button>
120
+ <Button
121
+ data-testid="request-changes-confirm"
122
+ variant="primary"
123
+ size="sm"
124
+ isDisabled={!canConfirm}
125
+ isPending={isPending}
126
+ onPress={handleConfirm}
127
+ >
128
+ {isPending ? labels.submitting : labels.confirm}
129
+ </Button>
130
+ </div>
131
+ </div>
132
+ </Dialog>
133
+ )
134
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
2
2
  import { clsx } from 'clsx'
3
+ import { useInFocusOverlay } from './focus-context'
3
4
 
4
5
  /**
5
6
  * RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
@@ -85,6 +86,9 @@ export function RichTextEditor({
85
86
  }: RichTextEditorProps) {
86
87
  const ref = useRef<HTMLDivElement>(null)
87
88
  const last = useRef<string>('')
89
+ // In a focus overlay, give the body more room to write in.
90
+ const inFocus = useInFocusOverlay()
91
+ const bodyMinHeight = inFocus ? Math.max(minHeight, 360) : minHeight
88
92
 
89
93
  // Sync only EXTERNAL value changes into the DOM — never on our own keystrokes,
90
94
  // or the caret would jump to the start on every character.
@@ -142,7 +146,7 @@ export function RichTextEditor({
142
146
  <div
143
147
  ref={ref}
144
148
  className="prose prose-sm max-w-none px-4 py-3.5 text-sm leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
145
- style={{ minHeight }}
149
+ style={{ minHeight: bodyMinHeight }}
146
150
  contentEditable
147
151
  suppressContentEditableWarning
148
152
  data-placeholder={placeholder}
@@ -13,6 +13,7 @@ import {
13
13
  Text,
14
14
  } from 'react-aria-components'
15
15
  import { uic } from '../utils/uic'
16
+ import { useInFocusOverlay } from './focus-context'
16
17
 
17
18
  /**
18
19
  * Select — accessible dropdown built on React Aria Components `Select`.
@@ -74,41 +75,55 @@ export const Select = <T extends object>({
74
75
  placeholder,
75
76
  children,
76
77
  ...props
77
- }: SelectProps<T>) => (
78
- <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
79
- <Label className="text-heading5 font-medium text-fg">{label}</Label>
80
- <SelectTrigger>
81
- <SelectValue className="data-[placeholder]:text-fg-muted" />
82
- {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
83
- <svg
84
- width="9.5"
85
- height="9.5"
86
- viewBox="0 0 12 12"
87
- fill="none"
88
- aria-hidden="true"
89
- className="pointer-events-none shrink-0 text-fg"
90
- >
91
- <path
92
- d="M3 4.5L6 7.5L9 4.5"
93
- stroke="currentColor"
94
- strokeWidth="1.5"
95
- strokeLinecap="round"
96
- strokeLinejoin="round"
97
- />
98
- </svg>
99
- </SelectTrigger>
100
- {description ? (
101
- <Text slot="description" className="text-xs text-fg-muted">
102
- {description}
103
- </Text>
104
- ) : null}
105
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
106
- {/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
107
- highlight sits as a padded pill; small gap for an even list rhythm. */}
108
- <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
109
- <ListBox className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
110
- {children}
111
- </ListBox>
112
- </Popover>
113
- </RACSelect>
114
- )
78
+ }: SelectProps<T>) => {
79
+ // In a focus overlay, show the options inline (seamless) instead of a popover.
80
+ const inFocus = useInFocusOverlay()
81
+ const listbox = (
82
+ <ListBox className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none">
83
+ {children}
84
+ </ListBox>
85
+ )
86
+ const desc = description ? (
87
+ <Text slot="description" className="text-xs text-fg-muted">
88
+ {description}
89
+ </Text>
90
+ ) : null
91
+ const err = <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
92
+
93
+ return (
94
+ <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
95
+ <Label className="text-heading5 font-medium text-fg">{label}</Label>
96
+ {inFocus ? (
97
+ <>
98
+ {listbox}
99
+ {desc}
100
+ {err}
101
+ </>
102
+ ) : (
103
+ <>
104
+ <SelectTrigger>
105
+ <SelectValue className="data-[placeholder]:text-fg-muted" />
106
+ {/* gs chevron: 9.5px caret, dark (neutral-400 → fg), non-interactive. */}
107
+ <svg
108
+ width="9.5"
109
+ height="9.5"
110
+ viewBox="0 0 12 12"
111
+ fill="none"
112
+ aria-hidden="true"
113
+ className="pointer-events-none shrink-0 text-fg"
114
+ >
115
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
116
+ </svg>
117
+ </SelectTrigger>
118
+ {desc}
119
+ {err}
120
+ {/* Cream fill, 8px radius, shadow-lg, NO border. 4px inset so each option's
121
+ highlight sits as a padded pill; small gap for an even list rhythm. */}
122
+ <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
123
+ {listbox}
124
+ </Popover>
125
+ </>
126
+ )}
127
+ </RACSelect>
128
+ )
129
+ }
@@ -0,0 +1,146 @@
1
+ import { Button as RACButton } from 'react-aria-components'
2
+ import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
3
+ import { uic } from '../utils/uic'
4
+
5
+ /**
6
+ * StatsCard — KPI card (port of gs-platform `GSStatsCard` / `StatsCard`).
7
+ *
8
+ * gs-platform renders title (heading-5) · large value (label-1) · footer
9
+ * (small body) inside a spacious cream `Tile` with panel radius. The `dark`
10
+ * variant mirrors gs's dark "Cloud" tile (inverted surface, light text).
11
+ *
12
+ * Presentational only (hard rule #1): all copy via props, no API/i18n.
13
+ *
14
+ * When `onPress` is set the whole card becomes a React Aria `Button` — it gets
15
+ * keyboard activation, `data-[focus-visible]` ring and `data-[pressed]` for
16
+ * free. Otherwise it renders as a plain non-interactive `div`.
17
+ */
18
+ export type StatsCardProps = {
19
+ /** Small uppercase-ish label above the value. */
20
+ title: ReactNode
21
+ /** The large headline number / value. */
22
+ value: ReactNode
23
+ /** Optional supporting footer line (e.g. "+3 this week"). */
24
+ footer?: ReactNode
25
+ /** When provided the card becomes a clickable React Aria Button. */
26
+ onPress?: () => void
27
+ /**
28
+ * Render the card AS the provided child element (e.g. a router `<Link>`) instead
29
+ * of a button — so the whole tile is a real anchor (cmd/middle-click, open in
30
+ * new tab, link semantics). The card styling + hover/focus are merged onto the
31
+ * child and the title/value/footer are injected as its children. Takes
32
+ * precedence over `onPress`.
33
+ */
34
+ asChild?: boolean
35
+ /** The element to render as the card when `asChild` is set (a single element). */
36
+ children?: ReactNode
37
+ /** Dark/inverted surface (gs's dark "Cloud" tile). */
38
+ dark?: boolean
39
+ /** Accessible label for the clickable card (defaults to nothing — title is read). */
40
+ 'aria-label'?: string
41
+ className?: string
42
+ }
43
+
44
+ // gs Tile: `--radius-lg` (8px) corners, 16px padding, min-height 208px (size
45
+ // "compact"), and it FILLS its grid cell (h-full) so a tall dashboard tile is a
46
+ // tall card. The three bands (title top / value centered / footer bottom) are laid
47
+ // out by StatsCardBody. The "skin" (surface + border) is split out so the dark
48
+ // variant can override it cleanly.
49
+ const baseSurface = 'flex h-full min-h-[208px] flex-col gap-2 rounded-lg p-4 text-left'
50
+ const lightSkin = 'border border-border bg-surface-card'
51
+ const darkSkin = 'border border-transparent bg-surface-inverted'
52
+
53
+ function StatsCardBody({
54
+ title,
55
+ value,
56
+ footer,
57
+ dark,
58
+ }: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'dark'>) {
59
+ return (
60
+ <>
61
+ <span
62
+ className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}
63
+ >
64
+ {title}
65
+ </span>
66
+ {/*
67
+ * gs Tile `contentAlign="center"`: the value sits in the CENTRE band of the
68
+ * three-band tile (title top / value centred / footer bottom). The `flex-1`
69
+ * band grows to fill the 208px tile and vertically centres the value, pushing
70
+ * any footer to the bottom (no `mt-auto` needed). Value ramp = gs label-1
71
+ * (1.875rem / 500 / 2rem line). Letter-spacing tracks gs 1:1: light tiles use
72
+ * -0.56px via `tracking-wide`; the dark "Cloud" count tile uses gs's em-based
73
+ * `-0.02em` (`AssetsTile.module.scss .count`).
74
+ */}
75
+ <span
76
+ className={`flex flex-1 items-center text-[1.875rem] font-medium leading-[2rem] ${dark ? 'tracking-[-0.02em] text-white' : 'tracking-wide text-fg'}`}
77
+ >
78
+ {value}
79
+ </span>
80
+ {footer ? (
81
+ <span className={`pt-4 text-compact leading-4 ${dark ? 'text-white/50' : 'text-fg-muted'}`}>
82
+ {footer}
83
+ </span>
84
+ ) : null}
85
+ </>
86
+ )
87
+ }
88
+
89
+ const PressableCard = uic(RACButton, {
90
+ displayName: 'StatsCardButton',
91
+ baseClass:
92
+ `${baseSurface} ${lightSkin} ` +
93
+ 'w-full outline-none transition-transform duration-150 ' +
94
+ 'data-[hovered]:scale-[1.02] ' +
95
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
96
+ 'data-[pressed]:scale-100',
97
+ })
98
+
99
+ // Anchor interaction skin: same subtle scale-up as the pressable card (gs Tile
100
+ // `whileHover` scale 1.02, no shadow), but driven by CSS `:hover`/`:focus-visible`
101
+ // (real <a>) rather than RAC `data-` attributes.
102
+ const anchorInteractive =
103
+ 'no-underline outline-none transition-transform duration-150 ' +
104
+ 'hover:scale-[1.02] ' +
105
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
106
+
107
+ export function StatsCard({
108
+ title,
109
+ value,
110
+ footer,
111
+ onPress,
112
+ asChild,
113
+ children,
114
+ dark,
115
+ className,
116
+ ...rest
117
+ }: StatsCardProps) {
118
+ // asChild wins: render the supplied element (typically a router <Link>) as the
119
+ // card. The body is injected as its children so callers pass a self-closing link.
120
+ if (asChild && isValidElement(children)) {
121
+ const child = children as ReactElement<{ className?: string }>
122
+ const cardClass = [baseSurface, dark ? darkSkin : lightSkin, anchorInteractive, child.props.className, className]
123
+ .filter(Boolean)
124
+ .join(' ')
125
+ return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} dark={dark} />)
126
+ }
127
+
128
+ if (onPress) {
129
+ // uic merges via tailwind-merge, so `darkSkin` cleanly overrides the
130
+ // light surface/border in the base class.
131
+ return (
132
+ <PressableCard
133
+ onPress={onPress}
134
+ className={[dark ? darkSkin : '', className].filter(Boolean).join(' ')}
135
+ aria-label={rest['aria-label']}
136
+ >
137
+ <StatsCardBody title={title} value={value} footer={footer} dark={dark} />
138
+ </PressableCard>
139
+ )
140
+ }
141
+ return (
142
+ <div className={[baseSurface, dark ? darkSkin : lightSkin, className].filter(Boolean).join(' ')}>
143
+ <StatsCardBody title={title} value={value} footer={footer} dark={dark} />
144
+ </div>
145
+ )
146
+ }
@@ -0,0 +1,123 @@
1
+ // @app/ui — task approval modal (issue 13).
2
+ //
3
+ // PRESENTATIONAL approve-confirmation dialog built on the @app/ui <Dialog>
4
+ // (React Aria Modal — focus trap, Esc, aria-modal). The note is OPTIONAL on
5
+ // approve; confirm is always enabled (a press with an empty note is valid).
6
+ //
7
+ // This component is APP-AGNOSTIC (hard rule #1: @app/ui never imports apps/web
8
+ // or calls API hooks). Every string is supplied via the `labels` prop and the
9
+ // confirm action is delegated through `onConfirm(note?)` — the owning surface
10
+ // in apps/web wires the i18n strings + the REST mutation + invalidation.
11
+ //
12
+ // a11y: the textarea is label-associated via htmlFor/id; the server error is
13
+ // role="alert"; confirm shows a pending state and disables while in flight.
14
+
15
+ import { useId, useRef } from 'react'
16
+ import { Button } from './button'
17
+ import { Dialog } from './dialog'
18
+
19
+ /** Strings the approval modal renders — supplied by the app (i18n), not hardcoded. */
20
+ export interface TaskApprovalModalLabels {
21
+ /** Dialog heading. */
22
+ title: string
23
+ /** Explanatory body copy under the title. */
24
+ body: string
25
+ /** Label for the optional note textarea. */
26
+ noteLabel: string
27
+ /** Placeholder for the note textarea. */
28
+ notePlaceholder: string
29
+ /** Cancel button. */
30
+ cancel: string
31
+ /** Confirm (approve) button — idle state. */
32
+ confirm: string
33
+ /** Confirm button — pending state. */
34
+ submitting: string
35
+ }
36
+
37
+ export interface TaskApprovalModalProps {
38
+ /** Controlled open state. */
39
+ isOpen: boolean
40
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
41
+ onOpenChange: (isOpen: boolean) => void
42
+ /** Run the approve command with the (trimmed, optional) note. */
43
+ onConfirm: (note?: string) => void
44
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
45
+ isPending?: boolean
46
+ /** Server / mutation error message to surface inline (role="alert"). */
47
+ error?: string
48
+ /** App-supplied i18n strings. */
49
+ labels: TaskApprovalModalLabels
50
+ /** Optional test id forwarded to the dialog. */
51
+ 'data-testid'?: string
52
+ }
53
+
54
+ /** Approve-confirmation modal with an optional note. */
55
+ export function TaskApprovalModal({
56
+ isOpen,
57
+ onOpenChange,
58
+ onConfirm,
59
+ isPending = false,
60
+ error,
61
+ labels,
62
+ 'data-testid': testId,
63
+ }: TaskApprovalModalProps): React.ReactNode {
64
+ const noteId = useId()
65
+ // UNCONTROLLED textarea (read at submit via ref): the note has no dependent UI
66
+ // on approve, so controlled state would only add a re-render per keystroke.
67
+ const noteRef = useRef<HTMLTextAreaElement | null>(null)
68
+
69
+ const handleConfirm = (): void => {
70
+ const trimmed = (noteRef.current?.value ?? '').trim()
71
+ onConfirm(trimmed.length > 0 ? trimmed : undefined)
72
+ }
73
+
74
+ return (
75
+ <Dialog
76
+ title={labels.title}
77
+ data-testid={testId ?? 'task-approval-modal'}
78
+ isOpen={isOpen}
79
+ onOpenChange={onOpenChange}
80
+ >
81
+ <div className="flex flex-col gap-3">
82
+ <p className="text-sm text-fg-muted">{labels.body}</p>
83
+ <label htmlFor={noteId} className="text-sm font-medium text-fg">
84
+ {labels.noteLabel}
85
+ </label>
86
+ <textarea
87
+ id={noteId}
88
+ data-testid="task-approval-note"
89
+ ref={noteRef}
90
+ maxLength={10_000}
91
+ rows={3}
92
+ placeholder={labels.notePlaceholder}
93
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
94
+ />
95
+ {error ? (
96
+ <p data-testid="task-approval-error" role="alert" className="text-sm text-danger">
97
+ {error}
98
+ </p>
99
+ ) : null}
100
+ <div className="mt-2 flex justify-end gap-2">
101
+ <Button
102
+ variant="secondary"
103
+ size="sm"
104
+ isDisabled={isPending}
105
+ onPress={() => onOpenChange(false)}
106
+ >
107
+ {labels.cancel}
108
+ </Button>
109
+ <Button
110
+ data-testid="task-approval-confirm"
111
+ variant="primary"
112
+ size="sm"
113
+ isDisabled={isPending}
114
+ isPending={isPending}
115
+ onPress={handleConfirm}
116
+ >
117
+ {isPending ? labels.submitting : labels.confirm}
118
+ </Button>
119
+ </div>
120
+ </div>
121
+ </Dialog>
122
+ )
123
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  // @podoba/react — the universal component library.
2
2
  //
3
3
  // React Aria Components + Tailwind, composed with `uic`. Seeded from graphic-standard's
4
- // @app/ui — atomic primitives + layout ONLY. Product-specific components (schema
5
- // renderer, delivery/approval modals, brand headers) stay in GS and consume this.
6
- // See ../../EXTRACTION.md.
4
+ // @app/ui — atomic primitives + layout, PLUS the label-driven product patterns
5
+ // (delivery/approval modals, brand header, stats/dashboard). Podoba is the single
6
+ // source of truth for UI. Only GS-DOMAIN-coupled UI (the @app/schema-driven component
7
+ // renderer) stays in GS and consumes this. See ../../EXTRACTION.md.
7
8
 
8
9
  // --- factory ---
9
10
  export { uic, uiconfig, type ConfigVariants, type NoInfer } from "./utils/uic";
@@ -13,6 +14,8 @@ export * from "./components/button";
13
14
  export * from "./components/input";
14
15
  export * from "./components/textarea";
15
16
  export * from "./components/rich-text-editor";
17
+ export * from "./components/focus-field";
18
+ export * from "./components/focus-context";
16
19
  export * from "./components/checkbox";
17
20
  export * from "./components/radio";
18
21
  export * from "./components/switch";
@@ -32,6 +35,7 @@ export * from "./components/context-menu";
32
35
  export * from "./components/tooltip";
33
36
  export * from "./components/toast";
34
37
  export * from "./components/disclosure";
38
+ export * from "./components/collapsible-card";
35
39
  export * from "./components/tabs";
36
40
  export * from "./components/section-tabs";
37
41
  export * from "./components/separator";
@@ -45,3 +49,14 @@ export * from "./layout/page-container";
45
49
  export * from "./layout/app-shell";
46
50
  export * from "./layout/topbar";
47
51
  export * from "./layout/persistent-page-shell";
52
+
53
+ // --- product patterns (label-driven; no domain coupling) ---
54
+ export * from "./components/brand-page-header";
55
+ export * from "./components/stats-card";
56
+ export * from "./components/dashboard-grid";
57
+ export * from "./components/task-approval-modal";
58
+ export * from "./components/request-changes-modal";
59
+ export * from "./components/delivery/download-modal";
60
+ export * from "./components/delivery/send-to-print-modal";
61
+ export * from "./components/delivery/publish-modal";
62
+ export * from "./components/delivery/delivery-status-module";