@startsimpli/ui 0.4.91 → 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.91",
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",
@@ -12,6 +12,7 @@
12
12
  "./gantt/styles": "./src/components/gantt/gantt.css",
13
13
  "./components": "./src/components/index.ts",
14
14
  "./error": "./src/components/error/index.ts",
15
+ "./i18n": "./src/components/i18n/index.ts",
15
16
  "./utils": "./src/utils/index.ts",
16
17
  "./theme": "./src/theme/index.ts",
17
18
  "./theme/contract": "./theme/contract.css",
@@ -43,8 +44,9 @@
43
44
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
44
45
  "react": "^18.0.0 || ^19.0.0",
45
46
  "react-dom": "^18.0.0 || ^19.0.0",
47
+ "@startsimpli/api": "^0.5.54",
46
48
  "@startsimpli/auth": "^0.4.34",
47
- "@startsimpli/api": "^0.5.54"
49
+ "@startsimpli/i18n": "^0.1.1"
48
50
  },
49
51
  "peerDependenciesMeta": {
50
52
  "@startsimpli/api": {
@@ -53,6 +55,9 @@
53
55
  "@startsimpli/auth": {
54
56
  "optional": true
55
57
  },
58
+ "@startsimpli/i18n": {
59
+ "optional": true
60
+ },
56
61
  "@startsimpli/realtime": {
57
62
  "optional": true
58
63
  },
@@ -115,6 +120,7 @@
115
120
  "tailwindcss": "^3.4.19",
116
121
  "tsup": "^8.5.1",
117
122
  "typescript": "^6.0.3",
123
+ "@startsimpli/i18n": "0.1.1",
118
124
  "@startsimpli/realtime": "0.0.2"
119
125
  },
120
126
  "keywords": [
@@ -0,0 +1,78 @@
1
+ /**
2
+ * startsim-768w.35.1 — the language control itself.
3
+ *
4
+ * The switcher is the one screen element that must make sense to someone who
5
+ * cannot read the current language. That constrains it more than a normal
6
+ * control: every option is written in its OWN language, each carries its own
7
+ * `lang`/`dir` so العربية isn't rendered by LTR rules, and the trigger is an
8
+ * icon rather than a word in whichever language the user is trying to leave.
9
+ */
10
+ import { render, screen } from '@testing-library/react'
11
+ import userEvent from '@testing-library/user-event'
12
+
13
+ import { LocaleSwitcher, type LocaleOption } from '../i18n/LocaleSwitcher'
14
+
15
+ const locales: LocaleOption[] = [
16
+ { code: 'en', name: 'English', nativeName: 'English', dir: 'ltr' },
17
+ { code: 'ar', name: 'Arabic', nativeName: 'العربية', dir: 'rtl' },
18
+ ]
19
+
20
+ describe('LocaleSwitcher — inline', () => {
21
+ it('offers every language by its own name and reports the active one', () => {
22
+ render(<LocaleSwitcher variant="inline" locales={locales} value="en" onChange={() => {}} />)
23
+
24
+ const english = screen.getByRole('radio', { name: 'English' })
25
+ const arabic = screen.getByRole('radio', { name: 'العربية' })
26
+
27
+ expect(english).toHaveAttribute('aria-checked', 'true')
28
+ expect(arabic).toHaveAttribute('aria-checked', 'false')
29
+ })
30
+
31
+ it('labels each option with its own language and direction', () => {
32
+ render(<LocaleSwitcher variant="inline" locales={locales} value="en" onChange={() => {}} />)
33
+
34
+ const arabic = screen.getByRole('radio', { name: 'العربية' })
35
+ expect(arabic).toHaveAttribute('lang', 'ar')
36
+ expect(arabic).toHaveAttribute('dir', 'rtl')
37
+ })
38
+
39
+ it('hands the chosen code to the caller', async () => {
40
+ const onChange = jest.fn()
41
+ render(<LocaleSwitcher variant="inline" locales={locales} value="en" onChange={onChange} />)
42
+
43
+ await userEvent.click(screen.getByRole('radio', { name: 'العربية' }))
44
+ expect(onChange).toHaveBeenCalledWith('ar')
45
+ })
46
+ })
47
+
48
+ describe('LocaleSwitcher — menu', () => {
49
+ it('names the trigger for screen readers and shows the active language on it', () => {
50
+ render(<LocaleSwitcher locales={locales} value="ar" onChange={() => {}} label="تغيير اللغة" />)
51
+
52
+ const trigger = screen.getByRole('button', { name: 'تغيير اللغة' })
53
+ expect(trigger).toHaveTextContent('العربية')
54
+ })
55
+
56
+ it('lists the languages as radios and switches on select', async () => {
57
+ const onChange = jest.fn()
58
+ render(<LocaleSwitcher locales={locales} value="en" onChange={onChange} />)
59
+
60
+ await userEvent.click(screen.getByRole('button', { name: 'Change language' }))
61
+
62
+ const options = await screen.findAllByRole('menuitemradio')
63
+ expect(options.map((o) => o.textContent)).toEqual(['English', 'العربية'])
64
+ expect(options[0]).toHaveAttribute('aria-checked', 'true')
65
+
66
+ await userEvent.click(screen.getByRole('menuitemradio', { name: 'العربية' }))
67
+ expect(onChange).toHaveBeenCalledWith('ar')
68
+ })
69
+ })
70
+
71
+ describe('LocaleSwitcher — nothing to switch', () => {
72
+ it('renders nothing when the app ships a single language', () => {
73
+ const { container } = render(
74
+ <LocaleSwitcher locales={[locales[0]]} value="en" onChange={() => {}} />
75
+ )
76
+ expect(container).toBeEmptyDOMElement()
77
+ })
78
+ })
@@ -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,
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { useLocale } from '@startsimpli/i18n';
5
+
6
+ import { LocaleSwitcher, type LocaleSwitcherProps } from './LocaleSwitcher';
7
+
8
+ export type LocaleMenuProps = Omit<LocaleSwitcherProps, 'locales' | 'value' | 'onChange'>;
9
+
10
+ /**
11
+ * The switcher, already wired.
12
+ *
13
+ * Drop `<LocaleMenu />` anywhere inside an `<I18nProvider>` and language
14
+ * switching works — the provider owns the locale, persists the choice, and
15
+ * flips `<html dir>`. Apps should not be re-writing that three-line binding
16
+ * each time; `LocaleSwitcher` stays available for the rare header that needs
17
+ * to drive the control from somewhere else.
18
+ *
19
+ * Lives behind the `@startsimpli/ui/i18n` subpath because it imports
20
+ * `@startsimpli/i18n`, which is an OPTIONAL peer — apps that never touch this
21
+ * subpath never need the dependency.
22
+ */
23
+ export function LocaleMenu(props: LocaleMenuProps) {
24
+ const { locale, setLocale, locales } = useLocale();
25
+ return <LocaleSwitcher {...props} locales={locales} value={locale} onChange={setLocale} />;
26
+ }
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Check, Languages } from 'lucide-react';
5
+
6
+ import { cn } from '../../lib/utils';
7
+ import { Button } from '../ui/button';
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from '../ui/dropdown-menu';
14
+
15
+ /**
16
+ * One selectable language. Structurally compatible with `LocaleConfig` from
17
+ * `@startsimpli/i18n`, but declared locally on purpose: this component stays a
18
+ * plain presentational control that any app can drive, i18n package or not.
19
+ */
20
+ export interface LocaleOption {
21
+ code: string;
22
+ name: string;
23
+ /** The language's name in its own language — what actually goes on screen. */
24
+ nativeName?: string;
25
+ dir?: 'ltr' | 'rtl';
26
+ }
27
+
28
+ export interface LocaleSwitcherProps {
29
+ locales: readonly LocaleOption[];
30
+ /** Active locale code. */
31
+ value: string;
32
+ onChange: (code: string) => void;
33
+ /** `menu` = icon + dropdown (any number of languages). `inline` = segmented
34
+ * buttons, all languages visible at once — good for exactly two. */
35
+ variant?: 'menu' | 'inline';
36
+ /** Show the active language's name beside the icon (`menu` only). */
37
+ showLabel?: boolean;
38
+ align?: 'start' | 'center' | 'end';
39
+ /** Accessible name for the control. Callers pass a translated string; the
40
+ * English default is only a floor, not a design choice. */
41
+ label?: string;
42
+ className?: string;
43
+ }
44
+
45
+ /** What to show for a language: its own name, falling back to the English one. */
46
+ function displayName(locale: LocaleOption): string {
47
+ return locale.nativeName || locale.name;
48
+ }
49
+
50
+ /**
51
+ * Language picker.
52
+ *
53
+ * Presentational and controlled — it holds no locale state and imports no i18n
54
+ * runtime. Wire it to whatever owns the locale (`<LocaleMenu />` in this package
55
+ * does exactly that against `@startsimpli/i18n`).
56
+ *
57
+ * Two details that are easy to get wrong and matter here:
58
+ *
59
+ * - Every option carries its OWN `dir`. A menu rendered LTR that prints العربية
60
+ * without `dir="rtl"` on that item mis-orders any punctuation or digits beside
61
+ * it. Each language label is a little island of its own direction.
62
+ * - The trigger is labelled with the icon, never with a flag. Flags are
63
+ * countries, not languages — Arabic isn't one flag, and picking one is a
64
+ * political statement nobody asked the switcher to make.
65
+ */
66
+ export function LocaleSwitcher({
67
+ locales,
68
+ value,
69
+ onChange,
70
+ variant = 'menu',
71
+ showLabel = true,
72
+ align = 'end',
73
+ label = 'Change language',
74
+ className,
75
+ }: LocaleSwitcherProps) {
76
+ // A picker with one option is a label pretending to be a control.
77
+ if (locales.length < 2) return null;
78
+
79
+ const active = locales.find((l) => l.code === value);
80
+
81
+ if (variant === 'inline') {
82
+ return (
83
+ <div
84
+ role="radiogroup"
85
+ aria-label={label}
86
+ dir="ltr"
87
+ className={cn(
88
+ 'inline-flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5',
89
+ className
90
+ )}
91
+ >
92
+ {locales.map((locale) => {
93
+ const selected = locale.code === value;
94
+ return (
95
+ <button
96
+ key={locale.code}
97
+ type="button"
98
+ role="radio"
99
+ aria-checked={selected}
100
+ lang={locale.code}
101
+ dir={locale.dir ?? 'ltr'}
102
+ onClick={() => onChange(locale.code)}
103
+ className={cn(
104
+ 'rounded px-2.5 py-1 text-xs font-medium transition-colors',
105
+ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
106
+ selected
107
+ ? 'bg-background text-foreground shadow-sm'
108
+ : 'text-muted-foreground hover:text-foreground'
109
+ )}
110
+ >
111
+ {displayName(locale)}
112
+ </button>
113
+ );
114
+ })}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <DropdownMenu>
121
+ <DropdownMenuTrigger asChild>
122
+ <Button
123
+ variant="ghost"
124
+ size="sm"
125
+ aria-label={label}
126
+ className={cn('gap-2 text-muted-foreground hover:text-foreground', className)}
127
+ >
128
+ <Languages className="h-4 w-4 shrink-0" aria-hidden="true" />
129
+ {showLabel && active ? (
130
+ <span lang={active.code} dir={active.dir ?? 'ltr'} className="text-xs font-medium">
131
+ {displayName(active)}
132
+ </span>
133
+ ) : null}
134
+ </Button>
135
+ </DropdownMenuTrigger>
136
+ <DropdownMenuContent align={align} className="min-w-[10rem]">
137
+ {/* Radio semantics on a plain Item rather than DropdownMenuRadioItem:
138
+ that primitive reserves its indicator gutter with physical `pl-8`,
139
+ which lands on the wrong side once the document flips to RTL. */}
140
+ {locales.map((locale) => (
141
+ <DropdownMenuItem
142
+ key={locale.code}
143
+ role="menuitemradio"
144
+ aria-checked={locale.code === value}
145
+ lang={locale.code}
146
+ dir={locale.dir ?? 'ltr'}
147
+ onSelect={() => onChange(locale.code)}
148
+ className="gap-6"
149
+ >
150
+ <span>{displayName(locale)}</span>
151
+ {locale.code === value ? (
152
+ <Check className="ms-auto h-4 w-4 shrink-0 opacity-70" aria-hidden="true" />
153
+ ) : null}
154
+ </DropdownMenuItem>
155
+ ))}
156
+ </DropdownMenuContent>
157
+ </DropdownMenu>
158
+ );
159
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Language selection UI.
3
+ *
4
+ * - `LocaleSwitcher` — presentational + controlled, no i18n runtime.
5
+ * - `LocaleMenu` — the same control bound to `@startsimpli/i18n` (optional peer).
6
+ */
7
+ export { LocaleSwitcher, type LocaleSwitcherProps, type LocaleOption } from './LocaleSwitcher';
8
+ export { LocaleMenu, type LocaleMenuProps } from './LocaleMenu';
@@ -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
+ }