@startsimpli/ui 0.4.92 → 0.4.94

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 (25) hide show
  1. package/package.json +3 -3
  2. package/src/components/dashboard/DashboardSection.tsx +36 -5
  3. package/src/components/dashboard/MetricCard.tsx +4 -1
  4. package/src/components/dashboard/StatTile.tsx +20 -1
  5. package/src/components/states/Absence.tsx +156 -0
  6. package/src/components/states/Caveat.tsx +167 -0
  7. package/src/components/states/EmptyState.tsx +39 -10
  8. package/src/components/states/Footnote.tsx +171 -0
  9. package/src/components/states/__tests__/caveat-and-absence.test.tsx +662 -0
  10. package/src/components/states/index.ts +7 -1
  11. package/src/components/ui/card.tsx +37 -6
  12. package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +62 -5
  13. package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +22 -0
  14. package/src/components/unified-table/__tests__/utils/border-tokens.test.ts +61 -0
  15. package/src/components/unified-table/components/BulkActionBar/index.tsx +1 -1
  16. package/src/components/unified-table/components/DataTableCore/index.tsx +1 -1
  17. package/src/components/unified-table/components/MobileView/Card.tsx +1 -1
  18. package/src/components/unified-table/components/TableFilters/index.tsx +2 -2
  19. package/src/components/unified-table/components/TablePagination/index.tsx +19 -5
  20. package/src/components/unified-table/hooks/usePagination.ts +12 -0
  21. package/src/components/unified-table/utils/themes.ts +8 -5
  22. package/src/theme/__tests__/type-scale.test.tsx +145 -0
  23. package/src/theme/contract.ts +5 -0
  24. package/src/theme/index.ts +10 -0
  25. package/src/theme/type-scale.ts +119 -0
@@ -1,6 +1,8 @@
1
1
  import * as React from "react"
2
2
 
3
3
  import { cn } from "../../lib/utils"
4
+ import { Caveat } from "../states/Caveat"
5
+ import { Footnote } from "../states/Footnote"
4
6
 
5
7
  const Card = React.forwardRef<
6
8
  HTMLDivElement,
@@ -17,12 +19,41 @@ const Card = React.forwardRef<
17
19
  ))
18
20
  Card.displayName = "Card"
19
21
 
20
- const CardHeader = React.forwardRef<
21
- HTMLDivElement,
22
- React.HTMLAttributes<HTMLDivElement>
23
- >(({ className, ...props }, ref) => (
24
- <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
25
- ))
22
+ export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
23
+ /**
24
+ * A qualification of what this card shows. Collapses to a marker at the end
25
+ * of the header and expands on demand — the card's canonical caveat slot, so
26
+ * apps stop putting the caveat in the body at the weight of the data.
27
+ */
28
+ caveat?: React.ReactNode
29
+ /**
30
+ * A caveat that belongs to the whole section. Inside a `FootnoteScope` it is
31
+ * printed once beneath the section rather than once per card.
32
+ */
33
+ note?: string
34
+ }
35
+
36
+ const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
37
+ ({ className, caveat, note, children, ...props }, ref) => {
38
+ const hasCaveat = Boolean(caveat || note)
39
+
40
+ return (
41
+ <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props}>
42
+ {hasCaveat ? (
43
+ <div className="flex items-start justify-between gap-2">
44
+ <div className="flex min-w-0 flex-col space-y-1.5">{children}</div>
45
+ <span className="flex shrink-0 items-center">
46
+ {caveat ? <Caveat>{caveat}</Caveat> : null}
47
+ {note ? <Footnote note={note} /> : null}
48
+ </span>
49
+ </div>
50
+ ) : (
51
+ children
52
+ )}
53
+ </div>
54
+ )
55
+ }
56
+ )
26
57
  CardHeader.displayName = "CardHeader"
27
58
 
28
59
  const CardTitle = React.forwardRef<
@@ -316,18 +316,75 @@ describe('TablePagination', () => {
316
316
  })
317
317
  })
318
318
 
