@podoba/react 0.0.17 → 0.0.19

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 (35) hide show
  1. package/package.json +3 -3
  2. package/src/components/brand-page-header.tsx +2 -2
  3. package/src/components/checkbox.tsx +1 -1
  4. package/src/components/combobox.tsx +7 -7
  5. package/src/components/count-up.tsx +59 -0
  6. package/src/components/date-field.tsx +5 -5
  7. package/src/components/date-picker.tsx +4 -4
  8. package/src/components/delivery/delivery-status-module.tsx +3 -3
  9. package/src/components/delivery/download-modal.tsx +7 -7
  10. package/src/components/delivery/publish-modal.tsx +2 -2
  11. package/src/components/delivery/send-to-print-modal.tsx +2 -2
  12. package/src/components/dialog.tsx +1 -1
  13. package/src/components/dropdown-menu.tsx +2 -2
  14. package/src/components/feature-tile.tsx +1 -1
  15. package/src/components/file-upload.tsx +3 -3
  16. package/src/components/focus-field.tsx +4 -4
  17. package/src/components/input.tsx +3 -3
  18. package/src/components/multiselect.tsx +6 -6
  19. package/src/components/number-field.tsx +5 -5
  20. package/src/components/radio.tsx +4 -4
  21. package/src/components/request-changes-modal.tsx +4 -4
  22. package/src/components/rich-text-editor.tsx +4 -4
  23. package/src/components/search-field.tsx +2 -2
  24. package/src/components/select.tsx +4 -4
  25. package/src/components/slider.tsx +1 -1
  26. package/src/components/stat-stack.tsx +61 -0
  27. package/src/components/switch.tsx +1 -1
  28. package/src/components/tabs.tsx +2 -2
  29. package/src/components/tag-group.tsx +2 -2
  30. package/src/components/task-approval-modal.tsx +4 -4
  31. package/src/components/text.tsx +5 -5
  32. package/src/components/textarea.tsx +4 -4
  33. package/src/index.ts +2 -0
  34. package/src/layout/persistent-page-shell.tsx +3 -3
  35. package/src/layout/topbar.tsx +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "typecheck": "tsc --build"
26
26
  },
