@tristan2828/ui-foundation 3.2.0 → 3.4.0

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/README.md CHANGED
@@ -19,7 +19,7 @@ than installing this by hand:
19
19
  | Import | What |
20
20
  |---|---|
21
21
  | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
22
- | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, table, textarea, tooltip |
22
+ | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
23
23
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
24
24
  | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
25
25
  | `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
@@ -76,7 +76,12 @@ is a filled-in example while the Widgets demo is still here).
76
76
  and labels from its field table, and field types by pattern (a
77
77
  `reference` field copies Category's searchable combobox, a
78
78
  `single choice` copies Status's select, a `date-time` copies
79
- Available From's date picker, a `multi choice` copies Tags — the
79
+ Available From's date picker, a `yes/no` copies In Stock — a `Switch`
80
+ (`@tristan2828/ui-foundation/ui/switch`) in a horizontal `Field` with
81
+ its label beside it on the form, cell pattern 12 (the word Yes/No) in
82
+ the table, and a three-way toolbar `Select` (either / yes / no) whose URL
83
+ value is `'true'`/`'false'` and whose `filters` value is a real boolean,
84
+ so `false` reaches the wire — and a `multi choice` copies Tags — the
80
85
  `<MultiChoice>` control from the foundation on the
81
86
  form and as a toolbar filter via `useTableUrlState`'s multi filters,
82
87
  badges in the table, and a `filters` array the gateway sends as a
@@ -5,10 +5,16 @@ first attempt gets wrong — **not components**: the lookup maps are always
5
5
  per-entity, and only the shape is reusable. Copy the markup into your
6
6
  `<entity>-columns.tsx`.
7
7
 
8
- Every pattern below was chosen for a real column, from a page of
8
+ Patterns 1–10 were each chosen for a real column, from a page of
9
9
  alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
10
- A pattern that proves generic is worth raising for the foundation, so it
11
- ships here for every app.
10
+ Pattern 12 (boolean) is the template's own reference: Widget's In Stock
11
+ column renders it and its specs pin it. The other patterns from 11 on are
12
+ marked **Unproven**: written ahead of a real
13
+ column, so no app has tested them on real data yet. Prefer a proven
14
+ pattern when one fits. When you use an unproven one, say so in the
15
+ entity's plan, and raise what you learned for the foundation, so the
16
+ entry can lose its label. A pattern that proves generic is worth raising
17
+ too, so it ships here for every app.
12
18
 
13
19
  ## The rules that apply to every cell
14
20
 
@@ -223,3 +229,104 @@ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
223
229
  - **Cost:** the detail can't be scanned down the column, and an average
224
230
  can't be sorted server-side without a backend change
225
231
  (`enableSorting: false`).
232
+
233
+ ## 11. Number or currency — *Unproven*
234
+
235
+ A count, a quantity, a price.
236
+
237
+ ```tsx
238
+ // Module scope: one formatter per column, not one per cell render.
239
+ const priceFormatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' })
240
+
241
+ cell: ({ getValue }) => {
242
+ const value = getValue<string | null>()
243
+ return value == null ? emptyCell : <span className="tabular-nums">{priceFormatter.format(Number(value))}</span>
244
+ }
245
+ ```
246
+
247
+ - `tabular-nums` gives every digit the same width, so values line up down
248
+ the column even when left-aligned.
249
+ - **Zero is a value, not an empty cell.** Test `value == null`, never
250
+ falsiness, or `0` renders as an em dash.
251
+ - An API `format: decimal` arrives as a **string**. `Number()` is exact
252
+ enough to display (about 15 significant digits). Never add up money in
253
+ floats on the client: totals come from the backend.
254
+ - **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
255
+ supports `'center'`, and a right-aligned cell under a left-aligned header
256
+ looks broken. Stay left-aligned until a real column needs
257
+ `align: 'right'`, then raise it for the foundation.
258
+ - The currency code comes from the data or the plan, never a guess. The
259
+ template's `USD` is the demo's.
260
+ - **Cost:** none to speak of. It's the plain-text pattern with formatting.
261
+
262
+ ## 12. Boolean
263
+
264
+ A yes/no field. Widget's In Stock column (`src/routes/widgets/widgets-columns.tsx`)
265
+ is the reference; its value is never null, so it drops the `emptyCell`
266
+ branch below.
267
+
268
+ ```tsx
269
+ cell: ({ getValue }) => {
270
+ const value = getValue<boolean | null>()
271
+ if (value == null) return emptyCell
272
+ return value ? (
273
+ <span className="inline-flex items-center gap-1">
274
+ <CheckIcon aria-hidden="true" className="size-4 text-success-text" />
275
+ Yes
276
+ </span>
277
+ ) : (
278
+ <span className="text-muted-foreground">No</span>
279
+ )
280
+ }
281
+ ```
282
+
283
+ - **The word carries the value.** The check only reinforces it, so the
284
+ cell survives greyscale and a screen reader hears "Yes".
285
+ - **Never a checkbox** in a read-only cell. It looks editable, and people
286
+ will try to click it.
287
+ - "No" is muted, not red. False is rarely bad. If it is, the field is
288
+ really an enum with a verdict, and pattern 5 fits better.
289
+ - **Nullable means three states.** `null` is the em dash, distinct from
290
+ "No". Check the schema before collapsing them.
291
+ - **Cost:** a column that is mostly "Yes" is noise. If almost every row
292
+ shares the value, consider showing only the exception.
293
+
294
+ ## 13. Progress bar — *Unproven*
295
+
296
+ A percentage, or a count out of a total (`7 of 12 done`).
297
+
298
+ - The package doesn't ship `progress`. Install shadcn's into
299
+ `src/components/ui/` (`npx shadcn@<tools.shadcn> add progress`, after
300
+ checking its dependencies against `deps-allowlist.json`).
301
+ - **Always print the number** beside the bar (`72%`, `7 / 12`). The bar
302
+ shows roughly how far; the number says exactly.
303
+ - Fix the bar's width (`w-24`) so bars compare down the column. A bar that
304
+ stretches with the column compares nothing.
305
+ - The default colour is `bg-primary`. Tone it (`bg-success`) only when the
306
+ value means something, like "complete", and say that in the plan.
307
+ - Clamp to the schema's range. A value of 105% from bad data shouldn't
308
+ draw past the track.
309
+ - Give each bar a row-specific `aria-label` (`${row.original.name}
310
+ progress`).
311
+ - **Cost:** a bar takes far more width than the number it replaces. On a
312
+ wide table, the plain number from pattern 11 usually wins.
313
+
314
+ ## 14. Avatar and name — *Unproven*
315
+
316
+ A person: an assignee or an owner.
317
+
318
+ - The package doesn't ship `avatar`. Install shadcn's the same way as
319
+ pattern 13.
320
+ - **Always render the fallback** (initials). Images fail, load slowly, or
321
+ aren't set, and the cell must still look intentional.
322
+ - The image is decorative (`alt=""`) because the name is right beside
323
+ it. Otherwise a screen reader reads the name twice.
324
+ - `size-6` keeps the row height unchanged at `compact` density
325
+ ([`design-language.md`](design-language.md) "Density").
326
+ - Initials on `bg-muted text-muted-foreground`. Categorical colours
327
+ would make every person look like a category.
328
+ - Avatar URLs load from wherever the API points. Use `loading="lazy"` so a
329
+ 25-row page doesn't fetch every image up front.
330
+ - **Cost:** a column of faces draws the eye more than any other cell. Use
331
+ it when *who* matters most in the row, not as decoration.
332
+
@@ -126,3 +126,57 @@ Four things about them:
126
126
 
127
127
  If you find yourself wanting a ninth, that is usually a sign the column
128
128
  should be showing a shape or a label rather than more colours.
129
+
130
+ ## Typography
131
+
132
+ Five roles cover every screen. Each is one class that sets size, line
133
+ height and weight together, from tokens in the package's `styles/theme.css`:
134
+
135
+ | Class | For | Size / weight |
136
+ |---|---|---|
137
+ | `type-page-title` | the screen's one `<h1>` (a table's title, a form's title) | 18px / 600 |
138
+ | `type-section-title` | a heading inside a screen (a form section, a card group) | 16px / 600 |
139
+ | `type-body` | running text, table cells, form text, the pagination summary | 14px / 400 |
140
+ | `type-label` | column headers, field labels, a small heading over a group | 14px / 500 |
141
+ | `type-caption` | counts, metadata, helper text under a field | 12px / 400 |
142
+
143
+ - **Use a role instead of size and weight classes,** not as well as them.
144
+ `type-page-title` replaces `text-lg font-semibold`. Combining a role with
145
+ `text-sm`, `font-medium` or `leading-*` makes two classes set the same
146
+ property, and which one wins depends on the order of rules in the
147
+ stylesheet, not on the order in `className`.
148
+ - **Colour stays separate:** `type-caption text-muted-foreground`. A role
149
+ never sets a colour, so any text token pairs with it.
150
+ - **Why `type-*` and not `text-*`:** `cn` (tailwind-merge) treats an
151
+ unknown `text-*` class as a colour, so
152
+ `cn('text-page-title', 'text-foreground')` would silently drop the title.
153
+ - A shadcn primitive keeps its own sizes (`CardTitle`, `Button`). Don't put
154
+ a role on one: its built-in size classes would then compete with the role.
155
+
156
+ ## Density
157
+
158
+ Tables have three densities, set with `data-density` on **any ancestor**
159
+ (a wrapper `<div>`, a route's layout, or `<html>` for the whole app). It is
160
+ a token set, not a `DataTable` prop:
161
+
162
+ ```tsx
163
+ <div data-density="compact">
164
+ <DataTable … />
165
+ </div>
166
+ ```
167
+
168
+ | `data-density` | Cell padding (vertical) | Header height | For |
169
+ |---|---|---|---|
170
+ | `compact` | 4px | 32px | wide tables people scan, many rows per screen |
171
+ | *(unset)* | 8px | 40px | the default |
172
+ | `comfortable` | 12px | 48px | short tables, or rows people read rather than scan |
173
+
174
+ - Density changes padding only. Text keeps `type-body`, so compact never
175
+ drops below a readable size.
176
+ - Pick **one density per screen**. Two tables at different densities side
177
+ by side look like a mistake.
178
+ - Under the hood, the table primitive's cells read `--table-cell-px`,
179
+ `--table-cell-py` and `--table-head-height`, and the `data-density`
180
+ selectors in `styles/theme.css` set them. The package's Storybook check
181
+ measures that each step really changes the row height.
182
+
@@ -24,7 +24,9 @@ One or two sentences: what one record is, and who uses the screens.
24
24
  - **Type** — what the foundation supports today:
25
25
  - *Demonstrated by the Widget reference* (copied directly): `text` (one
26
26
  line), `long text`, `decimal` (fixed places — say how many),
27
- `date-time`, `email`, `single choice` (list the options, in display
27
+ `date-time`, `email`, `yes/no` (a boolean that is always yes or no:
28
+ say its default and the filter's three labels, e.g. Any stock /
29
+ In stock / Out of stock), `single choice` (list the options, in display
28
30
  order; see the note below if it is *optional*), `multi choice` (list
29
31
  the options; any number can be picked —
30
32
  chips on the form, badges in the table, an any-of filter), `reference`
@@ -32,7 +34,8 @@ One or two sentences: what one record is, and who uses the screens.
32
34
  - *Close variants* (built by a small, stated change to the nearest
33
35
  pattern): `integer` (from decimal), `url` (from email — a format
34
36
  check), `date` (from date-time — no time part).
35
- - *Not supported yet:* boolean, file, and anything else.
37
+ - *Not supported yet:* a yes/no that can also be unset (three states),
38
+ file, and anything else.
36
39
  Write it anyway with a note; the playbook will stop and raise it
37
40
  rather than improvise.
38
41
  - **Required**: `yes`, or `no` (then say whether empty means "unknown").
@@ -69,7 +69,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }
69
69
  ? location.pathname === to
70
70
  : location.pathname.startsWith(to);
71
71
  return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(NavLink, { to: to, end: end }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
72
- }) }) })] }), sidebarExtra] }), _jsx(SidebarFooter, { children: _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate text-xs text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
72
+ }) }) })] }), sidebarExtra] }), _jsx(SidebarFooter, { children: _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
73
73
  setIsLoggingOut(true);
74
74
  logout().catch(() => setIsLoggingOut(false));
75
75
  }, children: _jsx(LogOutIcon, {}) })] }) })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "flex-1 p-6", children: _jsx(Outlet, {}) })] })] }), _jsx(Toaster, { position: "top-center" })] }));
@@ -163,5 +163,5 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
163
163
  : 'none', children: header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext())) }, header.id));
164
164
  }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn((pinFirstColumn || pinLastColumn) && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
165
165
  index === row.getVisibleCells().length - 1 &&
166
- PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "text-sm text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
166
+ PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
167
167
  }
@@ -4,5 +4,5 @@ import { Button } from '../ui/button.js';
4
4
  import { FieldGroup } from '../ui/field.js';
5
5
  import { Spinner } from '../ui/spinner.js';
6
6
  export function EntityForm({ title, onSubmit, isSubmitting, submitError, submitLabel, onCancel, danger, children, }) {
7
- return (_jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex max-w-xl flex-col gap-6", children: [_jsx("h1", { className: "text-lg font-semibold text-foreground", children: title }), _jsx(FieldGroup, { children: children }), submitError && (_jsx(ErrorState, { error: submitError, className: "border border-dashed border-destructive/30" })), _jsxs("div", { className: `flex items-center gap-2 ${danger ? 'justify-between' : 'justify-end'}`, children: [danger, _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isSubmitting, children: "Cancel" }), _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), submitLabel] })] })] })] }));
7
+ return (_jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex max-w-xl flex-col gap-6", children: [_jsx("h1", { className: "type-page-title text-foreground", children: title }), _jsx(FieldGroup, { children: children }), submitError && (_jsx(ErrorState, { error: submitError, className: "border border-dashed border-destructive/30" })), _jsxs("div", { className: `flex items-center gap-2 ${danger ? 'justify-between' : 'justify-end'}`, children: [danger, _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isSubmitting, children: "Cancel" }), _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), submitLabel] })] })] })] }));
8
8
  }
@@ -0,0 +1,5 @@
1
+ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
2
+ declare function Switch({ className, size, ...props }: SwitchPrimitive.Root.Props & {
3
+ size?: "sm" | "default";
4
+ }): import("react").JSX.Element;
5
+ export { Switch };
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
3
+ import { cn } from "cn";
4
+ function Switch({ className, size = "default", ...props }) {
5
+ return (_jsx(SwitchPrimitive.Root, { "data-slot": "switch", "data-size": size, className: cn("peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50", className), ...props, children: _jsx(SwitchPrimitive.Thumb, { "data-slot": "switch-thumb", className: "pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground" }) }));
6
+ }
7
+ export { Switch };
@@ -33,11 +33,15 @@ function TableFooter({ className, ...props }) {
33
33
  function TableRow({ className, ...props }) {
34
34
  return (_jsx("tr", { "data-slot": "table-row", className: cn("border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted", className), ...props }));
35
35
  }
36
+ // Patched (ui-foundation): padding and header height come from the
37
+ // --table-* density tokens in styles/theme.css instead of upstream's fixed
38
+ // h-10 / p-2, so data-density on an ancestor retunes every table. The
39
+ // defaults equal upstream's values.
36
40
  function TableHead({ className, ...props }) {
37
- return (_jsx("th", { "data-slot": "table-head", className: cn("h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0", className), ...props }));
41
+ return (_jsx("th", { "data-slot": "table-head", className: cn("h-(--table-head-height) px-(--table-cell-px) text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0", className), ...props }));
38
42
  }
39
43
  function TableCell({ className, ...props }) {
40
- return (_jsx("td", { "data-slot": "table-cell", className: cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className), ...props }));
44
+ return (_jsx("td", { "data-slot": "table-cell", className: cn("px-(--table-cell-px) py-(--table-cell-py) align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className), ...props }));
41
45
  }
42
46
  function TableCaption({ className, ...props }) {
43
47
  return (_jsx("caption", { "data-slot": "table-caption", className: cn("mt-4 text-sm text-muted-foreground", className), ...props }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/styles/index.css CHANGED
@@ -78,6 +78,34 @@
78
78
  --radius-4xl: calc(var(--radius) * 2.6);
79
79
  }
80
80
 
81
+ /* Typography roles: one class sets size, line height and weight together,
82
+ from the --type-* tokens in theme.css. */
83
+ @utility type-page-title {
84
+ font-size: var(--type-page-title-size);
85
+ line-height: var(--type-page-title-leading);
86
+ font-weight: var(--type-page-title-weight);
87
+ }
88
+ @utility type-section-title {
89
+ font-size: var(--type-section-title-size);
90
+ line-height: var(--type-section-title-leading);
91
+ font-weight: var(--type-section-title-weight);
92
+ }
93
+ @utility type-body {
94
+ font-size: var(--type-body-size);
95
+ line-height: var(--type-body-leading);
96
+ font-weight: var(--type-body-weight);
97
+ }
98
+ @utility type-label {
99
+ font-size: var(--type-label-size);
100
+ line-height: var(--type-label-leading);
101
+ font-weight: var(--type-label-weight);
102
+ }
103
+ @utility type-caption {
104
+ font-size: var(--type-caption-size);
105
+ line-height: var(--type-caption-leading);
106
+ font-weight: var(--type-caption-weight);
107
+ }
108
+
81
109
  @layer base {
82
110
  * {
83
111
  @apply border-border outline-ring/50;
package/styles/theme.css CHANGED
@@ -99,6 +99,41 @@
99
99
 
100
100
  --radius-base: 0.625rem;
101
101
 
102
+ /*
103
+ * Typography roles (conventions/docs/design-language.md "Typography").
104
+ * Each role is a size, a line height and a weight, consumed only through
105
+ * the type-* utilities in index.css. Named type-*, not text-*: the class
106
+ * merger (cn, tailwind-merge) reads an unknown text-* class as a colour,
107
+ * so `cn('text-page-title', 'text-foreground')` would drop the title.
108
+ * The values are what the package and template already used, so moving
109
+ * onto a role changes nothing visible.
110
+ */
111
+ --type-page-title-size: 1.125rem; /* was text-lg font-semibold */
112
+ --type-page-title-leading: 1.75rem;
113
+ --type-page-title-weight: 600;
114
+ --type-section-title-size: 1rem;
115
+ --type-section-title-leading: 1.5rem;
116
+ --type-section-title-weight: 600;
117
+ --type-body-size: 0.875rem; /* text-sm: tables, forms, most UI text */
118
+ --type-body-leading: 1.25rem;
119
+ --type-body-weight: 400;
120
+ --type-label-size: 0.875rem; /* column headers, field labels */
121
+ --type-label-leading: 1.25rem;
122
+ --type-label-weight: 500;
123
+ --type-caption-size: 0.75rem; /* counts, metadata, helper text */
124
+ --type-caption-leading: 1rem;
125
+ --type-caption-weight: 400;
126
+
127
+ /*
128
+ * Table density (design-language.md "Density"). The table primitive's
129
+ * cells read these, so density is a CSS choice, not a DataTable prop: an
130
+ * app sets data-density on any ancestor (a wrapper, a route, <html>).
131
+ * The default is what the table always had.
132
+ */
133
+ --table-cell-px: 0.5rem;
134
+ --table-cell-py: 0.5rem;
135
+ --table-head-height: 2.5rem;
136
+
102
137
  /* Layer 2 — semantic (light) */
103
138
  --background: var(--white);
104
139
  --foreground: var(--black);
@@ -160,6 +195,16 @@
160
195
  --sidebar-ring: var(--gray-400);
161
196
  }
162
197
 
198
+ [data-density="compact"] {
199
+ --table-cell-py: 0.25rem;
200
+ --table-head-height: 2rem;
201
+ }
202
+
203
+ [data-density="comfortable"] {
204
+ --table-cell-py: 0.75rem;
205
+ --table-head-height: 3rem;
206
+ }
207
+
163
208
  .dark {
164
209
  /* Layer 2 — semantic (dark). Only this block is remapped; Layer 1 is not. */
165
210
  --background: var(--black);