@startsimpli/ui 0.4.92 → 0.4.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.92",
3
+ "version": "0.4.93",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -44,9 +44,9 @@
44
44
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
45
45
  "react": "^18.0.0 || ^19.0.0",
46
46
  "react-dom": "^18.0.0 || ^19.0.0",
47
+ "@startsimpli/api": "^0.5.54",
47
48
  "@startsimpli/auth": "^0.4.34",
48
- "@startsimpli/i18n": "^0.1.1",
49
- "@startsimpli/api": "^0.5.54"
49
+ "@startsimpli/i18n": "^0.1.1"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@startsimpli/api": {
@@ -1,4 +1,6 @@
1
1
  import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { TYPE_SCALE } from '../../theme/type-scale'
2
4
 
3
5
  export interface DashboardSectionProps {
4
6
  title: string
@@ -16,12 +18,17 @@ export function DashboardSection({
16
18
  className = '',
17
19
  }: DashboardSectionProps) {
18
20
  return (
19
- <div className={`space-y-4 ${className}`}>
20
- <div className="flex items-start justify-between">
21
+ <div className={cn('space-y-4', className)}>
22
+ <div className="flex items-start justify-between gap-4">
21
23
  <div>
22
- <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
24
+ {/* `heading` is the section step of the type scale; the colour comes
25
+ from the theme, so this reads on a dark tenant as well as a light
26
+ one. `text-gray-900`/`text-gray-600` used to hard-code light. */}
27
+ <h2 className={cn(TYPE_SCALE.heading, 'text-foreground')}>{title}</h2>
23
28
  {description && (
24
- <p className="text-sm text-gray-600 mt-1">{description}</p>
29
+ <p className={cn(TYPE_SCALE.muted, 'mt-1 text-muted-foreground')}>
30
+ {description}
31
+ </p>
25
32
  )}
26
33
  </div>
27
34
  {action && <div className="flex-shrink-0">{action}</div>}
@@ -36,7 +36,10 @@ export function MetricCard({
36
36
  const content = (
37
37
  <div
38
38
  className={cn(
39
- 'rounded-xl border bg-card text-card-foreground shadow p-6 transition-all duration-200 hover:shadow-lg cursor-pointer',
39
+ // `border` on its own resolves to currentColor in Tailwind v4 — on a
40
+ // card that sets text-card-foreground that draws a near-black outline
41
+ // in light mode and a glaring pale one on a dark tenant. Name the token.
42
+ 'rounded-xl border border-border bg-card text-card-foreground shadow p-6 transition-all duration-200 hover:shadow-lg cursor-pointer',
40
43
  error && 'border-red-200 bg-red-50',
41
44
  isLoading && 'animate-pulse',
42
45
  className,
@@ -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'
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The type scale — packages/ui's contract for how big text is.
3
+ *
4
+ * Before this existed, every consumer picked font sizes by hand and the ladder
5
+ * collapsed: across one app 77% of all text was `text-sm` or `text-xs`, two
6
+ * ADJACENT steps, so a card title and its body differed by 1.5px and were told
7
+ * apart only by weight. The same "big number" was rendered at seven different
8
+ * sizes on seven screens because there was no rule to point at.
9
+ *
10
+ * There are seven roles, named for the job the text does, not for its size.
11
+ * Pick the role; the scale picks the pixels. The ladder is a deliberate 4:1
12
+ * from `display` down to `caption`, so any two adjacent roles are visibly
13
+ * different — that is the whole point.
14
+ *
15
+ * display 48px / 800 the one hero figure on a screen
16
+ * stat 32px / 800 a metric's value in a tile
17
+ * heading 20px / 700 a section header
18
+ * cardTitle 18px / 600 the name of a card
19
+ * body 16px / 400 prose and table cells
20
+ * muted 14px / 400 supporting copy under a title
21
+ * caption 12px / 400 labels, kickers, timestamps
22
+ *
23
+ * Mirrors packages/ui-native/src/tokens.ts + styles.ts, so a screen reads the
24
+ * same on web and iOS.
25
+ *
26
+ * SIZE and LINE-HEIGHT come from CSS variables (packages/ui/theme/contract.css),
27
+ * so an app reshapes the whole estate by overriding seven numbers — the same way
28
+ * `--radius` reshapes every corner. Each utility carries the default inline as a
29
+ * `var()` fallback, so a component still renders correctly in an app that has
30
+ * not adopted the contract file.
31
+ *
32
+ * WEIGHT and TRACKING are fixed per role, deliberately. They are what makes a
33
+ * role recognisable; an app that wants a lighter heading wants a different
34
+ * design, not a different token.
35
+ *
36
+ * Usage:
37
+ * import { TYPE_SCALE } from '@startsimpli/ui/theme'
38
+ * <h2 className={cn(TYPE_SCALE.heading, 'text-foreground')}>{title}</h2>
39
+ *
40
+ * The scale sets size/weight/leading only. COLOUR stays separate and must come
41
+ * from a theme token (`text-foreground`, `text-muted-foreground`) — never a
42
+ * `gray-*` literal, or the component is light-locked and unreadable on a dark
43
+ * tenant.
44
+ */
45
+
46
+ export type TypeScaleRole =
47
+ | 'display'
48
+ | 'stat'
49
+ | 'heading'
50
+ | 'cardTitle'
51
+ | 'body'
52
+ | 'muted'
53
+ | 'caption'
54
+
55
+ /** Tailwind utilities for each role. Compose with a colour token. */
56
+ export const TYPE_SCALE: Record<TypeScaleRole, string> = {
57
+ display:
58
+ 'text-[length:var(--text-display-size,3rem)] leading-[var(--text-display-leading,1.05)] font-extrabold tracking-tight',
59
+ stat: 'text-[length:var(--text-stat-size,2rem)] leading-[var(--text-stat-leading,1.1)] font-extrabold tracking-tight tabular-nums',
60
+ heading:
61
+ 'text-[length:var(--text-heading-size,1.25rem)] leading-[var(--text-heading-leading,1.3)] font-bold tracking-tight',
62
+ cardTitle:
63
+ 'text-[length:var(--text-card-title-size,1.125rem)] leading-[var(--text-card-title-leading,1.4)] font-semibold',
64
+ body: 'text-[length:var(--text-body-size,1rem)] leading-[var(--text-body-leading,1.5)] font-normal',
65
+ muted:
66
+ 'text-[length:var(--text-muted-size,0.875rem)] leading-[var(--text-muted-leading,1.5)] font-normal',
67
+ caption:
68
+ 'text-[length:var(--text-caption-size,0.75rem)] leading-[var(--text-caption-leading,1.4)] font-normal',
69
+ }
70
+
71
+ /**
72
+ * The roles in visual order, largest first. Useful for rendering the ladder in
73
+ * a harness and for tests that assert the steps stay distinct.
74
+ */
75
+ export const TYPE_SCALE_ORDER: readonly TypeScaleRole[] = [
76
+ 'display',
77
+ 'stat',
78
+ 'heading',
79
+ 'cardTitle',
80
+ 'body',
81
+ 'muted',
82
+ 'caption',
83
+ ] as const
84
+
85
+ /** Default size of each role, in rem — the fallback baked into the utilities. */
86
+ export const TYPE_SCALE_DEFAULTS: Record<TypeScaleRole, number> = {
87
+ display: 3,
88
+ stat: 2,
89
+ heading: 1.25,
90
+ cardTitle: 1.125,
91
+ body: 1,
92
+ muted: 0.875,
93
+ caption: 0.75,
94
+ }
95
+
96
+ /** CSS custom properties an app may override to reshape the scale. */
97
+ export const TYPE_SCALE_TOKENS = [
98
+ '--text-display-size',
99
+ '--text-display-leading',
100
+ '--text-stat-size',
101
+ '--text-stat-leading',
102
+ '--text-heading-size',
103
+ '--text-heading-leading',
104
+ '--text-card-title-size',
105
+ '--text-card-title-leading',
106
+ '--text-body-size',
107
+ '--text-body-leading',
108
+ '--text-muted-size',
109
+ '--text-muted-leading',
110
+ '--text-caption-size',
111
+ '--text-caption-leading',
112
+ ] as const
113
+
114
+ export type TypeScaleToken = (typeof TYPE_SCALE_TOKENS)[number]
115
+
116
+ /** `typeClass('heading')` — the utilities for a role, for dynamic call sites. */
117
+ export function typeClass(role: TypeScaleRole): string {
118
+ return TYPE_SCALE[role]
119
+ }