27
27
  "dependencies": {
28
- "@podoba/tokens": "^0.0.17",
29
- "@podoba/tailwind": "^0.0.17",
28
+ "@podoba/tokens": "^0.0.19",
29
+ "@podoba/tailwind": "^0.0.19",
30
30
  "react-aria-components": "1.18.0",
31
31
  "class-variance-authority": "0.7.1",
32
32
  "clsx": "2.1.1",
@@ -141,12 +141,12 @@ export function BrandPageHeader({
141
141
  onPress={() => setExpanded(!expanded)}
142
142
  aria-expanded={expanded}
143
143
  aria-controls={PANEL_ID}
144
- className="h-10 rounded-full bg-brand-secondary px-5 text-sm font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
144
+ className="h-10 rounded-full bg-brand-secondary px-5 text-small font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
145
145
  >
146
146
  {ctaLabel}
147
147
  </Button>
148
148
  ) : (
149
- <Button className="h-10 rounded-full bg-brand-secondary px-5 text-sm font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80">
149
+ <Button className="h-10 rounded-full bg-brand-secondary px-5 text-small font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80">
150
150
  {ctaLabel}
151
151
  </Button>
152
152
  )}
@@ -37,7 +37,7 @@ export type CheckboxProps = Omit<RACCheckboxProps, 'children'> & {
37
37
  export const Checkbox = ({ children, ...props }: CheckboxProps) => (
38
38
  <RACCheckbox
39
39
  {...props}
40
- className="group flex cursor-pointer select-none items-center gap-3 text-sm text-fg data-[disabled]:cursor-not-allowed"
40
+ className="group flex cursor-pointer select-none items-center gap-3 text-small text-fg data-[disabled]:cursor-not-allowed"
41
41
  >
42
42
  {({ isSelected, isIndeterminate }) => (
43
43
  <>
@@ -36,7 +36,7 @@ const Chevron = () => (
36
36
  const ComboBoxInput = uic(RACInput, {
37
37
  displayName: 'ComboBoxInput',
38
38
  baseClass:
39
- 'h-12 w-full rounded-lg border border-border bg-surface pl-4 pr-11 text-sm text-fg ' +
39
+ 'h-12 w-full rounded-lg border border-border bg-surface pl-4 pr-11 text-small text-fg ' +
40
40
  'outline-none transition-colors placeholder:text-fg-muted ' +
41
41
  'data-[hovered]:border-fg-subtle ' +
42
42
  'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
@@ -48,7 +48,7 @@ export const ComboBoxItem = uic(ListBoxItem, {
48
48
  displayName: 'ComboBoxItem',
49
49
  // Matches SelectItem so the two dropdowns are indistinguishable.
50
50
  baseClass:
51
- 'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm text-fg outline-none ' +
51
+ 'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-small text-fg outline-none ' +
52
52
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
53
53
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
54
54
  }) as (props: ListBoxItemProps) => ReactNode
@@ -76,7 +76,7 @@ export const ComboBox = <T extends object>({
76
76
  const inFocus = useInFocusOverlay()
77
77
  const { contains } = useFilter({ sensitivity: 'base' })
78
78
  const desc = description ? (
79
- <Text slot="description" className="text-xs text-fg-muted">
79
+ <Text slot="description" className="text-label text-fg-muted">
80
80
  {description}
81
81
  </Text>
82
82
  ) : null
@@ -101,14 +101,14 @@ export const ComboBox = <T extends object>({
101
101
  const k = keys === 'all' ? undefined : [...keys][0]
102
102
  onSelectionChange?.(k ?? null)
103
103
  }}
104
- renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
104
+ renderEmptyState={() => <div className="px-3 py-2 text-small text-fg-muted">No results</div>}
105
105
  className="mt-2 flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
106
106
  >
107
107
  {children}
108
108
  </ListBox>
109
109
  </Autocomplete>
110
110
  {desc}
111
- {errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
111
+ {errorMessage ? <span className="text-label text-danger">{errorMessage}</span> : null}
112
112
  </div>
113
113
  )
114
114
  }
@@ -132,11 +132,11 @@ export const ComboBox = <T extends object>({
132
132
  </RACButton>
133
133
  </div>
134
134
  {desc}
135
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
135
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
136
136
  <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
137
137
  <ListBox
138
138
  className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
139
- renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No results</div>}
139
+ renderEmptyState={() => <div className="px-3 py-2 text-small text-fg-muted">No results</div>}
140
140
  >
141
141
  {children}
142
142
  </ListBox>
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef, useState, type ElementType } from 'react'
2
+
3
+ /**
4
+ * CountUp — animates a number from 0 up to `value` on mount (and whenever `value`
5
+ * changes), for the subtle "counting up" effect on dashboard stats. easeOutCubic over
6
+ * ~900ms by default. Respects `prefers-reduced-motion` (renders the final value
7
+ * immediately). Presentational only — pass formatting via `format`.
8
+ */
9
+ export interface CountUpProps {
10
+ /** Target value to count up to. */
11
+ value: number
12
+ /** Animation duration in ms (default 900). */
13
+ durationMs?: number
14
+ /** Format the displayed number (default `toLocaleString`). */
15
+ format?: (n: number) => string
16
+ /** Element to render as (default `span`). */
17
+ as?: ElementType
18
+ className?: string
19
+ }
20
+
21
+ const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3)
22
+
23
+ export function CountUp({
24
+ value,
25
+ durationMs = 900,
26
+ format = (n) => n.toLocaleString(),
27
+ as: Tag = 'span',
28
+ className,
29
+ }: CountUpProps) {
30
+ const [display, setDisplay] = useState(value)
31
+ const rafRef = useRef<number | null>(null)
32
+ const startRef = useRef<number | null>(null)
33
+
34
+ useEffect(() => {
35
+ if (typeof window === 'undefined') return
36
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
37
+ if (reduce || durationMs <= 0) {
38
+ setDisplay(value)
39
+ return
40
+ }
41
+ startRef.current = null
42
+ const tick = (now: number) => {
43
+ if (startRef.current === null) startRef.current = now
44
+ const progress = Math.min(1, (now - startRef.current) / durationMs)
45
+ setDisplay(Math.round(value * easeOutCubic(progress)))
46
+ if (progress < 1) rafRef.current = requestAnimationFrame(tick)
47
+ }
48
+ rafRef.current = requestAnimationFrame(tick)
49
+ return () => {
50
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
51
+ }
52
+ }, [value, durationMs])
53
+
54
+ return (
55
+ <Tag className={className} aria-label={format(value)}>
56
+ {format(display)}
57
+ </Tag>
58
+ )
59
+ }
@@ -28,7 +28,7 @@ export const segmentClass =
28
28
  'data-[disabled]:opacity-50 data-[type=literal]:px-0 data-[type=literal]:text-fg-muted'
29
29
 
30
30
  export const dateInputClass =
31
- 'flex h-12 w-full items-center gap-0.5 rounded-lg border border-border bg-surface px-4 text-sm text-fg transition-colors ' +
31
+ 'flex h-12 w-full items-center gap-0.5 rounded-lg border border-border bg-surface px-4 text-small text-fg transition-colors ' +
32
32
  'hover:border-fg-subtle focus-within:border-brand-green focus-within:ring-2 focus-within:ring-ring ' +
33
33
  'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger'
34
34
 
@@ -43,11 +43,11 @@ export const DateField = <T extends DateValue>({ label, description, errorMessag
43
43
  <Label className="text-heading5 font-medium text-fg">{label}</Label>
44
44
  <DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
45
45
  {description ? (
46
- <Text slot="description" className="text-xs text-fg-muted">
46
+ <Text slot="description" className="text-label text-fg-muted">
47
47
  {description}
48
48
  </Text>
49
49
  ) : null}
50
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
50
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
51
51
  </RACDateField>
52
52
  )
53
53
 
@@ -62,10 +62,10 @@ export const TimeField = <T extends TimeValue>({ label, description, errorMessag
62
62
  <Label className="text-heading5 font-medium text-fg">{label}</Label>
63
63
  <DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
64
64
  {description ? (
65
- <Text slot="description" className="text-xs text-fg-muted">
65
+ <Text slot="description" className="text-label text-fg-muted">
66
66
  {description}
67
67
  </Text>
68
68
  ) : null}
69
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
69
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
70
70
  </RACTimeField>
71
71
  )
@@ -41,7 +41,7 @@ const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
41
41
  )
42
42
 
43
43
  const calendarCellClass =
44
- 'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-sm text-fg outline-none transition-colors ' +
44
+ 'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-small text-fg outline-none transition-colors ' +
45
45
  'data-[outside-month]:text-fg-subtle data-[hovered]:bg-surface-muted ' +
46
46
  'data-[selected]:bg-fg data-[selected]:text-fg-inverted data-[selected]:font-medium ' +
47
47
  'data-[unavailable]:text-fg-subtle data-[unavailable]:line-through ' +
@@ -54,7 +54,7 @@ function CalendarBody() {
54
54
  <RACButton slot="previous" className={navButton}>
55
55
  <Chevron dir="left" />
56
56
  </RACButton>
57
- <Heading className="text-sm font-medium text-fg" />
57
+ <Heading className="text-small font-medium text-fg" />
58
58
  <RACButton slot="next" className={navButton}>
59
59
  <Chevron dir="right" />
60
60
  </RACButton>
@@ -104,11 +104,11 @@ export const DatePicker = <T extends DateValue>({ label, description, errorMessa
104
104
  )}
105
105
  </Group>
106
106
  {description ? (
107
- <Text slot="description" className="text-xs text-fg-muted">
107
+ <Text slot="description" className="text-label text-fg-muted">
108
108
  {description}
109
109
  </Text>
110
110
  ) : null}
111
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
111
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
112
112
  {inFocus ? (
113
113
  <div className="mt-2 -ml-2">
114
114
  <CalendarBody />
@@ -75,13 +75,13 @@ export function DeliveryStatusModule({
75
75
 
76
76
  return (
77
77
  <DeliveryStatusModuleRoot data-testid={testId}>
78
- <div className="flex items-center justify-between gap-4 text-sm font-medium">
78
+ <div className="flex items-center justify-between gap-4 text-small font-medium">
79
79
  <span>{heading}</span>
80
80
  {headingMeta != null ? <span>{headingMeta}</span> : null}
81
81
  </div>
82
82
 
83
83
  {statusText != null ? (
84
- <p className="m-0 text-xs text-white/70">{statusText}</p>
84
+ <p className="m-0 text-label text-white/70">{statusText}</p>
85
85
  ) : null}
86
86
 
87
87
  {hasProgress ? (
@@ -97,7 +97,7 @@ export function DeliveryStatusModule({
97
97
  ) : null}
98
98
 
99
99
  {children != null ? (
100
- <div className="flex flex-wrap items-center gap-2 text-xs text-white/80">
100
+ <div className="flex flex-wrap items-center gap-2 text-label text-white/80">
101
101
  {children}
102
102
  </div>
103
103
  ) : null}
@@ -109,11 +109,11 @@ export function DownloadModal({
109
109
  onOpenChange={onOpenChange}
110
110
  >
111
111
  <div className="flex flex-col gap-3">
112
- <p className="text-sm text-fg-muted">{labels.body}</p>
112
+ <p className="text-small text-fg-muted">{labels.body}</p>
113
113
 
114
114
  {hasArtifact ? (
115
115
  <>
116
- <p data-testid="delivery-download-ready" role="status" className="text-sm text-fg">
116
+ <p data-testid="delivery-download-ready" role="status" className="text-small text-fg">
117
117
  {labels.ready}
118
118
  </p>
119
119
  <div className="mt-2 flex justify-end gap-2">
@@ -126,7 +126,7 @@ export function DownloadModal({
126
126
  data-testid="delivery-download-link"
127
127
  href={artifactUrl ?? undefined}
128
128
  download
129
- className="inline-flex h-8 items-center justify-center gap-2 rounded-md bg-brand-primary px-3 text-sm font-medium text-fg-inverted no-underline outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
129
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-md bg-brand-primary px-3 text-small font-medium text-fg-inverted no-underline outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
130
130
  >
131
131
  {labels.download}
132
132
  </a>
@@ -134,7 +134,7 @@ export function DownloadModal({
134
134
  </>
135
135
  ) : isPreparing ? (
136
136
  <>
137
- <p data-testid="delivery-download-preparing" role="status" className="text-sm text-fg-muted">
137
+ <p data-testid="delivery-download-preparing" role="status" className="text-small text-fg-muted">
138
138
  {labels.preparing}
139
139
  </p>
140
140
  <div className="mt-2 flex justify-end gap-2">
@@ -151,7 +151,7 @@ export function DownloadModal({
151
151
  onChange={(value: string) => setFormat(value as DownloadFormat)}
152
152
  className="flex flex-col gap-2"
153
153
  >
154
- <Label className="text-sm font-medium text-fg">{labels.formatLabel}</Label>
154
+ <Label className="text-small font-medium text-fg">{labels.formatLabel}</Label>
155
155
  <div className="flex gap-2">
156
156
  <DownloadFormatOption value="png" label={labels.formatPng} />
157
157
  <DownloadFormatOption value="pdf" label={labels.formatPdf} />
@@ -159,7 +159,7 @@ export function DownloadModal({
159
159
  </RadioGroup>
160
160
 
161
161
  {error ? (
162
- <p data-testid="delivery-download-error" role="alert" className="text-sm text-danger">
162
+ <p data-testid="delivery-download-error" role="alert" className="text-small text-danger">
163
163
  {error}
164
164
  </p>
165
165
  ) : null}
@@ -196,7 +196,7 @@ function DownloadFormatOption({ value, label }: { value: Key; label: string }):
196
196
  return (
197
197
  <Radio
198
198
  value={String(value)}
199
- className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[selected]:border-brand-primary data-[selected]:font-medium"
199
+ className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-small text-fg outline-none transition-colors data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[selected]:border-brand-primary data-[selected]:font-medium"
200
200
  >
201
201
  {label}
202
202
  </Radio>
@@ -115,7 +115,7 @@ export function PublishModal({
115
115
  onOpenChange={handleOpenChange}
116
116
  >
117
117
  <div className="flex flex-col gap-3">
118
- <p className="text-sm text-fg-muted">{labels.body}</p>
118
+ <p className="text-small text-fg-muted">{labels.body}</p>
119
119
 
120
120
  <Input
121
121
  label={labels.destinationLabel}
@@ -139,7 +139,7 @@ export function PublishModal({
139
139
  </Select>
140
140
 
141
141
  {error ? (
142
- <p data-testid="delivery-publish-error" role="alert" className="text-sm text-danger">
142
+ <p data-testid="delivery-publish-error" role="alert" className="text-small text-danger">
143
143
  {error}
144
144
  </p>
145
145
  ) : null}
@@ -149,7 +149,7 @@ export function SendToPrintModal({
149
149
  onOpenChange={handleOpenChange}
150
150
  >
151
151
  <div className="flex flex-col gap-3">
152
- <p className="text-sm text-fg-muted">{labels.body}</p>
152
+ <p className="text-small text-fg-muted">{labels.body}</p>
153
153
 
154
154
  <Input
155
155
  label={labels.sizeLabel}
@@ -185,7 +185,7 @@ export function SendToPrintModal({
185
185
  />
186
186
 
187
187
  {error ? (
188
- <p data-testid="delivery-print-error" role="alert" className="text-sm text-danger">
188
+ <p data-testid="delivery-print-error" role="alert" className="text-small text-danger">
189
189
  {error}
190
190
  </p>
191
191
  ) : null}
@@ -152,7 +152,7 @@ export const Dialog = ({
152
152
  {title}
153
153
  </Heading>
154
154
  {badge ? (
155
- <span className="inline-flex shrink-0 items-center rounded-full bg-surface-muted px-2 py-0.5 text-xs font-medium text-fg-muted">
155
+ <span className="inline-flex shrink-0 items-center rounded-full bg-surface-muted px-2 py-0.5 text-label font-medium text-fg-muted">
156
156
  {badge}
157
157
  </span>
158
158
  ) : null}
@@ -57,13 +57,13 @@ export const DropdownMenuTrigger = RACMenuTrigger
57
57
  // gs `.item`: 12/16px padding, 6px radius, base text on white; hover/focus wash
58
58
  // `#f7f6f2` (→ surface-card). We light up both `data-[focused]` (keyboard) and
59
59
  // `data-[hovered]` (pointer) so keyboard focus stays clearly visible. Item text
60
- // size follows the house light Select (`text-sm`) rather than gs's 16px base so
60
+ // size follows the house light Select (`text-small`) rather than gs's 16px base so
61
61
  // the two light dropdowns stay consistent.
62
62
  export const DropdownMenuItem = uic(RACMenuItem, {
63
63
  displayName: 'DropdownMenuItem',
64
64
  baseClass:
65
65
  'flex cursor-pointer select-none items-center gap-2.5 rounded-md px-4 py-3 ' +
66
- 'text-sm text-fg outline-none ' +
66
+ 'text-small text-fg outline-none ' +
67
67
  'data-[focused]:bg-surface-card data-[hovered]:bg-surface-card ' +
68
68
  'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
69
69
  variants: {
@@ -125,7 +125,7 @@ export function FeatureTile({
125
125
  or badge falls through to the play/press overlay; only `action`
126
126
  re-enables its own pointer events. */}
127
127
  <div className="pointer-events-none absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
128
- <h2 className="max-w-md text-display-xl font-medium leading-tight text-white">
128
+ <h2 className="max-w-md text-display font-medium leading-tight text-white">
129
129
  {title}
130
130
  </h2>
131
131
  <div className="flex items-center justify-between gap-4">
@@ -47,7 +47,7 @@ export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles
47
47
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
48
48
  }
49
49
  >
50
- <Text slot="label" className="text-sm text-fg-muted">
50
+ <Text slot="label" className="text-small text-fg-muted">
51
51
  Drag &amp; drop {allowsMultiple ? 'files' : 'a file'} here, or
52
52
  </Text>
53
53
  <FileTrigger
@@ -59,9 +59,9 @@ export const FileUpload = ({ label, description, accept, allowsMultiple, onFiles
59
59
  Choose {allowsMultiple ? 'files' : 'file'}
60
60
  </Button>
61
61
  </FileTrigger>
62
- {names.length > 0 ? <span className="max-w-full truncate text-xs text-fg">{names.join(', ')}</span> : null}
62
+ {names.length > 0 ? <span className="max-w-full truncate text-label text-fg">{names.join(', ')}</span> : null}
63
63
  </DropZone>
64
- {description ? <span className="text-xs text-fg-muted">{description}</span> : null}
64
+ {description ? <span className="text-label text-fg-muted">{description}</span> : null}
65
65
  </div>
66
66
  )
67
67
  }
@@ -90,7 +90,7 @@ const OVERLAY_EDITOR =
90
90
 
91
91
  function IconBadge({ icon }: { icon: ReactNode }) {
92
92
  return (
93
- <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-surface-muted text-sm font-medium text-fg">
93
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-surface-muted text-small font-medium text-fg">
94
94
  {icon}
95
95
  </div>
96
96
  )
@@ -164,8 +164,8 @@ export function FocusField({
164
164
  >
165
165
  {icon ? <IconBadge icon={icon} /> : null}
166
166
  <div className="min-w-0 flex-1">
167
- <div className="text-sm text-fg-muted">{label}</div>
168
- <div className="mt-1 truncate text-base text-fg">
167
+ <div className="text-small text-fg-muted">{label}</div>
168
+ <div className="mt-1 truncate text-body text-fg">
169
169
  {hasValue ? preview : <span className="text-fg-subtle">{placeholder}</span>}
170
170
  </div>
171
171
  </div>
@@ -198,7 +198,7 @@ export function FocusField({
198
198
  <div className="flex items-start gap-3">
199
199
  {icon ? <IconBadge icon={icon} /> : null}
200
200
  <div className="min-w-0 flex-1 pr-10">
201
- <div className="text-sm text-fg-muted">{label}</div>
201
+ <div className="text-small text-fg-muted">{label}</div>
202
202
  <div className={OVERLAY_EDITOR}>{children}</div>
203
203
  </div>
204
204
  </div>
@@ -28,7 +28,7 @@ const StyledInput = uic(RACInput, {
28
28
  // Identical to textarea.tsx's filled-field skin; `fieldSize` adds the
29
29
  // single-line height (gs sizes the field via padding only).
30
30
  baseClass:
31
- 'w-full rounded-lg border border-border bg-surface px-4 text-sm text-fg ' +
31
+ 'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
32
32
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
33
33
  'data-[hovered]:border-fg-subtle ' +
34
34
  'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
@@ -64,10 +64,10 @@ export const Input = ({ label, description, errorMessage, placeholder, size, ...
64
64
  <Label className="text-heading5 font-medium text-fg">{label}</Label>
65
65
  <StyledInput placeholder={placeholder} fieldSize={size} />
66
66
  {description ? (
67
- <Text slot="description" className="text-xs text-fg-muted">
67
+ <Text slot="description" className="text-label text-fg-muted">
68
68
  {description}
69
69
  </Text>
70
70
  ) : null}
71
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
71
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
72
72
  </TextField>
73
73
  )
@@ -51,7 +51,7 @@ const MultiSelectItem = uic(ListBoxItem, {
51
51
  displayName: 'MultiSelectItem',
52
52
  // `group` so the leading Check can react to this item's data-selected.
53
53
  baseClass:
54
- 'group flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-fg outline-none ' +
54
+ 'group flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-small text-fg outline-none ' +
55
55
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
56
56
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
57
57
  }) as (props: ListBoxItemProps) => ReactNode
@@ -116,7 +116,7 @@ export const MultiSelect = ({
116
116
  selectionMode="multiple"
117
117
  selectedKeys={selected}
118
118
  onSelectionChange={handleChange}
119
- renderEmptyState={() => <div className="px-3 py-2 text-sm text-fg-muted">No matches</div>}
119
+ renderEmptyState={() => <div className="px-3 py-2 text-small text-fg-muted">No matches</div>}
120
120
  className="flex max-h-64 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
121
121
  >
122
122
  {options.map((o) => (
@@ -133,7 +133,7 @@ export const MultiSelect = ({
133
133
  <SearchField aria-label="Filter options" autoFocus className="border-b border-border p-1">
134
134
  <RACInput
135
135
  placeholder="Search…"
136
- className="w-full rounded-md bg-surface px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
136
+ className="w-full rounded-md bg-surface px-3 py-2 text-small text-fg outline-none placeholder:text-fg-muted"
137
137
  />
138
138
  </SearchField>
139
139
  {list}
@@ -142,8 +142,8 @@ export const MultiSelect = ({
142
142
  list
143
143
  )
144
144
 
145
- const desc = description ? <span className="text-xs text-fg-muted">{description}</span> : null
146
- const err = isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null
145
+ const desc = description ? <span className="text-label text-fg-muted">{description}</span> : null
146
+ const err = isInvalid && errorMessage ? <span className="text-label text-danger">{errorMessage}</span> : null
147
147
 
148
148
  return (
149
149
  <div className={clsx('flex flex-col gap-2', className)}>
@@ -165,7 +165,7 @@ export const MultiSelect = ({
165
165
  isDisabled={isDisabled}
166
166
  onPress={() => setOpen(true)}
167
167
  className={clsx(
168
- 'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border bg-surface px-4 text-sm text-fg outline-none transition-colors',
168
+ 'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border bg-surface px-4 text-small text-fg outline-none transition-colors',
169
169
  'data-[hovered]:border-fg-subtle data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
170
170
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
171
171
  isInvalid ? 'border-danger ring-2 ring-danger' : 'border-border',
@@ -16,7 +16,7 @@ import {
16
16
  * Components `NumberField`; styled to match the other form fields.
17
17
  */
18
18
  const stepper =
19
- 'flex h-full w-9 shrink-0 items-center justify-center text-base text-fg-muted outline-none transition-colors ' +
19
+ 'flex h-full w-9 shrink-0 items-center justify-center text-body text-fg-muted outline-none transition-colors ' +
20
20
  'hover:bg-surface-muted hover:text-fg data-[pressed]:bg-surface-muted data-[disabled]:opacity-40'
21
21
 
22
22
  export type NumberFieldProps = RACNumberFieldProps & {
@@ -32,7 +32,7 @@ export const NumberField = ({ label, description, errorMessage, placeholder, ...
32
32
  <Label className="text-heading5 font-medium text-fg">{label}</Label>
33
33
  <Group
34
34
  className={
35
- 'flex h-12 w-full items-center overflow-hidden rounded-lg border border-border bg-surface text-sm text-fg transition-colors ' +
35
+ 'flex h-12 w-full items-center overflow-hidden rounded-lg border border-border bg-surface text-small text-fg transition-colors ' +
36
36
  'data-[hovered]:border-fg-subtle ' +
37
37
  'data-[focus-within]:border-brand-green data-[focus-within]:ring-2 data-[focus-within]:ring-ring ' +
38
38
  'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
@@ -44,17 +44,17 @@ export const NumberField = ({ label, description, errorMessage, placeholder, ...
44
44
  </RACButton>
45
45
  <RACInput
46
46
  placeholder={placeholder}
47
- className="min-w-0 flex-1 bg-transparent px-4 text-sm tabular-nums text-fg outline-none placeholder:text-fg-muted"
47
+ className="min-w-0 flex-1 bg-transparent px-4 text-small tabular-nums text-fg outline-none placeholder:text-fg-muted"
48
48
  />
49
49
  <RACButton slot="increment" className={`${stepper} border-l border-border`}>
50
50
  +
51
51
  </RACButton>
52
52
  </Group>
53
53
  {description ? (
54
- <Text slot="description" className="text-xs text-fg-muted">
54
+ <Text slot="description" className="text-label text-fg-muted">
55
55
  {description}
56
56
  </Text>
57
57
  ) : null}
58
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
58
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
59
59
  </RACNumberField>
60
60
  )
@@ -31,14 +31,14 @@ export type RadioGroupProps = RACRadioGroupProps & {
31
31
 
32
32
  export const RadioGroup = ({ label, description, errorMessage, children, ...props }: RadioGroupProps) => (
33
33
  <RACRadioGroup {...props} className="flex flex-col gap-2">
34
- {label ? <Label className="text-sm font-medium text-fg">{label}</Label> : null}
34
+ {label ? <Label className="text-small font-medium text-fg">{label}</Label> : null}
35
35
  <div className="flex flex-col gap-3">{children}</div>
36
36
  {description ? (
37
- <Text slot="description" className="text-xs text-fg-muted">
37
+ <Text slot="description" className="text-label text-fg-muted">
38
38
  {description}
39
39
  </Text>
40
40
  ) : null}
41
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
41
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
42
42
  </RACRadioGroup>
43
43
  )
44
44
 
@@ -50,7 +50,7 @@ export type RadioProps = Omit<RACRadioProps, 'children'> & {
50
50
  export const Radio = ({ children, ...props }: RadioProps) => (
51
51
  <RACRadio
52
52
  {...props}
53
- className="group flex cursor-pointer select-none items-center gap-3 text-sm text-fg data-[disabled]:cursor-not-allowed"
53
+ className="group flex cursor-pointer select-none items-center gap-3 text-small text-fg data-[disabled]:cursor-not-allowed"
54
54
  >
55
55
  {/* gs token map: border #eceae1 → border · hover #aba89c → fg-subtle ·
56
56
  selected dot/border #0d0d0d → fg · error → danger. */}
@@ -87,8 +87,8 @@ export function RequestChangesModal({
87
87
  }}
88
88
  >
89
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">
90
+ <p className="text-small text-fg-muted">{labels.body}</p>
91
+ <label htmlFor={noteId} className="text-small font-medium text-fg">
92
92
  {labels.noteLabel}
93
93
  </label>
94
94
  <textarea
@@ -101,10 +101,10 @@ export function RequestChangesModal({
101
101
  required
102
102
  aria-invalid={isEmpty ? true : undefined}
103
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"
104
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-small text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
105
105
  />
106
106
  {error ? (
107
- <p data-testid="request-changes-error" role="alert" className="text-sm text-danger">
107
+ <p data-testid="request-changes-error" role="alert" className="text-small text-danger">
108
108
  {error}
109
109
  </p>
110
110
  ) : null}
@@ -137,7 +137,7 @@ export function RichTextEditor({
137
137
  title={t.title}
138
138
  onMouseDown={(e) => e.preventDefault()}
139
139
  onClick={() => t.run(exec)}
140
- className="rounded border-0 bg-transparent px-2.5 py-0.5 text-xs text-fg-muted transition-colors hover:bg-surface hover:text-fg"
140
+ className="rounded border-0 bg-transparent px-2.5 py-0.5 text-label text-fg-muted transition-colors hover:bg-surface hover:text-fg"
141
141
  >
142
142
  {t.label}
143
143
  </button>
@@ -145,7 +145,7 @@ export function RichTextEditor({
145
145
  </div>
146
146
  <div
147
147
  ref={ref}
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)]"
148
+ className="prose prose-sm max-w-none px-4 py-3.5 text-small leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
149
149
  style={{ minHeight: bodyMinHeight }}
150
150
  contentEditable
151
151
  suppressContentEditableWarning
@@ -155,8 +155,8 @@ export function RichTextEditor({
155
155
  onPaste={onPaste}
156
156
  />
157
157
  </div>
158
- {description ? <span className="text-xs text-fg-muted">{description}</span> : null}
159
- {isInvalid && errorMessage ? <span className="text-xs text-danger">{errorMessage}</span> : null}
158
+ {description ? <span className="text-label text-fg-muted">{description}</span> : null}
159
+ {isInvalid && errorMessage ? <span className="text-label text-danger">{errorMessage}</span> : null}
160
160
  </div>
161
161
  )
162
162
  }
@@ -44,7 +44,7 @@ export const SearchField = ({ label, description, placeholder, ...props }: Searc
44
44
  <RACInput
45
45
  placeholder={placeholder}
46
46
  className={
47
- 'h-12 w-full rounded-lg border border-border bg-surface pl-10 pr-10 text-sm text-fg outline-none transition-colors ' +
47
+ 'h-12 w-full rounded-lg border border-border bg-surface pl-10 pr-10 text-small text-fg outline-none transition-colors ' +
48
48
  'placeholder:text-fg-muted data-[hovered]:border-fg-subtle ' +
49
49
  'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
50
50
  '[&::-webkit-search-cancel-button]:hidden'
@@ -58,7 +58,7 @@ export const SearchField = ({ label, description, placeholder, ...props }: Searc
58
58
  </RACButton>
59
59
  </div>
60
60
  {description ? (
61
- <Text slot="description" className="text-xs text-fg-muted">
61
+ <Text slot="description" className="text-label text-fg-muted">
62
62
  {description}
63
63
  </Text>
64
64
  ) : null}
@@ -36,7 +36,7 @@ const SelectTrigger = uic(RACButton, {
36
36
  // Error → 2px danger ring driven off the RACSelect root's `data-invalid`.
37
37
  baseClass:
38
38
  'flex h-12 w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-4 ' +
39
- 'text-sm text-fg outline-none transition-colors ' +
39
+ 'text-small text-fg outline-none transition-colors ' +
40
40
  'data-[hovered]:border-fg-subtle ' +
41
41
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
42
42
  'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
@@ -49,7 +49,7 @@ export const SelectItem = uic(ListBoxItem, {
49
49
  // medium weight. We add a `surface-muted` focus background (gs highlights with
50
50
  // weight only) so keyboard focus stays clearly visible on the cream content.
51
51
  baseClass:
52
- 'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm text-fg outline-none ' +
52
+ 'flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-small text-fg outline-none ' +
53
53
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
54
54
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
55
55
  }) as (props: ListBoxItemProps) => ReactNode
@@ -84,11 +84,11 @@ export const Select = <T extends object>({
84
84
  </ListBox>
85
85
  )
86
86
  const desc = description ? (
87
- <Text slot="description" className="text-xs text-fg-muted">
87
+ <Text slot="description" className="text-label text-fg-muted">
88
88
  {description}
89
89
  </Text>
90
90
  ) : null
91
- const err = <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
91
+ const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
92
92
 
93
93
  return (
94
94
  <RACSelect {...props} placeholder={placeholder} className="group flex flex-col gap-2">
@@ -37,7 +37,7 @@ export const Slider = <T extends number | number[]>({ label, hideValue, ...props
37
37
  className="h-4 w-4 rounded-full border-2 border-fg bg-surface outline-none transition-transform data-[dragging]:scale-110 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring"
38
38
  >
39
39
  {hideValue ? null : (
40
- <span className="pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium tabular-nums text-fg">
40
+ <span className="pointer-events-none absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-label font-medium tabular-nums text-fg">
41
41
  {state.getThumbValueLabel(i)}
42
42
  </span>
43
43
  )}
@@ -0,0 +1,61 @@
1
+ import type { ReactNode } from 'react'
2
+ import { CountUp } from './count-up'
3
+
4
+ /**
5
+ * StatStack — a "production compass" infographic: a fanned deck of coloured rounded
6
+ * bars, each showing an animated count + label (e.g. 55 Progress · 36 Done · 26
7
+ * Review). Bars cascade with a left inset that shrinks top→bottom and overlap
8
+ * vertically, later bars in front. The counts animate up via <CountUp>. Sits inside a
9
+ * dark data tile. Presentational only.
10
+ */
11
+ export type StatStackTone = 'neutral' | 'green' | 'mint' | 'yellow' | 'pink' | 'blue'
12
+
13
+ const TONE: Record<StatStackTone, string> = {
14
+ // Fixed decorative chip colours (they sit on a dark tile in both themes, so they
15
+ // do NOT theme-flip); dark text reads on all of them.
16
+ neutral: 'bg-[#eae7e0] text-fg',
17
+ green: 'bg-brand-green text-fg',
18
+ mint: 'bg-accent-mint text-fg',
19
+ yellow: 'bg-accent-yellow text-fg',
20
+ pink: 'bg-accent-pink text-fg',
21
+ blue: 'bg-accent-blue text-white',
22
+ }
23
+
24
+ export interface StatStackSegment {
25
+ value: number
26
+ label: ReactNode
27
+ tone: StatStackTone
28
+ }
29
+
30
+ export interface StatStackProps {
31
+ segments: StatStackSegment[]
32
+ className?: string
33
+ }
34
+
35
+ export function StatStack({ segments, className }: StatStackProps) {
36
+ const n = segments.length
37
+ return (
38
+ <div className={['flex flex-col', className].filter(Boolean).join(' ')}>
39
+ {segments.map((s, i) => (
40
+ <div
41
+ key={i}
42
+ className={[
43
+ 'flex items-baseline gap-2 rounded-lg px-5 py-4 shadow-sm',
44
+ TONE[s.tone],
45
+ ].join(' ')}
46
+ style={{
47
+ // Cascade: most-inset at the top, flush-left at the bottom; overlap the
48
+ // bar above; later bars sit in front.
49
+ marginLeft: `${(n - 1 - i) * 1.75}rem`,
50
+ marginRight: `${i * 1.25}rem`,
51
+ marginTop: i === 0 ? 0 : '-1rem',
52
+ zIndex: i + 1,
53
+ }}
54
+ >
55
+ <CountUp value={s.value} className="text-heading1 font-medium leading-none" />
56
+ <span className="text-heading3 font-medium leading-none">{s.label}</span>
57
+ </div>
58
+ ))}
59
+ </div>
60
+ )
61
+ }
@@ -19,7 +19,7 @@ export type SwitchProps = Omit<RACSwitchProps, 'children'> & {
19
19
  export const Switch = ({ children, ...props }: SwitchProps) => (
20
20
  <RACSwitch
21
21
  {...props}
22
- className="group flex cursor-pointer select-none items-center gap-3 text-sm text-fg data-[disabled]:cursor-not-allowed"
22
+ className="group flex cursor-pointer select-none items-center gap-3 text-small text-fg data-[disabled]:cursor-not-allowed"
23
23
  >
24
24
  {/* gs token map: off track #eceae1 → surface-muted · on track #0d0d0d → fg ·
25
25
  thumb #ffffff → surface. gs hovers the track at opacity .9 (not a border). */}
@@ -26,7 +26,7 @@ export const TabList = uic(RACTabList, {
26
26
  export const Tab = uic(RACTab, {
27
27
  displayName: 'Tab',
28
28
  baseClass:
29
- 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-sm font-medium text-fg-muted ' +
29
+ 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-small font-medium text-fg-muted ' +
30
30
  'outline-none transition-colors ' +
31
31
  'data-[hovered]:text-fg ' +
32
32
  'data-[selected]:border-brand-primary data-[selected]:text-fg ' +
@@ -36,5 +36,5 @@ export const Tab = uic(RACTab, {
36
36
 
37
37
  export const TabPanel = uic(RACTabPanel, {
38
38
  displayName: 'TabPanel',
39
- baseClass: 'py-4 text-sm text-fg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
39
+ baseClass: 'py-4 text-small text-fg outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
40
40
  }) as (props: TabPanelProps) => ReturnType<typeof RACTabPanel>
@@ -26,7 +26,7 @@ export const TagGroup = ({ label, description, children, ...props }: TagGroupPro
26
26
  {label ? <Label className="text-heading5 font-medium text-fg">{label}</Label> : null}
27
27
  <TagList className="flex flex-wrap gap-2 outline-none">{children}</TagList>
28
28
  {description ? (
29
- <Text slot="description" className="text-xs text-fg-muted">
29
+ <Text slot="description" className="text-label text-fg-muted">
30
30
  {description}
31
31
  </Text>
32
32
  ) : null}
@@ -40,7 +40,7 @@ export const Tag = ({ children, textValue, ...props }: RACTagProps) => (
40
40
  // the tag's text — derive it from a string child for accessibility.
41
41
  textValue={textValue ?? (typeof children === 'string' ? children : undefined)}
42
42
  className={
43
- 'inline-flex cursor-default select-none items-center gap-1.5 rounded-full border border-border bg-surface-card px-3 py-1 text-sm text-fg outline-none transition-colors ' +
43
+ 'inline-flex cursor-default select-none items-center gap-1.5 rounded-full border border-border bg-surface-card px-3 py-1 text-small text-fg outline-none transition-colors ' +
44
44
  'data-[hovered]:border-fg-subtle ' +
45
45
  'data-[selected]:border-fg data-[selected]:bg-surface-muted ' +
46
46
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
@@ -79,8 +79,8 @@ export function TaskApprovalModal({
79
79
  onOpenChange={onOpenChange}
80
80
  >
81
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">
82
+ <p className="text-small text-fg-muted">{labels.body}</p>
83
+ <label htmlFor={noteId} className="text-small font-medium text-fg">
84
84
  {labels.noteLabel}
85
85
  </label>
86
86
  <textarea
@@ -90,10 +90,10 @@ export function TaskApprovalModal({
90
90
  maxLength={10_000}
91
91
  rows={3}
92
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"
93
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-small text-fg outline-none transition-colors placeholder:text-fg-muted focus-visible:ring-2 focus-visible:ring-ring"
94
94
  />
95
95
  {error ? (
96
- <p data-testid="task-approval-error" role="alert" className="text-sm text-danger">
96
+ <p data-testid="task-approval-error" role="alert" className="text-small text-danger">
97
97
  {error}
98
98
  </p>
99
99
  ) : null}
@@ -27,15 +27,15 @@ export const Text = uic('span', {
27
27
  size: {
28
28
  micro: 'text-micro',
29
29
  caption: 'text-caption',
30
- label: 'text-compact',
30
+ label: 'text-label',
31
31
  compact: 'text-compact',
32
+ small: 'text-small',
32
33
  callout: 'text-callout',
33
34
  body: 'text-body',
34
- subtitle: 'text-heading4',
35
- title: 'text-heading3',
36
- headline: 'text-heading2',
35
+ subtitle: 'text-subtitle',
36
+ title: 'text-title',
37
+ headline: 'text-headline',
37
38
  display: 'text-display',
38
- 'display-xl': 'text-display-xl',
39
39
  },
40
40
  // NC Fontina caps at medium (500) as the heaviest weight in the system, so
41
41
  // `semibold` and `bold` are kept as API-compatible aliases that render
@@ -27,7 +27,7 @@ const StyledTextArea = uic(RACTextArea, {
27
27
  // fg-subtle · focus #75e7b8 → brand-green · error → danger. min-h-[120px] is a
28
28
  // control dimension (not a design token) — gs uses a literal 120px here too.
29
29
  baseClass:
30
- 'min-h-[120px] w-full resize-y rounded-lg border border-border bg-surface px-4 py-3 text-sm text-fg ' +
30
+ 'min-h-[120px] w-full resize-y rounded-lg border border-border bg-surface px-4 py-3 text-small text-fg ' +
31
31
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
32
32
  'data-[hovered]:border-fg-subtle ' +
33
33
  'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
@@ -49,13 +49,13 @@ export type TextareaProps = TextFieldProps & {
49
49
 
50
50
  export const Textarea = ({ label, description, errorMessage, placeholder, rows, ...props }: TextareaProps) => (
51
51
  <TextField {...props} className="flex w-full flex-col gap-2">
52
- <Label className="text-sm font-medium text-fg">{label}</Label>
52
+ <Label className="text-small font-medium text-fg">{label}</Label>
53
53
  <StyledTextArea placeholder={placeholder} rows={rows} />
54
54
  {description ? (
55
- <Text slot="description" className="text-xs text-fg-muted">
55
+ <Text slot="description" className="text-label text-fg-muted">
56
56
  {description}
57
57
  </Text>
58
58
  ) : null}
59
- <FieldError className="text-xs text-danger">{errorMessage}</FieldError>
59
+ <FieldError className="text-label text-danger">{errorMessage}</FieldError>
60
60
  </TextField>
61
61
  )
package/src/index.ts CHANGED
@@ -52,8 +52,10 @@ export * from "./layout/persistent-page-shell";
52
52
 
53
53
  // --- product patterns (label-driven; no domain coupling) ---
54
54
  export * from "./components/brand-page-header";
55
+ export * from "./components/count-up";
55
56
  export * from "./components/cta-pill";
56
57
  export * from "./components/feature-tile";
58
+ export * from "./components/stat-stack";
57
59
  export * from "./components/icons";
58
60
  export * from "./components/subtle";
59
61
  export * from "./components/stats-card";
@@ -51,9 +51,9 @@ export type PersistentPageShellProps = {
51
51
  title?: string
52
52
  }
53
53
 
54
- const defaultLoadingSlot = <div className="text-sm text-fg-muted">Loading…</div>
55
- const defaultEmptySlot = <div className="text-sm text-fg-muted">No content available.</div>
56
- const defaultErrorSlot = <div className="text-sm text-danger">Failed to load content.</div>
54
+ const defaultLoadingSlot = <div className="text-small text-fg-muted">Loading…</div>
55
+ const defaultEmptySlot = <div className="text-small text-fg-muted">No content available.</div>
56
+ const defaultErrorSlot = <div className="text-small text-danger">Failed to load content.</div>
57
57
 
58
58
  const Header = uic('header', {
59
59
  displayName: 'PersistentPageShell.Header',
@@ -138,11 +138,11 @@ export const WorkspaceSwitcher = ({
138
138
  ...props
139
139
  }: WorkspaceSwitcherProps) => (
140
140
  <RACComboBox {...props} items={workspaces} className="flex flex-col gap-1">
141
- <Label className={hideLabel ? 'sr-only' : 'text-sm font-medium text-fg'}>{label}</Label>
141
+ <Label className={hideLabel ? 'sr-only' : 'text-small font-medium text-fg'}>{label}</Label>
142
142
  <div className="flex items-center">
143
143
  <RACInput
144
144
  className={
145
- 'h-9 w-48 rounded-md border border-border bg-surface px-3 text-sm text-fg outline-none ' +
145
+ 'h-9 w-48 rounded-md border border-border bg-surface px-3 text-small text-fg outline-none ' +
146
146
  'data-[focused]:ring-2 data-[focused]:ring-ring'
147
147
  }
148
148
  />
@@ -162,7 +162,7 @@ export const WorkspaceSwitcher = ({
162
162
  const WorkspaceSwitcherItem = uic(ListBoxItem, {
163
163
  displayName: 'WorkspaceSwitcher.Item',
164
164
  baseClass:
165
- 'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm text-fg outline-none ' +
165
+ 'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-small text-fg outline-none ' +
166
166
  'data-[focused]:bg-surface-muted data-[selected]:font-medium',
167
167
  }) as (props: ListBoxItemProps) => ReactNode
168
168
 
@@ -190,7 +190,7 @@ export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenu
190
190
  <RACButton
191
191
  aria-label={triggerLabel}
192
192
  className={
193
- 'flex h-9 items-center gap-2 rounded-md px-2 text-sm font-medium text-fg outline-none ' +
193
+ 'flex h-9 items-center gap-2 rounded-md px-2 text-small font-medium text-fg outline-none ' +
194
194
  'transition-colors hover:bg-surface-muted ' +
195
195
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
196
196
  }
@@ -208,7 +208,7 @@ export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenu
208
208
  export const UserMenuItem = uic(RACMenuItem, {
209
209
  displayName: 'UserMenu.Item',
210
210
  baseClass:
211
- 'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-sm text-fg outline-none ' +
211
+ 'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-small text-fg outline-none ' +
212
212
  'data-[focused]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
213
213
  }) as (props: MenuItemProps) => ReactNode
214
214