@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 +3 -3
- package/src/components/dashboard/DashboardSection.tsx +11 -4
- package/src/components/dashboard/MetricCard.tsx +4 -1
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +62 -5
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +22 -0
- package/src/components/unified-table/__tests__/utils/border-tokens.test.ts +61 -0
- package/src/components/unified-table/components/BulkActionBar/index.tsx +1 -1
- package/src/components/unified-table/components/DataTableCore/index.tsx +1 -1
- package/src/components/unified-table/components/MobileView/Card.tsx +1 -1
- package/src/components/unified-table/components/TableFilters/index.tsx +2 -2
- package/src/components/unified-table/components/TablePagination/index.tsx +19 -5
- package/src/components/unified-table/hooks/usePagination.ts +12 -0
- package/src/components/unified-table/utils/themes.ts +8 -5
- package/src/theme/__tests__/type-scale.test.tsx +145 -0
- package/src/theme/contract.ts +5 -0
- package/src/theme/index.ts +10 -0
- package/src/theme/type-scale.ts +119 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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={
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
{
|
|
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
|
+
})
|
package/src/theme/contract.ts
CHANGED
|
@@ -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]
|
package/src/theme/index.ts
CHANGED
|
@@ -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
|
+
}
|