319
- describe('Edge Cases', () => {
320
- it('should handle zero total count', () => {
321
- const pagination = createMockPagination({
319
+ // An empty table has nothing to count. Every number on this line is either
320
+ // wrong or noise, so the line becomes a sentence instead of arithmetic.
321
+ describe('Empty result set', () => {
322
+ const emptyPagination = (overrides?: Partial<UsePaginationReturn>) =>
323
+ createMockPagination({
322
324
  totalCount: 0,
323
325
  totalPages: 1,
326
+ canGoNext: false,
327
+ canGoPrevious: false,
324
328
  getDisplayRange: jest.fn(() => ({ start: 0, end: 0 })),
329
+ ...overrides,
325
330
  })
326
- render(<TablePagination pagination={pagination} />)
327
331
 
328
- expect(screen.getByText(/0-0/)).toBeInTheDocument()
332
+ it('states that there is nothing rather than printing a range', () => {
333
+ const { container } = render(
334
+ <TablePagination pagination={emptyPagination()} totalFilteredCount={0} />
335
+ )
336
+
337
+ const countLine = container.querySelector('.text-muted-foreground')
338
+ expect(countLine?.textContent).toBe('No items')
339
+ })
340
+
341
+ it('never prints a range whose start is past its end', () => {
342
+ const { container } = render(
343
+ <TablePagination
344
+ pagination={emptyPagination({
345
+ // What the unfixed hook hands the component on an empty table.
346
+ getDisplayRange: jest.fn(() => ({ start: 1, end: 0 })),
347
+ })}
348
+ />
349
+ )
350
+
351
+ expect(container.textContent).not.toContain('1-0')
352
+ })
353
+
354
+ it('does not leak a stray 0 out of the filtered-count guard', () => {
355
+ // `{count && <span/>}` renders the NUMBER 0 when count is 0 — that is the
356
+ // second 0 in "of 00".
357
+ const { container } = render(
358
+ <TablePagination pagination={emptyPagination()} totalFilteredCount={0} />
359
+ )
360
+
361
+ expect(container.textContent).not.toMatch(/00/)
362
+ expect(container.textContent).toBe('No items')
363
+ })
364
+
365
+ it('does not leak a stray 0 out of the select-all guard', () => {
366
+ const { container } = render(
367
+ <TablePagination
368
+ pagination={emptyPagination()}
369
+ showSelectAllPrompt
370
+ onSelectAllPages={jest.fn()}
371
+ totalFilteredCount={0}
372
+ />
373
+ )
374
+
375
+ expect(container.textContent).toBe('No items')
376
+ expect(screen.queryByText(/Select all/)).not.toBeInTheDocument()
329
377
  })
330
378
 
379
+ it('hides page controls when there is nothing to page through', () => {
380
+ render(<TablePagination pagination={emptyPagination()} />)
381
+
382
+ expect(screen.queryByTitle('First page')).not.toBeInTheDocument()
383
+ expect(screen.queryByTitle('Next page')).not.toBeInTheDocument()
384
+ })
385
+ })
386
+
387
+ describe('Edge Cases', () => {
331
388
  it('should handle single item', () => {
332
389
  const pagination = createMockPagination({
333
390
  totalCount: 1,
@@ -341,6 +341,28 @@ describe('usePagination', () => {
341
341
  const range = result.current.getDisplayRange()
342
342
  expect(range).toEqual({ start: 51, end: 75 })
343
343
  })
344
+
345
+ it('should report an empty range when there are no rows at all', () => {
346
+ const { result } = renderHook(() =>
347
+ usePagination({ ...defaultProps, totalCount: 0 })
348
+ )
349
+
350
+ // A range that begins at 1 and ends at 0 describes nothing; it is the
351
+ // arithmetic leaking out. Nothing shown means nothing on both ends.
352
+ const range = result.current.getDisplayRange()
353
+ expect(range).toEqual({ start: 0, end: 0 })
354
+ expect(range.start).not.toBeGreaterThan(range.end)
355
+ })
356
+
357
+ it('should report an empty range when the page sits past the last row', () => {
358
+ // Server-side counts shrink under a page that is already open (rule 8:
359
+ // the count comes from the server, the client never recounts).
360
+ const { result } = renderHook(() =>
361
+ usePagination({ ...defaultProps, totalCount: 10, initialPage: 2 })
362
+ )
363
+
364
+ expect(result.current.getDisplayRange()).toEqual({ start: 0, end: 0 })
365
+ })
344
366
  })
345
367
 
346
368
  describe('Server-Side Pagination', () => {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Every border in the table names its colour (startsim-768w.35.2.4).
3
+ *
4
+ * In Tailwind v4 a bare `border` / `border-b` sets a width and leaves the
5
+ * colour at `currentColor`, so the rule is painted in the TEXT colour of
6
+ * whatever it sits in. It is quiet on a white page — the rows just look heavier
7
+ * than intended — and loud on a dark tenant, where every row separator turns
8
+ * near-white. Browser-confirmed on both surfaces before this guard existed.
9
+ *
10
+ * The fix is always the same: say `border-border` (or a token shade like
11
+ * `border-border/50`) next to the width utility.
12
+ */
13
+
14
+ import * as fs from 'fs'
15
+ import * as path from 'path'
16
+
17
+ const TABLE_SRC = path.resolve(__dirname, '../..')
18
+
19
+ /** `border`, `border-t`, `border-x` — width with no colour. */
20
+ const BARE_BORDER = /^border(-[trblxy])?$/
21
+ /** Anything that names a colour: `border-border`, `border-primary`, `border-[#fff]`. */
22
+ const NAMED_COLOR = /^([a-z-]+:)*border-(?!\d|[trblxy]$)/
23
+
24
+ function sourceFiles(dir: string, acc: string[] = []): string[] {
25
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ const full = path.join(dir, entry.name)
27
+ if (entry.isDirectory()) {
28
+ if (entry.name === '__tests__' || entry.name === 'node_modules') continue
29
+ sourceFiles(full, acc)
30
+ } else if (/\.tsx?$/.test(entry.name)) {
31
+ acc.push(full)
32
+ }
33
+ }
34
+ return acc
35
+ }
36
+
37
+ function offenders(): string[] {
38
+ const found: string[] = []
39
+ for (const file of sourceFiles(TABLE_SRC)) {
40
+ fs.readFileSync(file, 'utf8')
41
+ .split('\n')
42
+ .forEach((rawLine, i) => {
43
+ // Prose in a doc comment may legitimately name the banned class.
44
+ const line = rawLine.replace(/^\s*(\/\/|\/\*|\*).*$/, '')
45
+ for (const quoted of line.match(/'[^']*'|"[^"]*"|`[^`]*`/g) ?? []) {
46
+ const tokens = quoted.slice(1, -1).split(/\s+/)
47
+ const bare = tokens.filter((t) => BARE_BORDER.test(t))
48
+ if (!bare.length) continue
49
+ if (tokens.some((t) => NAMED_COLOR.test(t))) continue
50
+ found.push(`${path.relative(TABLE_SRC, file)}:${i + 1} ${bare.join(' ')}`)
51
+ }
52
+ })
53
+ }
54
+ return found
55
+ }
56
+
57
+ describe('unified-table borders are theme-coloured, not currentColor', () => {
58
+ it('no border utility ships without a colour token beside it', () => {
59
+ expect(offenders()).toEqual([])
60
+ })
61
+ })
@@ -54,7 +54,7 @@ export function BulkActionBar({
54
54
  )}
55
55
  aria-hidden={!isVisible}
56
56
  >
57
- <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border">
57
+ <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
58
58
  {/* Selection summary */}
59
59
  <div className="flex items-center gap-2 text-sm font-medium">
60
60
  {selectAllPages ? (
@@ -330,7 +330,7 @@ export function DataTableCore<TData>({
330
330
  return (
331
331
  // overflow-x-auto: a wide table scrolls horizontally inside its own border
332
332
  // instead of overflowing the page to the right (startsim column-overflow).
333
- <div className={cn('rounded-md border overflow-x-auto', className)}>
333
+ <div className={cn('rounded-md border border-border overflow-x-auto', className)}>
334
334
  <Table>
335
335
  {renderColumnHeaders()}
336
336
 
@@ -186,7 +186,7 @@ export function Card<TData = any>({
186
186
  )}
187
187
 
188
188
  {secondaryFields && secondaryFields.length > 0 && (
189
- <div className="border-t pt-3">
189
+ <div className="border-t border-border pt-3">
190
190
  <div className="grid grid-cols-2 gap-3">
191
191
  {secondaryFields.map((field) => (
192
192
  <div
@@ -157,7 +157,7 @@ export function TableFilters({ config, filters, className, defaultExpanded = fal
157
157
  const isExpanded = expandedSections.has(section.id)
158
158
 
159
159
  return (
160
- <div key={section.id} className="border rounded-lg p-3">
160
+ <div key={section.id} className="border border-border rounded-lg p-3">
161
161
  <Button
162
162
  variant="ghost"
163
163
  size="sm"
@@ -214,7 +214,7 @@ export function TableFilters({ config, filters, className, defaultExpanded = fal
214
214
  const activeFilterCount = filters.getActiveFilterCount()
215
215
 
216
216
  return (
217
- <div className={cn('rounded-lg border bg-muted/30', className)}>
217
+ <div className={cn('rounded-lg border border-border bg-muted/30', className)}>
218
218
  {/* Collapsible Header */}
219
219
  <button
220
220
  type="button"
@@ -37,15 +37,29 @@ export function TablePagination({
37
37
  const { start, end } = getDisplayRange()
38
38
  const pageNumbers = getPageNumbers()
39
39
 
40
+ // Nothing to count: a range would read "1-0 of 0". Say it in words instead.
41
+ const isEmpty = totalCount <= 0 || end < start || end <= 0
42
+ // `count && <jsx/>` renders the NUMBER 0 when count is 0 — compare explicitly.
43
+ const showFilteredNote =
44
+ totalFilteredCount != null && totalFilteredCount !== totalCount
45
+ const showSelectAll =
46
+ Boolean(showSelectAllPrompt) &&
47
+ Boolean(onSelectAllPages) &&
48
+ (totalFilteredCount ?? 0) > end
49
+
40
50
  return (
41
51
  <div className={className}>
42
52
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
43
53
  {/* Item count display */}
44
54
  <div className="text-sm text-muted-foreground">
45
- Showing <span className="font-medium">{start}-{end}</span> of{' '}
46
- <span className="font-medium">{totalCount}</span>
47
- {totalFilteredCount && totalFilteredCount !== totalCount && (
48
- <span> filtered items</span>
55
+ {isEmpty ? (
56
+ 'No items'
57
+ ) : (
58
+ <>
59
+ Showing <span className="font-medium">{start}-{end}</span> of{' '}
60
+ <span className="font-medium">{totalCount}</span>
61
+ {showFilteredNote && <span> filtered items</span>}
62
+ </>
49
63
  )}
50
64
  </div>
51
65
 
@@ -137,7 +151,7 @@ export function TablePagination({
137
151
  </div>
138
152
 
139
153
  {/* Select all pages prompt */}
140
- {showSelectAllPrompt && onSelectAllPages && totalFilteredCount && totalFilteredCount > end && (
154
+ {showSelectAll && (
141
155
  <div className="mt-3 flex items-center justify-center p-3 bg-blue-50 border border-blue-200 rounded-lg">
142
156
  <span className="text-sm text-blue-900 mr-2">
143
157
  Select all <span className="font-semibold">{totalFilteredCount}</span> items?
@@ -97,9 +97,21 @@ export function usePagination({
97
97
  return pages
98
98
  }, [currentPage, totalPages])
99
99
 
100
+ /**
101
+ * The 1-based span of rows on screen, or `{ start: 0, end: 0 }` when there is
102
+ * no span to describe. `totalCount` is whatever the server reported, so it can
103
+ * legitimately be 0, or drop below the page the user already has open — both
104
+ * cases used to produce a backwards range like `1-0`.
105
+ */
100
106
  const getDisplayRange = useCallback(() => {
107
+ if (totalCount <= 0) return { start: 0, end: 0 }
108
+
101
109
  const start = (currentPage - 1) * pageSize + 1
102
110
  const end = Math.min(currentPage * pageSize, totalCount)
111
+
112
+ // The page begins past the last row: nothing is shown here.
113
+ if (start > end) return { start: 0, end: 0 }
114
+
103
115
  return { start, end }
104
116
  }, [currentPage, pageSize, totalCount])
105
117
 
@@ -15,19 +15,22 @@ export interface TableTheme {
15
15
  mobileCardContent: string
16
16
  }
17
17
 
18
+ // Every rule here names its border colour. A bare `border` / `border-b` takes
19
+ // currentColor in Tailwind v4, which paints table rules in the TEXT colour —
20
+ // near-black rows on a light page, glaring pale rows on a dark tenant.
18
21
  export const defaultTheme: TableTheme = {
19
22
  name: 'default',
20
23
  container: 'w-full',
21
24
  header: 'bg-muted/50',
22
25
  headerCell: 'px-4 py-3 text-left text-sm font-medium text-muted-foreground',
23
26
  body: '',
24
- row: 'border-b transition-colors',
27
+ row: 'border-b border-border transition-colors',
25
28
  rowHover: 'hover:bg-muted/50',
26
29
  rowSelected: 'bg-primary/10 border-primary',
27
30
  cell: 'px-4 py-3 text-sm',
28
- pagination: 'flex items-center justify-between px-4 py-3 border-t',
31
+ pagination: 'flex items-center justify-between px-4 py-3 border-t border-border',
29
32
  emptyState: 'flex items-center justify-center py-12 text-muted-foreground',
30
- mobileCard: 'rounded-lg border bg-card shadow-sm',
33
+ mobileCard: 'rounded-lg border border-border bg-card shadow-sm',
31
34
  mobileCardHeader: 'p-4 pb-2',
32
35
  mobileCardContent: 'p-4 pt-2',
33
36
  }
@@ -37,7 +40,7 @@ export const compactTheme: TableTheme = {
37
40
  name: 'compact',
38
41
  headerCell: 'px-2 py-2 text-left text-xs font-medium text-muted-foreground',
39
42
  cell: 'px-2 py-2 text-xs',
40
- pagination: 'flex items-center justify-between px-2 py-2 border-t',
43
+ pagination: 'flex items-center justify-between px-2 py-2 border-t border-border',
41
44
  mobileCardHeader: 'p-3 pb-1',
42
45
  mobileCardContent: 'p-3 pt-1',
43
46
  }
@@ -47,7 +50,7 @@ export const spaciousTheme: TableTheme = {
47
50
  name: 'spacious',
48
51
  headerCell: 'px-6 py-4 text-left text-sm font-medium text-muted-foreground',
49
52
  cell: 'px-6 py-4 text-sm',
50
- pagination: 'flex items-center justify-between px-6 py-4 border-t',
53
+ pagination: 'flex items-center justify-between px-6 py-4 border-t border-border',
51
54
  mobileCardHeader: 'p-6 pb-3',
52
55
  mobileCardContent: 'p-6 pt-3',
53
56
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The type scale is a contract, so it gets tested like one (startsim-768w.35.2.4).
3
+ *
4
+ * Two things went wrong without it:
5
+ * 1. Every consumer picked sizes by hand. 77% of one app's text landed on
6
+ * `text-sm` or `text-xs` — two ADJACENT steps — so a card title and its
7
+ * body were 1.5px apart and the hierarchy read as flat.
8
+ * 2. DashboardSection hard-coded `text-gray-900` / `text-gray-600`, which is
9
+ * invisible-adjacent on a dark tenant. Apps hand-rolled their own headings
10
+ * rather than use it, which is how you end up with nineteen loose <h2>s.
11
+ */
12
+
13
+ import * as fs from 'fs'
14
+ import * as path from 'path'
15
+ import { render, screen } from '@testing-library/react'
16
+ import {
17
+ TYPE_SCALE,
18
+ TYPE_SCALE_ORDER,
19
+ TYPE_SCALE_DEFAULTS,
20
+ TYPE_SCALE_TOKENS,
21
+ typeClass,
22
+ } from '../type-scale'
23
+ import { THEME_TOKENS } from '../contract'
24
+ import { DashboardSection } from '../../components/dashboard/DashboardSection'
25
+
26
+ const CONTRACT_CSS = fs.readFileSync(
27
+ path.resolve(__dirname, '../../../theme/contract.css'),
28
+ 'utf8',
29
+ )
30
+
31
+ describe('the type scale is a real contract', () => {
32
+ it('names every role in packages/ui/theme/contract.css', () => {
33
+ const missing = TYPE_SCALE_TOKENS.filter(
34
+ (token) => !new RegExp(`${token}\\s*:`).test(CONTRACT_CSS),
35
+ )
36
+ expect(missing).toEqual([])
37
+ })
38
+
39
+ it('is listed in THEME_TOKENS so apps can enumerate what they must define', () => {
40
+ expect(THEME_TOKENS.typeScale).toEqual(TYPE_SCALE_TOKENS)
41
+ })
42
+
43
+ it('drives every role off a CSS variable, so one override reshapes the estate', () => {
44
+ for (const role of TYPE_SCALE_ORDER) {
45
+ expect(TYPE_SCALE[role]).toMatch(/text-\[length:var\(--text-[a-z-]+-size,/)
46
+ expect(TYPE_SCALE[role]).toMatch(/leading-\[var\(--text-[a-z-]+-leading,/)
47
+ }
48
+ })
49
+
50
+ it('carries a fallback in every variable, so a component still renders in an app that has not adopted the contract', () => {
51
+ for (const role of TYPE_SCALE_ORDER) {
52
+ // var(--x) with no comma is the failure mode: the utility computes to
53
+ // nothing and the text falls back to the browser default.
54
+ expect(TYPE_SCALE[role]).not.toMatch(/var\(--text-[a-z-]+-(size|leading)\)/)
55
+ }
56
+ })
57
+
58
+ it('gives every role its own size — no two steps land on the same number', () => {
59
+ const sizes = TYPE_SCALE_ORDER.map((role) => TYPE_SCALE_DEFAULTS[role])
60
+ expect(new Set(sizes).size).toBe(sizes.length)
61
+ })
62
+
63
+ it('keeps adjacent steps far enough apart to be seen as different', () => {
64
+ const sizes = TYPE_SCALE_ORDER.map((role) => TYPE_SCALE_DEFAULTS[role])
65
+ for (let i = 1; i < sizes.length; i++) {
66
+ expect(sizes[i]).toBeLessThan(sizes[i - 1])
67
+ // The original defect was a card title 1.5px larger than its own body —
68
+ // a difference nobody can see. 0.125rem (2px) is the floor.
69
+ expect(sizes[i - 1] - sizes[i]).toBeGreaterThanOrEqual(0.125)
70
+ }
71
+ })
72
+
73
+ it('spans a wide enough range that a headline cannot be mistaken for body copy', () => {
74
+ expect(TYPE_SCALE_DEFAULTS.display / TYPE_SCALE_DEFAULTS.body).toBeGreaterThanOrEqual(3)
75
+ })
76
+
77
+ it('sets size and weight only — colour stays a theme decision', () => {
78
+ for (const role of TYPE_SCALE_ORDER) {
79
+ expect(TYPE_SCALE[role]).not.toMatch(/(^|\s)text-(gray|slate|zinc|neutral|stone|foreground)/)
80
+ }
81
+ })
82
+
83
+ it('exposes the roles through a helper for dynamic call sites', () => {
84
+ expect(typeClass('heading')).toBe(TYPE_SCALE.heading)
85
+ })
86
+ })
87
+
88
+ describe('DashboardSection is theme-driven, not light-locked', () => {
89
+ const classesOf = (el: Element | null | undefined) =>
90
+ (el?.getAttribute('class') ?? '').split(/\s+/)
91
+
92
+ it('renders the title on the heading step of the scale', () => {
93
+ render(<DashboardSection title="Overview"><div /></DashboardSection>)
94
+ const heading = screen.getByRole('heading', { level: 2, name: 'Overview' })
95
+
96
+ for (const cls of TYPE_SCALE.heading.split(/\s+/)) {
97
+ expect(heading).toHaveClass(cls)
98
+ }
99
+ })
100
+
101
+ it('renders the description on the muted step of the scale', () => {
102
+ render(
103
+ <DashboardSection title="Overview" description="Last 28 days">
104
+ <div />
105
+ </DashboardSection>,
106
+ )
107
+ const description = screen.getByText('Last 28 days')
108
+
109
+ for (const cls of TYPE_SCALE.muted.split(/\s+/)) {
110
+ expect(description).toHaveClass(cls)
111
+ }
112
+ })
113
+
114
+ it('colours text with theme tokens so it reads on a dark tenant', () => {
115
+ const { container } = render(
116
+ <DashboardSection title="Overview" description="Last 28 days">
117
+ <div />
118
+ </DashboardSection>,
119
+ )
120
+
121
+ expect(screen.getByRole('heading', { level: 2 })).toHaveClass('text-foreground')
122
+ expect(screen.getByText('Last 28 days')).toHaveClass('text-muted-foreground')
123
+
124
+ // No `gray-*` / `bg-white` anywhere in the subtree — those ignore the theme.
125
+ const offenders = Array.from(container.querySelectorAll('*'))
126
+ .flatMap((el) => classesOf(el))
127
+ .filter((cls) => /^(bg|text|border|divide|ring)-(gray|slate|zinc|neutral|stone)-\d{2,3}$|^bg-white$/.test(cls))
128
+ expect(offenders).toEqual([])
129
+ })
130
+
131
+ it('never draws a bare `border`, which is currentColor in Tailwind v4', () => {
132
+ const { container } = render(
133
+ <DashboardSection title="Overview" action={<button>Export</button>}>
134
+ <div />
135
+ </DashboardSection>,
136
+ )
137
+
138
+ for (const el of Array.from(container.querySelectorAll('*'))) {
139
+ const classes = classesOf(el)
140
+ const bare = classes.filter((c) => /^border(-[trblxy])?$/.test(c))
141
+ if (!bare.length) continue
142
+ expect(classes.some((c) => c.startsWith('border-') && !/^border-[trblxy]$/.test(c))).toBe(true)
143
+ }
144
+ })
145
+ })
@@ -1,3 +1,5 @@
1
+ import { TYPE_SCALE_TOKENS } from './type-scale'
2
+
1
3
  /** All CSS custom property names that apps must define in their theme */
2
4
  export const THEME_TOKENS = {
3
5
  colors: [
@@ -33,6 +35,8 @@ export const THEME_TOKENS = {
33
35
  ],
34
36
  shadows: ['--shadow-sm', '--shadow-md', '--shadow-lg', '--shadow-xl'],
35
37
  typography: ['--font-sans', '--font-mono', '--font-heading'],
38
+ // The type scale — see ./type-scale.ts for the roles these size.
39
+ typeScale: TYPE_SCALE_TOKENS,
36
40
  animation: ['--duration-fast', '--duration-normal', '--duration-slow', '--easing-default'],
37
41
  borders: ['--border-width'],
38
42
  } as const
@@ -44,5 +48,6 @@ export type ThemeToken =
44
48
  | ThemeShapeTokens
45
49
  | (typeof THEME_TOKENS.shadows)[number]
46
50
  | (typeof THEME_TOKENS.typography)[number]
51
+ | (typeof THEME_TOKENS.typeScale)[number]
47
52
  | (typeof THEME_TOKENS.animation)[number]
48
53
  | (typeof THEME_TOKENS.borders)[number]
@@ -7,3 +7,13 @@ export { default as tailwindPreset } from './tailwind.preset'
7
7
  // Design token contract — all CSS variables apps must define
8
8
  export { THEME_TOKENS } from './contract'
9
9
  export type { ThemeColorTokens, ThemeShapeTokens, ThemeToken } from './contract'
10
+
11
+ // Type scale — the named roles every text size in the estate comes from
12
+ export {
13
+ TYPE_SCALE,
14
+ TYPE_SCALE_ORDER,
15
+ TYPE_SCALE_DEFAULTS,
16
+ TYPE_SCALE_TOKENS,
17
+ typeClass,
18
+ } from './type-scale'
19
+ export type { TypeScaleRole, TypeScaleToken } from './type-scale'