@tristan2828/ui-foundation 3.1.2 → 3.3.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.
@@ -51,6 +51,13 @@ is right. Do not disable, skip, or work around it.
51
51
  - NEVER import `@tristan2828/ui-foundation/gateway` outside
52
52
  `src/api/gateway/`. Components and hooks call a gateway module through
53
53
  TanStack Query. (Enforced: no-restricted-imports.)
54
+ - NEVER add a database table or column without a comment. The migration
55
+ that creates it also runs `COMMENT ON TABLE` / `COMMENT ON COLUMN`,
56
+ saying what it holds in words someone with only database access can use:
57
+ units, what null means, which table an id points at. A second agent with
58
+ credentials and no repo reads the schema alone. Applies to an app with a
59
+ Postgres backend. (Enforced: `backend/scripts/check_db_comments.py`, run
60
+ by `scripts/check-backend-postgres.sh` after `alembic upgrade head`.)
54
61
  - NEVER let a backend-shaped response reach a component. Paginated data is
55
62
  `Page<T>`. Failures are `AppError`. Queries are `QuerySpec`. The gateway
56
63
  translates; nothing above it knows the wire format. (Enforced: gateway
@@ -5,10 +5,14 @@ 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
+ Patterns from 11 on are marked **Unproven**: written ahead of a real
11
+ column, so no app has tested them on real data yet. Prefer a proven
12
+ pattern when one fits. When you use an unproven one, say so in the
13
+ entity's plan, and raise what you learned for the foundation, so the
14
+ entry can lose its label. A pattern that proves generic is worth raising
15
+ too, so it ships here for every app.
12
16
 
13
17
  ## The rules that apply to every cell
14
18
 
@@ -223,3 +227,102 @@ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
223
227
  - **Cost:** the detail can't be scanned down the column, and an average
224
228
  can't be sorted server-side without a backend change
225
229
  (`enableSorting: false`).
230
+
231
+ ## 11. Number or currency — *Unproven*
232
+
233
+ A count, a quantity, a price.
234
+
235
+ ```tsx
236
+ // Module scope: one formatter per column, not one per cell render.
237
+ const priceFormatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' })
238
+
239
+ cell: ({ getValue }) => {
240
+ const value = getValue<string | null>()
241
+ return value == null ? emptyCell : <span className="tabular-nums">{priceFormatter.format(Number(value))}</span>
242
+ }
243
+ ```
244
+
245
+ - `tabular-nums` gives every digit the same width, so values line up down
246
+ the column even when left-aligned.
247
+ - **Zero is a value, not an empty cell.** Test `value == null`, never
248
+ falsiness, or `0` renders as an em dash.
249
+ - An API `format: decimal` arrives as a **string**. `Number()` is exact
250
+ enough to display (about 15 significant digits). Never add up money in
251
+ floats on the client: totals come from the backend.
252
+ - **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
253
+ supports `'center'`, and a right-aligned cell under a left-aligned header
254
+ looks broken. Stay left-aligned until a real column needs
255
+ `align: 'right'`, then raise it for the foundation.
256
+ - The currency code comes from the data or the plan, never a guess. The
257
+ template's `USD` is the demo's.
258
+ - **Cost:** none to speak of. It's the plain-text pattern with formatting.
259
+
260
+ ## 12. Boolean — *Unproven*
261
+
262
+ A yes/no field.
263
+
264
+ ```tsx
265
+ cell: ({ getValue }) => {
266
+ const value = getValue<boolean | null>()
267
+ if (value == null) return emptyCell
268
+ return value ? (
269
+ <span className="inline-flex items-center gap-1">
270
+ <CheckIcon aria-hidden="true" className="size-4 text-success-text" />
271
+ Yes
272
+ </span>
273
+ ) : (
274
+ <span className="text-muted-foreground">No</span>
275
+ )
276
+ }
277
+ ```
278
+
279
+ - **The word carries the value.** The check only reinforces it, so the
280
+ cell survives greyscale and a screen reader hears "Yes".
281
+ - **Never a checkbox** in a read-only cell. It looks editable, and people
282
+ will try to click it.
283
+ - "No" is muted, not red. False is rarely bad. If it is, the field is
284
+ really an enum with a verdict, and pattern 5 fits better.
285
+ - **Nullable means three states.** `null` is the em dash, distinct from
286
+ "No". Check the schema before collapsing them.
287
+ - **Cost:** a column that is mostly "Yes" is noise. If almost every row
288
+ shares the value, consider showing only the exception.
289
+
290
+ ## 13. Progress bar — *Unproven*
291
+
292
+ A percentage, or a count out of a total (`7 of 12 done`).
293
+
294
+ - The package doesn't ship `progress`. Install shadcn's into
295
+ `src/components/ui/` (`npx shadcn@<tools.shadcn> add progress`, after
296
+ checking its dependencies against `deps-allowlist.json`).
297
+ - **Always print the number** beside the bar (`72%`, `7 / 12`). The bar
298
+ shows roughly how far; the number says exactly.
299
+ - Fix the bar's width (`w-24`) so bars compare down the column. A bar that
300
+ stretches with the column compares nothing.
301
+ - The default colour is `bg-primary`. Tone it (`bg-success`) only when the
302
+ value means something, like "complete", and say that in the plan.
303
+ - Clamp to the schema's range. A value of 105% from bad data shouldn't
304
+ draw past the track.
305
+ - Give each bar a row-specific `aria-label` (`${row.original.name}
306
+ progress`).
307
+ - **Cost:** a bar takes far more width than the number it replaces. On a
308
+ wide table, the plain number from pattern 11 usually wins.
309
+
310
+ ## 14. Avatar and name — *Unproven*
311
+
312
+ A person: an assignee or an owner.
313
+
314
+ - The package doesn't ship `avatar`. Install shadcn's the same way as
315
+ pattern 13.
316
+ - **Always render the fallback** (initials). Images fail, load slowly, or
317
+ aren't set, and the cell must still look intentional.
318
+ - The image is decorative (`alt=""`) because the name is right beside
319
+ it. Otherwise a screen reader reads the name twice.
320
+ - `size-6` keeps the row height unchanged at `compact` density
321
+ ([`design-language.md`](design-language.md) "Density").
322
+ - Initials on `bg-muted text-muted-foreground`. Categorical colours
323
+ would make every person look like a category.
324
+ - Avatar URLs load from wherever the API points. Use `loading="lazy"` so a
325
+ 25-row page doesn't fetch every image up front.
326
+ - **Cost:** a column of faces draws the eye more than any other cell. Use
327
+ it when *who* matters most in the row, not as decoration.
328
+
@@ -8,16 +8,24 @@ composites don't grow that way; they are built against a real app's screen.
8
8
 
9
9
  ## Semantic tones
10
10
 
11
- Three tones carry meaning: **success**, **warning**, **destructive**. Grey
12
- is not a tone — it is the absence of one, and most values should stay grey.
11
+ Four tones carry meaning: **success**, **warning**, **destructive** and
12
+ **info**. Grey is not a tone — it is the absence of one, and most values
13
+ should stay grey.
14
+
15
+ The first three say good or bad. **Info** says neither: *notice this*,
16
+ without a verdict. It is for a state that is worth seeing but isn't good
17
+ news or bad news: in progress, new, scheduled, beta. If the honest answer
18
+ to "is this good?" is "neither, but it matters right now", it's info. If
19
+ the answer is "neither, and it doesn't", it's grey: info is still a
20
+ colour, and the "about a third" limit below counts it.
13
21
 
14
22
  Each tone has two kinds of token, and picking the wrong one is the single
15
23
  most common way to ship an unreadable label:
16
24
 
17
25
  | Token | For | Why it can't be the other one |
18
26
  |---|---|---|
19
- | `--success` / `--warning` / `--destructive` (+ `-foreground`) | a **solid fill**, always with white text on it | chosen to contrast with *white*, so on the page background it's too light in light mode |
20
- | `--success-text` / `--warning-text` / `--destructive-text` | the tone as **text, a border or an icon** on the page background | chosen to contrast with *the page*, so as a fill under white text it's needlessly dark |
27
+ | `--success` / `--warning` / `--destructive` / `--info` (+ `-foreground`) | a **solid fill**, always with white text on it | chosen to contrast with *white*, so on the page background it's too light in light mode |
28
+ | `--success-text` / `--warning-text` / `--destructive-text` / `--info-text` | the tone as **text, a border or an icon** on the page background | chosen to contrast with *the page*, so as a fill under white text it's needlessly dark |
21
29
 
22
30
  Only the `*-text` tokens are remapped in `.dark`. A solid fill keeps one
23
31
  shade in both themes, because it always carries white text and the page
@@ -25,20 +33,22 @@ behind it never enters the calculation.
25
33
 
26
34
  Measured ratios (WCAG 2.x, sRGB) are recorded next to each primitive in
27
35
  the package's `styles/theme.css`, and its `e2e/storybook-visual.spec.ts` re-checks all
28
- nine tone/style combinations with axe against the rendered DOM in both
36
+ twelve tone/style combinations with axe against the rendered DOM in both
29
37
  themes. **A new tone or style is not done until it appears in that story.**
30
38
 
31
39
  ## The three badge styles
32
40
 
33
41
  `Badge` ships each tone in three styles. They are not interchangeable:
34
42
 
35
- - **Outline** — `outline-success`, `outline-warning`, `outline-destructive`.
43
+ - **Outline** — `outline-success`, `outline-warning`, `outline-destructive`,
44
+ `outline-info`.
36
45
  The default choice, and the one proven across real columns. Quiet enough
37
46
  to repeat down 25 rows without the column becoming a wall of color.
38
- - **Tinted** — `tinted-success`, `tinted-warning`, `tinted-destructive`. A
47
+ - **Tinted** — `tinted-success`, `tinted-warning`, `tinted-destructive`,
48
+ `tinted-info`. A
39
49
  low-alpha wash of the tone with `*-text` on top. More presence than
40
50
  outline; use when the value is the point of the row, not a detail of it.
41
- - **Solid** — `success`, `warning`, `destructive`. The loudest. Use for one
51
+ - **Solid** — `success`, `warning`, `destructive`, `info`. The loudest. Use for one
42
52
  value that must be impossible to miss, rarely for a whole column: solid
43
53
  reads as an alert, and a column of alerts reads as noise.
44
54
 
@@ -75,7 +85,7 @@ Two rules that keep this honest:
75
85
 
76
86
  ## Categorical colour
77
87
 
78
- Tones say good or bad. A value that is merely *different* from its
88
+ Tones say good, bad or *notice this*. A value that is merely *different* from its
79
89
  neighbours — a genre, a team, a category — needs a different mechanism, and
80
90
  it is `--category-1` through `--category-8`.
81
91
 
@@ -98,7 +108,9 @@ Four things about them:
98
108
 
99
109
  - **The hues avoid red, green and amber**, which the tones already spend. A
100
110
  category drawn in red reads as "this one is bad" even when nothing is
101
- wrong.
111
+ wrong. Info's blue came later, and slots 1, 6 and 8 sit near it: a table
112
+ that shows an info badge keeps its categories off those three, so a blue
113
+ genre isn't read as "notice this".
102
114
  - **They are ordered by distinctness, not by hue angle.** An app using
103
115
  three categories gets three obviously different colours. Past about five,
104
116
  hue alone stops separating them — slots 3 and 7 are both yellow-greens —
@@ -114,3 +126,57 @@ Four things about them:
114
126
 
115
127
  If you find yourself wanting a ninth, that is usually a sign the column
116
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
+
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { useRender } from "@base-ui/react/use-render";
2
2
  import { type VariantProps } from "class-variance-authority";
3
3
  declare const badgeVariants: (props?: ({
4
- variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "warning" | "outline-success" | "outline-warning" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-destructive" | null | undefined;
4
+ variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "warning" | "info" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
7
7
  export { Badge, badgeVariants };
@@ -10,14 +10,16 @@ const badgeVariants = cva("group/badge inline-flex h-5 w-fit shrink-0 items-cent
10
10
  destructive: "bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/90",
11
11
  success: "bg-success text-success-foreground focus-visible:ring-success/20 dark:focus-visible:ring-success/40 [a]:hover:bg-success/90",
12
12
  warning: "bg-warning text-warning-foreground focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 [a]:hover:bg-warning/90",
13
+ info: "bg-info text-info-foreground focus-visible:ring-info/20 dark:focus-visible:ring-info/40 [a]:hover:bg-info/90",
13
14
  outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
14
- // Three tones x three styles. The *-text tokens, never the solid
15
+ // Four tones x three styles. The *-text tokens, never the solid
15
16
  // fill tokens: `border-destructive`/`text-destructive` would use
16
17
  // the shade chosen to carry white text, which is too light to read
17
18
  // against the page in light mode and too dark in dark mode. See
18
19
  // styles/theme.css and conventions/docs/design-language.md.
19
20
  "outline-success": "border-success-text text-success-text",
20
21
  "outline-warning": "border-warning-text text-warning-text",
22
+ "outline-info": "border-info-text text-info-text",
21
23
  "outline-destructive": "border-destructive-text text-destructive-text",
22
24
  // The fill is the solid token at low alpha (a wash of the tone),
23
25
  // while the text is the *-text shade — mixing the two is the point.
@@ -25,6 +27,7 @@ const badgeVariants = cva("group/badge inline-flex h-5 w-fit shrink-0 items-cent
25
27
  // against a near-black page.
26
28
  "tinted-success": "bg-success/15 text-success-text dark:bg-success/20",
27
29
  "tinted-warning": "bg-warning/15 text-warning-text dark:bg-warning/20",
30
+ "tinted-info": "bg-info/15 text-info-text dark:bg-info/20",
28
31
  "tinted-destructive": "bg-destructive/15 text-destructive-text dark:bg-destructive/20",
29
32
  ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
30
33
  link: "text-primary underline-offset-4 hover:underline",
@@ -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.1.2",
3
+ "version": "3.3.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
@@ -44,6 +44,9 @@
44
44
  --color-warning: var(--warning);
45
45
  --color-warning-foreground: var(--warning-foreground);
46
46
  --color-warning-text: var(--warning-text);
47
+ --color-info: var(--info);
48
+ --color-info-foreground: var(--info-foreground);
49
+ --color-info-text: var(--info-text);
47
50
  --color-category-1: var(--category-1);
48
51
  --color-category-2: var(--category-2);
49
52
  --color-category-3: var(--category-3);
@@ -75,6 +78,34 @@
75
78
  --radius-4xl: calc(var(--radius) * 2.6);
76
79
  }
77
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
+
78
109
  @layer base {
79
110
  * {
80
111
  @apply border-border outline-ring/50;
package/styles/theme.css CHANGED
@@ -53,6 +53,9 @@
53
53
  --amber-700: oklch(0.555 0.163 48.998); /* white on it: 5.05 */
54
54
  --amber-800: oklch(0.47 0.14 48.998); /* on white: 7.20 */
55
55
  --amber-400: oklch(0.828 0.189 84.429); /* on near-black: 11.53 */
56
+ --blue-700: oklch(0.52 0.17 255); /* white on it: 5.59 */
57
+ --blue-800: oklch(0.45 0.15 255); /* on white: 7.54 */
58
+ --blue-400: oklch(0.75 0.12 255); /* on near-black: 8.90 */
56
59
  /*
57
60
  * Categorical slots. For values that differ from each other but are not
58
61
  * better or worse than each other — a genre, a team, a category. Tones
@@ -65,7 +68,8 @@
65
68
  *
66
69
  * Hues avoid the red and green bands that destructive/success already
67
70
  * spend, and the amber that warning does — a category drawn in red reads
68
- * as "bad". They are ordered by distinctness, not by hue angle, so an app
71
+ * as "bad". The blue slots (1, 6, 8) sit near info's hue, which came
72
+ * later: a table using info keeps its categories off those three. They are ordered by distinctness, not by hue angle, so an app
69
73
  * using only the first three gets three obviously different colours.
70
74
  * Past about five, hue alone stops separating them and the glyph has to
71
75
  * carry the meaning; see docs/design-language.md.
@@ -95,6 +99,41 @@
95
99
 
96
100
  --radius-base: 0.625rem;
97
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
+
98
137
  /* Layer 2 — semantic (light) */
99
138
  --background: var(--white);
100
139
  --foreground: var(--black);
@@ -116,7 +155,9 @@
116
155
  --success-foreground: var(--white);
117
156
  --warning: var(--amber-700);
118
157
  --warning-foreground: var(--white);
119
- /* The three pairs above are a SOLID fill plus its own text color, and
158
+ --info: var(--blue-700);
159
+ --info-foreground: var(--white);
160
+ /* The four pairs above are a SOLID fill plus its own text color, and
120
161
  nothing else. Used as text or a border on the page's own background —
121
162
  an outline badge, a tinted chip, an icon tint — a fill shade is the
122
163
  wrong value: it was picked to carry white, not to read against the
@@ -125,6 +166,7 @@
125
166
  --destructive-text: var(--red-700);
126
167
  --success-text: var(--green-800);
127
168
  --warning-text: var(--amber-800);
169
+ --info-text: var(--blue-800);
128
170
  /* Categorical slots — a glyph tint, never a fill behind text. */
129
171
  --category-1: var(--category-1-700);
130
172
  --category-2: var(--category-2-700);
@@ -153,6 +195,16 @@
153
195
  --sidebar-ring: var(--gray-400);
154
196
  }
155
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
+
156
208
  .dark {
157
209
  /* Layer 2 — semantic (dark). Only this block is remapped; Layer 1 is not. */
158
210
  --background: var(--black);
@@ -175,12 +227,15 @@
175
227
  --success-foreground: var(--white);
176
228
  --warning: var(--amber-700);
177
229
  --warning-foreground: var(--white);
230
+ --info: var(--blue-700);
231
+ --info-foreground: var(--white);
178
232
  /* Solid fills keep one shade in both themes — they always carry white
179
233
  text, so the page behind them never enters the calculation. Only the
180
234
  *-text shades flip, because those read against the page. */
181
235
  --destructive-text: var(--red-400);
182
236
  --success-text: var(--green-400);
183
237
  --warning-text: var(--amber-400);
238
+ --info-text: var(--blue-400);
184
239
  /* Categorical slots — lighter shades to read against the dark page. */
185
240
  --category-1: var(--category-1-400);
186
241
  --category-2: var(--category-2-400);