@tristan2828/ui-foundation 3.27.4 → 3.28.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.
@@ -227,9 +227,9 @@ pulls the eye from every other column (cell patterns 16 and 20).
227
227
 
228
228
  ## Density
229
229
 
230
- Tables have three densities, set with `data-density` on **any ancestor**
231
- (a wrapper `<div>`, a route's layout, or `<html>` for the whole app). It is
232
- a token set, not a `DataTable` prop:
230
+ Four densities, set with `data-density` on **any ancestor** (a wrapper
231
+ `<div>`, a route's layout, or `<html>` for the whole app). It is a token
232
+ set, not a `DataTable` prop:
233
233
 
234
234
  ```tsx
235
235
  <div data-density="compact">
@@ -237,18 +237,37 @@ a token set, not a `DataTable` prop:
237
237
  </div>
238
238
  ```
239
239
 
240
- | `data-density` | Cell padding (vertical) | Header height | For |
241
- |---|---|---|---|
242
- | `compact` | 4px | 32px | wide tables people scan, many rows per screen |
243
- | *(unset)* | 8px | 40px | the default |
244
- | `comfortable` | 12px | 48px | short tables, or rows people read rather than scan |
245
-
246
- - Density changes padding only. Text keeps `type-body`, so compact never
247
- drops below a readable size.
240
+ | `data-density` | Changes | Table cell padding (vertical) | Header height | For |
241
+ |---|---|---|---|---|
242
+ | `compact` | table rows only | 4px | 32px | wide tables people scan, many rows per screen |
243
+ | *(unset)* | — | 8px | 40px | the default |
244
+ | `comfortable` | table rows only | 12px | 48px | short tables, or rows people read rather than scan |
245
+ | `large` | **everything**: type, spacing, control heights, icons, the sidebar, table rows | 12px | 56px | a screen read from a distance: a TV used as the desktop monitor, with a mouse and keyboard |
246
+
247
+ - `compact` and `comfortable` change padding only. Text keeps
248
+ `type-body`, so compact never drops below a readable size.
249
+ - `large` grows the whole UI by about a quarter: body text 14 → 18px,
250
+ controls 32 → 40px tall, icons 16 → 20px, the sidebar 256 → 320px.
251
+ Breakpoints and `max-w-*` containers stay where they are, which is
252
+ what browser zoom can't do (zoom narrows the viewport in CSS pixels, so
253
+ a wide screen can fall into the phone layout).
254
+ - For `large`, set it on **`<html>`**. Menus, selects, dialogs and
255
+ tooltips are portaled to `<body>`, so a wrapper `<div>` doesn't reach
256
+ them. `compact` and `comfortable` only touch tables, so a wrapper is fine.
248
257
  - Pick **one density per screen**. Two tables at different densities side
249
258
  by side look like a mistake.
259
+ - Size things the way the package does and `large` reaches them for free:
260
+ Tailwind's spacing and size utilities (`h-8`, `size-4`, `gap-2`, `w-64`)
261
+ and `text-*`/`type-*`. A fixed `h-[18px]`, `w-[240px]` or
262
+ `text-[13px]` stays its size at `large`; write it as a step of the scale
263
+ (`h-[--spacing(4.5)]`) instead.
250
264
  - Under the hood, the table primitive's cells read `--table-cell-px`,
251
- `--table-cell-py` and `--table-head-height`, and the `data-density`
252
- selectors in `styles/theme.css` set them. The package's Storybook check
253
- measures that each step really changes the row height.
254
-
265
+ `--table-cell-py` and `--table-head-height`. `large` also redefines
266
+ Tailwind's own scale (`--spacing`, `--text-*`, `--radius`), the `--type-*`
267
+ roles and `--control-text-sm` (the small Button and Toggle). Each
268
+ default is the value it always had, so a screen without `data-density`
269
+ doesn't change. The selectors are in `styles/theme.css`. Storybook's
270
+ Density toolbar shows any story at a density, and `patterns/Density`
271
+ shows the primitives, `DataTable` and the sidebar at each. Its checks
272
+ measure that every step changes what it should, and that the default
273
+ keeps today's sizes.
@@ -79,7 +79,7 @@ const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
79
79
  // width it last had loaded while it shows another state
80
80
  // (--data-table-last-width), at most the area. Narrower, no change.
81
81
  const CONTENT_WIDTH_CLASS = '@7xl/data-table:w-fit @7xl/data-table:max-w-full ' +
82
- '@7xl/data-table:min-w-[max(36rem,var(--data-table-last-width,0px))]';
82
+ '@7xl/data-table:min-w-[max(--spacing(144),var(--data-table-last-width,0px))]';
83
83
  // A second horizontal scrollbar, stuck to the viewport's bottom edge. The
84
84
  // real one sits directly under the last row, which on a full page is
85
85
  // off-screen until you have already scrolled past every row. Only rendered
@@ -67,8 +67,8 @@ function orEmptyLabel(value, emptyLabel) {
67
67
  // <dl>): label above value in the narrow rail, beside it once the rail's
68
68
  // sections stack full width under the header.
69
69
  const FIELD_ROW = {
70
- page: 'sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6',
71
- rail: '@lg/fields:grid @lg/fields:grid-cols-[minmax(8rem,12rem)_1fr] @lg/fields:gap-6',
70
+ page: 'sm:grid sm:grid-cols-[minmax(--spacing(32),--spacing(48))_1fr] sm:gap-6',
71
+ rail: '@lg/fields:grid @lg/fields:grid-cols-[minmax(--spacing(32),--spacing(48))_1fr] @lg/fields:gap-6',
72
72
  };
73
73
  function FieldRow({ field, inRail }) {
74
74
  return (_jsxs("div", { className: cn('flex flex-col gap-1 py-3 first:pt-0 last:pb-0', FIELD_ROW[inRail ? 'rail' : 'page']), children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: field.edit ? (_jsx(EditableValue, { label: field.label, edit: field.edit, children: orEmptyLabel(field.value, field.emptyLabel) })) : (orEmptyLabel(field.value, field.emptyLabel)) })] }));
@@ -85,7 +85,7 @@ function ViewSection({ section, inRail = false }) {
85
85
  // right of row 1 and main on its left, both starting under the header.
86
86
  const RAIL_LAYOUT = {
87
87
  page: '@container/view flex min-w-0 flex-col gap-6',
88
- columns: 'flex flex-col gap-6 @4xl/view:grid @4xl/view:grid-cols-[minmax(0,1fr)_22rem] @4xl/view:items-start',
88
+ columns: 'flex flex-col gap-6 @4xl/view:grid @4xl/view:grid-cols-[minmax(0,1fr)_--spacing(88)] @4xl/view:items-start',
89
89
  rail: '@4xl/view:col-start-2 @4xl/view:row-start-1',
90
90
  main: 'flex min-w-0 flex-col gap-6 @4xl/view:col-start-1 @4xl/view:row-start-1',
91
91
  // Main alone (no rail sections): the whole row.
@@ -15,7 +15,7 @@ const buttonVariants = cva("group/button inline-flex shrink-0 items-center justi
15
15
  size: {
16
16
  default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
17
17
  xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
18
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
18
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-(length:--control-text-sm) in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
19
19
  lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
20
20
  icon: "size-8",
21
21
  "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
@@ -26,10 +26,10 @@ function Calendar({ className, classNames, showOutsideDays = true, captionLayout
26
26
  : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", defaultClassNames.caption_label),
27
27
  month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
28
28
  weekdays: cn("flex", defaultClassNames.weekdays),
29
- weekday: cn("flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", defaultClassNames.weekday),
29
+ weekday: cn("flex-1 rounded-(--cell-radius) text-(length:--control-text-sm) font-normal text-muted-foreground select-none", defaultClassNames.weekday),
30
30
  week: cn("mt-2 flex w-full", defaultClassNames.week),
31
31
  week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
32
- week_number: cn("text-[0.8rem] text-muted-foreground select-none", defaultClassNames.week_number),
32
+ week_number: cn("text-(length:--control-text-sm) text-muted-foreground select-none", defaultClassNames.week_number),
33
33
  day: cn("group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", props.showWeekNumber
34
34
  ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
35
35
  : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", defaultClassNames.day),
@@ -31,7 +31,7 @@ function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
31
31
  return (_jsxs(MenuPrimitive.SubmenuTrigger, { "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset, className: cn("flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
32
32
  }
33
33
  function DropdownMenuSubContent({ align = "start", alignOffset = -3, side = "right", sideOffset = 0, className, ...props }) {
34
- return (_jsx(DropdownMenuContent, { "data-slot": "dropdown-menu-sub-content", className: cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, ...props }));
34
+ return (_jsx(DropdownMenuContent, { "data-slot": "dropdown-menu-sub-content", className: cn("w-auto min-w-24 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, ...props }));
35
35
  }
36
36
  function DropdownMenuCheckboxItem({ className, children, checked, inset, ...props }) {
37
37
  return (_jsxs(MenuPrimitive.CheckboxItem, { "data-slot": "dropdown-menu-checkbox-item", "data-inset": inset, className: cn("relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), checked: checked, ...props, children: [_jsx("span", { className: "pointer-events-none absolute right-2 flex items-center justify-center", "data-slot": "dropdown-menu-checkbox-item-indicator", children: _jsx(MenuPrimitive.CheckboxItemIndicator, { children: _jsx(CheckIcon, {}) }) }), children] }));
@@ -14,9 +14,14 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "./tooltip.js";
14
14
  import { PanelLeftIcon } from "lucide-react";
15
15
  const SIDEBAR_COOKIE_NAME = "sidebar_state";
16
16
  const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
17
- const SIDEBAR_WIDTH = "16rem";
18
- const SIDEBAR_WIDTH_MOBILE = "18rem";
19
- const SIDEBAR_WIDTH_ICON = "3rem";
17
+ // This repo's patch: the widths are steps of Tailwind's spacing scale, not
18
+ // rem literals (upstream's 16rem, 18rem, 3rem), so data-density="large"
19
+ // widens the sidebar with everything in it and the icon rail still fits
20
+ // its buttons (styles/theme.css). Diff against upstream before taking a
21
+ // new version of this file.
22
+ const SIDEBAR_WIDTH = "calc(var(--spacing) * 64)";
23
+ const SIDEBAR_WIDTH_MOBILE = "calc(var(--spacing) * 72)";
24
+ const SIDEBAR_WIDTH_ICON = "calc(var(--spacing) * 12)";
20
25
  const SIDEBAR_KEYBOARD_SHORTCUT = "b";
21
26
  const SidebarContext = React.createContext(null);
22
27
  function useSidebar() {
@@ -2,6 +2,6 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
3
3
  import { cn } from "cn";
4
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" }) }));
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-[--spacing(4.6)] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 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
6
  }
7
7
  export { Switch };
@@ -15,7 +15,7 @@ const toggleVariants = cva("group/toggle inline-flex items-center justify-center
15
15
  },
16
16
  size: {
17
17
  default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
18
- sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
18
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-(length:--control-text-sm) has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
19
19
  lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
20
20
  // 24px: the smallest target WCAG 2.2 allows (2.5.8).
21
21
  "icon-xs": "size-6 min-w-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.27.4",
3
+ "version": "3.28.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/theme.css CHANGED
@@ -164,6 +164,10 @@
164
164
  --table-cell-px: 0.5rem;
165
165
  --table-cell-py: 0.5rem;
166
166
  --table-head-height: 2.5rem;
167
+ /* The small size of Button and Toggle (and the calendar's weekdays):
168
+ between text-xs and text-sm, so it has a token of its own for the
169
+ large density to scale. */
170
+ --control-text-sm: 0.8rem;
167
171
 
168
172
  /* Layer 2 — semantic (light) */
169
173
  --background: var(--white);
@@ -251,6 +255,58 @@
251
255
  --table-head-height: 3rem;
252
256
  }
253
257
 
258
+ /*
259
+ * Large: a screen read from a distance (a TV used as the desktop monitor,
260
+ * with a mouse and keyboard). Not only tables: everything under it grows,
261
+ * by about a quarter, while the layout's breakpoints stay where they are
262
+ * (browser zoom would move them and switch a wide screen to the phone
263
+ * layout).
264
+ *
265
+ * Most of it is Tailwind's own scale. Every spacing, size and icon utility
266
+ * compiles to calc(var(--spacing) * n) and every text-* to var(--text-*),
267
+ * so redefining those here reaches every primitive, composite and app
268
+ * class at once. The primitives that sized something outside that scale
269
+ * (the sidebar's width, the switch, the small control text) were patched
270
+ * to read it. Each value below is a literal, never var() of another
271
+ * token: a custom property is resolved where it is declared, so one
272
+ * built from --spacing on :root would keep :root's value under here.
273
+ *
274
+ * Popups (menus, selects, dialogs, tooltips) are portaled to <body>, so
275
+ * they only grow when data-density="large" is on <html>.
276
+ */
277
+ [data-density="large"] {
278
+ --spacing: 0.3125rem; /* 4px → 5px: h-8 is 40px, size-4 is 20px */
279
+ --text-xs: 0.9375rem; /* 12 → 15px */
280
+ --text-sm: 1.125rem; /* 14 → 18px: most UI text */
281
+ --text-base: 1.25rem; /* 16 → 20px */
282
+ --text-lg: 1.375rem; /* 18 → 22px */
283
+ --text-xl: 1.5625rem; /* 20 → 25px */
284
+ --text-2xl: 1.875rem; /* 24 → 30px */
285
+ --control-text-sm: 1rem; /* 12.8 → 16px */
286
+ --radius: 0.75rem; /* 10 → 12px, so larger controls keep their shape */
287
+
288
+ --type-page-title-size: 1.375rem;
289
+ --type-page-title-leading: 2.125rem;
290
+ --type-section-title-size: 1.25rem;
291
+ --type-section-title-leading: 1.875rem;
292
+ --type-body-size: 1.125rem;
293
+ --type-body-leading: 1.625rem;
294
+ --type-label-size: 1.125rem;
295
+ --type-label-leading: 1.625rem;
296
+ --type-caption-size: 0.9375rem;
297
+ --type-caption-leading: 1.25rem;
298
+ --type-heading-1-size: 2rem;
299
+ --type-heading-1-leading: 2.5rem;
300
+ --type-heading-2-size: 1.5625rem;
301
+ --type-heading-2-leading: 2.125rem;
302
+ --type-heading-3-size: 1.3125rem;
303
+ --type-heading-3-leading: 1.875rem;
304
+
305
+ --table-cell-px: 0.75rem;
306
+ --table-cell-py: 0.75rem;
307
+ --table-head-height: 3.5rem;
308
+ }
309
+
254
310
  .dark {
255
311
  /* Layer 2 — semantic (dark). Only this block is remapped; Layer 1 is not. */
256
312
  --background: var(--black);