@dashforge/tw 0.5.0-beta → 0.7.0-beta

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2504 -51
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +171 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +216 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  13. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  14. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  15. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  16. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  17. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  18. package/dist/src/components/Table/Table.d.ts +24 -0
  19. package/dist/src/components/Table/Table.d.ts.map +1 -0
  20. package/dist/src/components/Table/cells/RenderButton.d.ts +13 -0
  21. package/dist/src/components/Table/cells/RenderButton.d.ts.map +1 -0
  22. package/dist/src/components/Table/cells/RenderChip.d.ts +80 -0
  23. package/dist/src/components/Table/cells/RenderChip.d.ts.map +1 -0
  24. package/dist/src/components/Table/cells/RenderText.d.ts +21 -0
  25. package/dist/src/components/Table/cells/RenderText.d.ts.map +1 -0
  26. package/dist/src/components/Table/cells/RenderTwoLine.d.ts +20 -0
  27. package/dist/src/components/Table/cells/RenderTwoLine.d.ts.map +1 -0
  28. package/dist/src/components/Table/cells/RowActionsMenu.d.ts +36 -0
  29. package/dist/src/components/Table/cells/RowActionsMenu.d.ts.map +1 -0
  30. package/dist/src/components/Table/cells/index.d.ts +11 -0
  31. package/dist/src/components/Table/cells/index.d.ts.map +1 -0
  32. package/dist/src/components/Table/table.types.d.ts +312 -0
  33. package/dist/src/components/Table/table.types.d.ts.map +1 -0
  34. package/dist/src/components/Table/table.variants.d.ts +285 -0
  35. package/dist/src/components/Table/table.variants.d.ts.map +1 -0
  36. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  37. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  38. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  39. package/dist/src/components/_shared/data/getNestedValue.d.ts +20 -0
  40. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  41. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts +45 -0
  42. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  43. package/dist/src/components/_shared/data/useControllableState.d.ts +23 -0
  44. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useDebouncedValue.d.ts +10 -0
  46. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  47. package/dist/src/components/_shared/data/useTableFilter.d.ts +13 -0
  48. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  49. package/dist/src/components/_shared/data/useTableSearch.d.ts +23 -0
  50. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  51. package/dist/src/components/_shared/data/useTableSelection.d.ts +25 -0
  52. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  53. package/dist/src/components/_shared/data/useTableSort.d.ts +20 -0
  54. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  55. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  56. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  57. package/dist/src/index.d.ts +10 -1
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/package.json +3 -3
  60. package/src/components/Accordion/accordion.variants.ts +5 -3
  61. package/src/components/Box/Box.test.tsx +32 -11
  62. package/src/components/Box/box.variants.ts +23 -4
  63. package/src/components/DataGrid/DataGrid.test.tsx +496 -0
  64. package/src/components/DataGrid/DataGrid.tsx +831 -0
  65. package/src/components/DataGrid/dataGrid.types.ts +189 -0
  66. package/src/components/DataGrid/dataGrid.variants.ts +144 -0
  67. package/src/components/Dialog/dialog.variants.ts +10 -6
  68. package/src/components/Divider/Divider.test.tsx +3 -1
  69. package/src/components/Divider/Divider.tsx +1 -1
  70. package/src/components/Divider/divider.variants.ts +2 -1
  71. package/src/components/Pagination/Pagination.tsx +3 -1
  72. package/src/components/Pagination/pagination.variants.ts +19 -13
  73. package/src/components/Popover/popover.variants.ts +10 -4
  74. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  75. package/src/components/Table/Table.test.tsx +603 -0
  76. package/src/components/Table/Table.tsx +816 -0
  77. package/src/components/Table/cells/RenderButton.tsx +23 -0
  78. package/src/components/Table/cells/RenderChip.tsx +103 -0
  79. package/src/components/Table/cells/RenderText.tsx +37 -0
  80. package/src/components/Table/cells/RenderTwoLine.tsx +34 -0
  81. package/src/components/Table/cells/RowActionsMenu.tsx +147 -0
  82. package/src/components/Table/cells/index.ts +14 -0
  83. package/src/components/Table/table.types.ts +346 -0
  84. package/src/components/Table/table.variants.ts +207 -0
  85. package/src/components/Tabs/tabs.variants.ts +19 -8
  86. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  87. package/src/components/Typography/Typography.test.tsx +3 -1
  88. package/src/components/Typography/typography.variants.ts +6 -2
  89. package/src/components/_shared/data/getNestedValue.test.ts +50 -0
  90. package/src/components/_shared/data/getNestedValue.ts +32 -0
  91. package/src/components/_shared/data/useColumnAutoDetect.test.ts +123 -0
  92. package/src/components/_shared/data/useColumnAutoDetect.ts +100 -0
  93. package/src/components/_shared/data/useControllableState.ts +48 -0
  94. package/src/components/_shared/data/useDebouncedValue.ts +24 -0
  95. package/src/components/_shared/data/useTableFilter.ts +37 -0
  96. package/src/components/_shared/data/useTableSearch.test.ts +115 -0
  97. package/src/components/_shared/data/useTableSearch.ts +66 -0
  98. package/src/components/_shared/data/useTableSelection.test.ts +100 -0
  99. package/src/components/_shared/data/useTableSelection.ts +100 -0
  100. package/src/components/_shared/data/useTableSort.test.ts +150 -0
  101. package/src/components/_shared/data/useTableSort.ts +141 -0
  102. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  103. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  104. package/src/components/_shared/themeIdentity.test.ts +113 -0
  105. package/src/index.ts +59 -1
@@ -0,0 +1,346 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AccessRequirement } from '@dashforge/rbac';
3
+ import type { TableVariants } from './table.variants.js';
4
+
5
+ // ───── Nested key util ─────
6
+
7
+ /**
8
+ * Type-level dotted path through an object's nested structure.
9
+ *
10
+ * `NestedKeyOf<{ user: { name: string } }>` → `'user' | 'user.name'`.
11
+ *
12
+ * Used as the `field` of a column to give autocomplete on nested
13
+ * paths. The runtime lookup happens via `getNestedValue` in
14
+ * `_internal/getNestedValue.ts` (split on `.` and walk).
15
+ */
16
+ export type NestedKeyOf<T extends object> = {
17
+ [K in keyof T & (string | number)]: T[K] extends object | undefined
18
+ ? T[K] extends ReadonlyArray<unknown>
19
+ ? `${K}`
20
+ : NonNullable<T[K]> extends object
21
+ ? `${K}` | `${K}.${NestedKeyOf<NonNullable<T[K]>>}`
22
+ : `${K}`
23
+ : `${K}`;
24
+ }[keyof T & (string | number)];
25
+
26
+ // ───── Column types ─────
27
+
28
+ /**
29
+ * Auto-detected primitive type for a column. Drives smart defaults:
30
+ * - `number` → align right + monospace (`tabular-nums`)
31
+ * - `date` → align left + monospace (consistent ISO width)
32
+ * - `boolean` → align center
33
+ * - `string` → align left (default)
34
+ * - `unknown` → fallback to left, no monospace
35
+ */
36
+ export type TableColumnInferredType =
37
+ | 'number'
38
+ | 'date'
39
+ | 'boolean'
40
+ | 'string'
41
+ | 'unknown';
42
+
43
+ /**
44
+ * Context passed to a custom `cellRenderer`. Mirrors what the consumer
45
+ * needs to render a cell: row, resolved value, position, selection
46
+ * state, expand state, and the resolved RBAC access state for THIS
47
+ * column.
48
+ */
49
+ export interface TableCellContext<T> {
50
+ row: T;
51
+ value: unknown;
52
+ rowIndex: number;
53
+ isSelected: boolean;
54
+ isExpanded: boolean;
55
+ /** Resolved RBAC state for this column. `null` if no `access` set. */
56
+ access: { hidden: boolean; disabled: boolean; readonly: boolean } | null;
57
+ }
58
+
59
+ /**
60
+ * One column definition for `<Table>`.
61
+ *
62
+ * **i18n**: pass `t('users.name')` directly to `header` — `header`
63
+ * accepts `string` for the common case. Use the function form
64
+ * `header: () => <span>…</span>` only when you need richer JSX.
65
+ *
66
+ * **Smart defaults**: when `align` / `monospace` are omitted, the
67
+ * Table auto-detects the column type from the first non-null value
68
+ * across the visible rows and applies `right + tabular-nums` for
69
+ * numbers, `center` for booleans, etc. Explicit props always win.
70
+ */
71
+ export interface TableColumn<T extends object> {
72
+ /**
73
+ * Dotted path to the value in each row. Supports nested keys
74
+ * (`'address.city.zipCode'`) thanks to `NestedKeyOf<T>` typing +
75
+ * runtime `getNestedValue()`.
76
+ */
77
+ field: NestedKeyOf<T> | (string & {});
78
+
79
+ /**
80
+ * Visible header. Pass a translation string directly:
81
+ * `header: t('users.name')`. Use the function form for richer
82
+ * JSX (e.g. header with an icon).
83
+ */
84
+ header: string | (() => ReactNode);
85
+
86
+ /** Pixel width (`px`). Mutually informative with `flex`. */
87
+ width?: number;
88
+ /** Flex grow factor (CSS `flex: <n> 1 0`). */
89
+ flex?: number;
90
+ /** Minimum pixel width (for resizable columns — deferred to v1-bis). */
91
+ minWidth?: number;
92
+
93
+ /**
94
+ * Horizontal alignment. Default is auto-detected from value type
95
+ * (number → right, boolean → center, otherwise left).
96
+ */
97
+ align?: 'left' | 'right' | 'center';
98
+
99
+ /**
100
+ * Opt-in to a monospace **font family** for this column. The
101
+ * library never auto-applies `font-mono` — it would override the
102
+ * consumer's theme `font-sans`. Set `true` explicitly only when
103
+ * the design calls for a typewriter font (e.g. raw hex IDs).
104
+ *
105
+ * The dashforgePreset does NOT own the `fontFamily` axis; the
106
+ * consumer configures the mono stack in their own
107
+ * `tailwind.config.ts` (`theme.extend.fontFamily.mono`).
108
+ */
109
+ monospace?: boolean;
110
+
111
+ /**
112
+ * Apply `font-variant-numeric: tabular-nums` so every digit
113
+ * occupies the same width — clean alignment for currency / metric
114
+ * columns. Does NOT change the font family. Auto-applied for
115
+ * `number` and `date` columns; pass `false` explicitly to opt out
116
+ * or `true` to force on a string column.
117
+ */
118
+ tabularNums?: boolean;
119
+
120
+ /**
121
+ * Make the column sortable. Pass `true` for default comparator
122
+ * (works for string/number/date/boolean) or a custom
123
+ * `(a, b) => number`.
124
+ */
125
+ sortable?: boolean | ((a: T, b: T) => number);
126
+
127
+ /**
128
+ * Include this column in the global search filter.
129
+ * @default false
130
+ */
131
+ searchable?: boolean;
132
+
133
+ /**
134
+ * Enable per-column quick filter (text-based in v1).
135
+ * @default false
136
+ */
137
+ filterable?: boolean;
138
+
139
+ /**
140
+ * Custom cell renderer. Receives the full row + the resolved
141
+ * (potentially nested) value, the row index, selection state,
142
+ * expansion state, and the RBAC access state.
143
+ */
144
+ cellRenderer?: (ctx: TableCellContext<T>) => ReactNode;
145
+
146
+ /**
147
+ * Per-column RBAC. `onUnauthorized: 'hide'` removes the column
148
+ * entirely from header AND every row cell (a11y-correct).
149
+ * `disable` / `readonly` are forwarded to `cellRenderer` via
150
+ * `ctx.access` — the renderer is expected to honor them.
151
+ */
152
+ access?: AccessRequirement;
153
+
154
+ /**
155
+ * Pin this column during horizontal scroll. `'left'` (DataGrid v1)
156
+ * uses CSS `position: sticky; left: 0`. `'right'` is reserved for
157
+ * v1-bis (CSS edge cases around last-column borders). Table
158
+ * (non-virtualized) ignores this prop today — only DataGrid honors
159
+ * it (Sprint 4.2).
160
+ */
161
+ sticky?: 'left' | 'right';
162
+
163
+ /** Reserved for v1-bis (column-visibility dialog). */
164
+ hideable?: boolean;
165
+ defaultHidden?: boolean;
166
+ }
167
+
168
+ // ───── State models ─────
169
+
170
+ export type TableSortDirection = 'asc' | 'desc';
171
+
172
+ export interface TableSortItem {
173
+ field: string;
174
+ direction: TableSortDirection;
175
+ }
176
+
177
+ /**
178
+ * Sort model. Single-column sort = array of length 1. Multi-column
179
+ * sort = array of N (typically up to 3). The order matters: first
180
+ * item is the primary sort key, subsequent items break ties.
181
+ */
182
+ export type TableSortModel = TableSortItem[];
183
+
184
+ export interface TableFilterItem {
185
+ field: string;
186
+ /** Text substring match. Future ops: `equals`, `in`, `gte`, `lte`, `between`. */
187
+ op: 'contains';
188
+ value: string;
189
+ }
190
+
191
+ export type TableFilterModel = TableFilterItem[];
192
+
193
+ export type TableRowSelectionMode = 'none' | 'single' | 'multiple';
194
+
195
+ // ───── i18n labels ─────
196
+
197
+ /**
198
+ * Internal default strings used by the Table for accessibility
199
+ * announcements, density toggle, search placeholder, etc. Pass the
200
+ * subset you need to translate; the rest fall back to English.
201
+ *
202
+ * For column headers, empty state, bulk actions and row action
203
+ * labels — pass the translated value directly via the matching
204
+ * prop (`header`, `emptyState`, `bulkActions`, `rowActions`).
205
+ */
206
+ export interface TableLabels {
207
+ searchPlaceholder?: string;
208
+ noData?: string;
209
+ loading?: string;
210
+ ariaSortNone?: string;
211
+ ariaSortAscending?: string;
212
+ ariaSortDescending?: string;
213
+ ariaSelectRow?: string;
214
+ ariaSelectAllRows?: string;
215
+ ariaExpandRow?: string;
216
+ ariaCollapseRow?: string;
217
+ /** "X selected" — `{count}` placeholder gets replaced. */
218
+ selectedCount?: string;
219
+ clearSelection?: string;
220
+ /** Per-column filter Popover. */
221
+ filterColumn?: string;
222
+ filterApply?: string;
223
+ filterClear?: string;
224
+ /** Density toggle. */
225
+ density?: string;
226
+ densityCompact?: string;
227
+ densityComfortable?: string;
228
+ densitySpacious?: string;
229
+ }
230
+
231
+ // ───── Slot props ─────
232
+
233
+ export interface TableSlotProps {
234
+ root?: { className?: string };
235
+ toolbar?: { className?: string };
236
+ search?: { className?: string };
237
+ scroll?: { className?: string };
238
+ table?: { className?: string };
239
+ thead?: { className?: string };
240
+ tbody?: { className?: string };
241
+ headerRow?: { className?: string };
242
+ headerCell?: { className?: string };
243
+ row?: { className?: string };
244
+ cell?: { className?: string };
245
+ emptyState?: { className?: string };
246
+ loadingOverlay?: { className?: string };
247
+ selectionCell?: { className?: string };
248
+ expandToggleCell?: { className?: string };
249
+ expandedRow?: { className?: string };
250
+ rowActionsCell?: { className?: string };
251
+ bulkActionFooter?: { className?: string };
252
+ }
253
+
254
+ // ───── Expandable rows config ─────
255
+
256
+ export interface TableExpandableConfig<T> {
257
+ render: (row: T) => ReactNode;
258
+ expandedRowIds?: string[];
259
+ onExpandChange?: (ids: string[]) => void;
260
+ }
261
+
262
+ // ───── Main props ─────
263
+
264
+ /**
265
+ * Props for `<Table>`.
266
+ *
267
+ * Hybrid declarative-first architecture: by default every state piece
268
+ * (sort, filter, search, selection, expansion) is internal. Pass the
269
+ * matching controlled prop + change handler to lift state up.
270
+ *
271
+ * Smart defaults: column types are auto-detected on first render —
272
+ * number columns get right-align + tabular-nums, dates get monospace,
273
+ * booleans get center-align, etc. Override via explicit `align` /
274
+ * `monospace` per column.
275
+ *
276
+ * Visual style: Stripe-inspired clean lines by default
277
+ * (`variant="lines"`), row actions revealed on hover, sticky header,
278
+ * `comfortable` density. Switch via variant / density / size props.
279
+ *
280
+ * i18n: column `header` accepts plain strings (use `t('...')`
281
+ * directly). All internal label strings configurable via `labels`
282
+ * prop with English defaults.
283
+ */
284
+ export interface TableProps<T extends object>
285
+ extends Pick<TableVariants, 'variant' | 'size' | 'density'> {
286
+ // ───── Data ─────
287
+ rows: T[];
288
+ cols: TableColumn<T>[];
289
+ /** Stable row id resolver. Default: positional index. */
290
+ getRowId?: (row: T, index: number) => string;
291
+
292
+ // ───── Sort ─────
293
+ sortModel?: TableSortModel;
294
+ onSortChange?: (model: TableSortModel) => void;
295
+ /** Default sort applied when uncontrolled. */
296
+ defaultSortModel?: TableSortModel;
297
+
298
+ // ───── Search ─────
299
+ enableSearch?: boolean;
300
+ searchQuery?: string;
301
+ onSearchQueryChange?: (q: string) => void;
302
+ searchPlaceholder?: string;
303
+ searchDebounceMs?: number;
304
+
305
+ // ───── Per-column filter ─────
306
+ filterModel?: TableFilterModel;
307
+ onFilterChange?: (model: TableFilterModel) => void;
308
+
309
+ // ───── Selection ─────
310
+ rowSelection?: TableRowSelectionMode;
311
+ selectedRowIds?: string[];
312
+ onSelectionChange?: (ids: string[]) => void;
313
+ /** Sticky bulk action footer (visible only when selection > 0). */
314
+ bulkActions?: (selectedRows: T[]) => ReactNode;
315
+
316
+ // ───── Expandable rows ─────
317
+ expandable?: TableExpandableConfig<T>;
318
+
319
+ // ───── Row actions (revealed on hover) ─────
320
+ rowActions?: (row: T) => ReactNode;
321
+
322
+ // ───── States ─────
323
+ loading?: boolean;
324
+ /** Number of skeleton rows shown while `loading=true`. @default 5 */
325
+ loadingRowCount?: number;
326
+ emptyState?: ReactNode;
327
+
328
+ // ───── A11Y ─────
329
+ /** Sticky header. @default true */
330
+ stickyHeader?: boolean;
331
+ /** Optional caption for screen readers. */
332
+ caption?: ReactNode;
333
+ /** Renders the caption visually too (otherwise sr-only). @default false */
334
+ showCaption?: boolean;
335
+
336
+ // ───── RBAC ─────
337
+ /** Table-level RBAC. `hide` returns null; `disable` greys everything. */
338
+ access?: AccessRequirement;
339
+
340
+ // ───── i18n ─────
341
+ labels?: TableLabels;
342
+
343
+ // ───── Customization escape hatches ─────
344
+ sx?: string;
345
+ slotProps?: TableSlotProps;
346
+ }
@@ -0,0 +1,207 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * `<Table>` variant recipe.
5
+ *
6
+ * Slots:
7
+ * - `root` — outer wrapper
8
+ * - `toolbar` — search + filter chips row above table
9
+ * - `search` — search input
10
+ * - `scroll` — `<div class="overflow-auto">` wrapping the table
11
+ * - `table` — `<table>` element
12
+ * - `thead` — `<thead>` (sticky positioning here)
13
+ * - `tbody` — `<tbody>`
14
+ * - `headerRow` — `<tr>` inside `<thead>`
15
+ * - `headerCell` — `<th>` cell
16
+ * - `headerCellButton` — `<button>` inside sortable `<th>`
17
+ * - `row` — `<tr>` inside `<tbody>` (data row)
18
+ * - `cell` — `<td>` data cell
19
+ * - `selectionCell` — first `<td>` when selection mode active
20
+ * - `expandToggleCell` — second `<td>` when expandable active
21
+ * - `expandedRow` — full-width `<tr>` rendered after an expanded row
22
+ * - `rowActionsCell` — last `<td>` when `rowActions` provided
23
+ * - `emptyState` — placeholder when rows is empty
24
+ * - `loadingOverlay` — skeleton overlay when loading
25
+ * - `bulkActionFooter` — sticky footer with bulk actions
26
+ *
27
+ * Variant axes:
28
+ * - `variant` — visual row-division style:
29
+ * - `plain` — minimal separators
30
+ * - `lines` — Stripe-style thin horizontal dividers (default)
31
+ * - `striped` — alternating row backgrounds (zebra)
32
+ * - `bordered` — full cell borders
33
+ * - `card` — each row rendered as a separate surface card
34
+ * - `size` — `sm` / `md` (default) / `lg`
35
+ * - `density` — `compact` (40px row) / `comfortable` (default 48px) / `spacious` (56px)
36
+ *
37
+ * Smart-default alignment (per column) is applied by the Table
38
+ * component code, NOT here — it depends on the runtime-detected
39
+ * value type, which the recipe doesn't see.
40
+ *
41
+ * **Dashforge identity rule** (memory: `feedback_dashforge_preset_is_identity`):
42
+ * This recipe uses NO `dark:` variants on the neutral palette. The
43
+ * `dashforgePreset()` CSS-variable swap auto-inverts neutral tokens
44
+ * (`bg-neutral-50` is light surface in light mode, dark surface in
45
+ * dark mode — same class, different physical color). Adding a
46
+ * `dark:` variant on neutral palette = DOUBLE inversion = breaks
47
+ * dark mode. The selected-row state uses the canonical LeftNav
48
+ * `itemActive` pattern (`bg-primary-100 text-primary-900 font-medium`),
49
+ * which works in both modes because the primary palette has no
50
+ * inversion (both `bg` and `text` resolve to the same physical
51
+ * colors regardless of mode → guaranteed contrast).
52
+ */
53
+ export const tableVariants = tv({
54
+ slots: {
55
+ root: 'w-full',
56
+ toolbar: 'flex flex-wrap items-center gap-3 mb-3',
57
+ search: [
58
+ 'inline-flex items-center gap-2',
59
+ 'w-full max-w-sm rounded-md',
60
+ 'border border-neutral-300',
61
+ // bg-neutral-50 auto-inverts via CSS var swap (light in light mode,
62
+ // dark in dark mode). Same semantic as TextField wrapper.
63
+ 'bg-neutral-50',
64
+ 'focus-within:ring-2 focus-within:ring-primary-500',
65
+ 'transition-colors motion-reduce:transition-none',
66
+ ],
67
+ scroll: 'relative w-full overflow-auto rounded-md',
68
+ table: 'w-full caption-bottom text-left',
69
+ // thead matches the page surface (auto-inverts). When sticky the
70
+ // header sits opaque against the scroll content beneath it.
71
+ thead: 'bg-neutral-50',
72
+ tbody: '',
73
+ headerRow: '',
74
+ headerCell: [
75
+ // Less prominent than the body — secondary-text intent
76
+ // (auto-inverts: dark grey in light mode, light grey in dark).
77
+ 'text-neutral-700',
78
+ 'font-semibold whitespace-nowrap select-none',
79
+ 'border-b border-neutral-200',
80
+ ],
81
+ headerCellButton: [
82
+ 'group inline-flex items-center gap-1 select-none',
83
+ // hover increases prominence (auto-inverts).
84
+ 'hover:text-neutral-900',
85
+ 'focus:outline-none focus-visible:underline',
86
+ 'transition-colors motion-reduce:transition-none',
87
+ ],
88
+ row: [
89
+ 'group/row transition-colors motion-reduce:transition-none',
90
+ // bg-neutral-100 = "elevated above surface" in both modes via
91
+ // auto-inversion. Slightly visible against the table bg.
92
+ 'hover:bg-neutral-100',
93
+ // Canonical LeftNav `itemActive` pattern — primary palette
94
+ // doesn't invert, so dark-navy text on light-blue bg in BOTH
95
+ // modes. Guaranteed contrast.
96
+ 'data-[selected=true]:bg-primary-100',
97
+ 'data-[selected=true]:text-primary-900',
98
+ 'data-[selected=true]:font-medium',
99
+ ],
100
+ cell: [
101
+ // Auto-inverts — dark text in light mode, light text in dark mode.
102
+ 'text-neutral-900',
103
+ 'border-b border-neutral-200',
104
+ 'align-middle',
105
+ ],
106
+ selectionCell: 'w-10 text-center',
107
+ expandToggleCell: 'w-10 text-center',
108
+ // Expanded panel uses one tier of elevation above the page surface.
109
+ expandedRow: 'bg-neutral-100',
110
+ rowActionsCell: [
111
+ 'w-12 text-right',
112
+ // Row actions hidden until row is hovered or focus-within
113
+ // (Pencil & Paper UX pattern: reveal-on-hover reduces visual density).
114
+ 'opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100',
115
+ 'transition-opacity motion-reduce:transition-none',
116
+ ],
117
+ emptyState: [
118
+ 'py-12 text-center text-neutral-500',
119
+ ],
120
+ loadingOverlay: [
121
+ 'pointer-events-none',
122
+ ],
123
+ bulkActionFooter: [
124
+ 'sticky bottom-0 z-20',
125
+ 'flex flex-wrap items-center justify-between gap-3',
126
+ 'px-3 py-2 mt-0',
127
+ 'border-t border-neutral-200',
128
+ // Surface-elevated bg + shadow ladder.
129
+ 'bg-neutral-50 shadow-md',
130
+ ],
131
+ },
132
+ variants: {
133
+ variant: {
134
+ plain: {},
135
+ lines: {
136
+ // Default — Stripe-style 1px horizontal dividers (already
137
+ // applied via `border-b` on row + cell). No extra style here.
138
+ },
139
+ striped: {
140
+ // bg-neutral-100/60 = subtle elevation tier (auto-inverts).
141
+ row: 'odd:bg-neutral-100/60',
142
+ },
143
+ bordered: {
144
+ table: 'border border-neutral-200',
145
+ cell: 'border-r last:border-r-0 border-neutral-200',
146
+ headerCell: 'border-r last:border-r-0 border-neutral-200',
147
+ },
148
+ card: {
149
+ scroll: 'overflow-visible',
150
+ table: 'border-separate border-spacing-y-2',
151
+ row: [
152
+ 'rounded-lg shadow-sm',
153
+ // Each row becomes an elevated card above the surface.
154
+ 'bg-neutral-50',
155
+ ],
156
+ cell: 'border-b-0 first:rounded-l-lg last:rounded-r-lg',
157
+ headerCell: 'border-b-0',
158
+ },
159
+ },
160
+ size: {
161
+ sm: {
162
+ headerCell: 'text-xs',
163
+ cell: 'text-xs',
164
+ search: 'text-xs',
165
+ },
166
+ md: {
167
+ headerCell: 'text-sm',
168
+ cell: 'text-sm',
169
+ search: 'text-sm',
170
+ },
171
+ lg: {
172
+ headerCell: 'text-base',
173
+ cell: 'text-base',
174
+ search: 'text-base',
175
+ },
176
+ },
177
+ density: {
178
+ // Pencil & Paper UX research: 40px / 48px / 56px row height.
179
+ compact: {
180
+ headerCell: 'px-2 py-1',
181
+ cell: 'px-2 py-1',
182
+ },
183
+ comfortable: {
184
+ headerCell: 'px-3 py-2',
185
+ cell: 'px-3 py-2',
186
+ },
187
+ spacious: {
188
+ headerCell: 'px-4 py-3',
189
+ cell: 'px-4 py-3',
190
+ },
191
+ },
192
+ stickyHeader: {
193
+ true: {
194
+ thead: 'sticky top-0 z-10',
195
+ },
196
+ false: {},
197
+ },
198
+ },
199
+ defaultVariants: {
200
+ variant: 'lines',
201
+ size: 'md',
202
+ density: 'comfortable',
203
+ stickyHeader: true,
204
+ },
205
+ });
206
+
207
+ export type TableVariants = VariantProps<typeof tableVariants>;
@@ -19,21 +19,32 @@ export const tabsVariants = tv({
19
19
  variants: {
20
20
  variant: {
21
21
  underline: {
22
- list: 'border-b border-neutral-200 dark:border-neutral-800',
22
+ // Neutral palette auto-inverts via CSS-var swap.
23
+ list: 'border-b border-neutral-200',
23
24
  trigger: [
24
- 'px-3 py-2 border-b-2 border-transparent text-neutral-600 dark:text-neutral-400',
25
- 'hover:text-neutral-900 dark:hover:text-neutral-50',
25
+ 'px-3 py-2 border-b-2 border-transparent text-neutral-600',
26
+ 'hover:text-neutral-900',
27
+ // Primary palette does NOT auto-invert — `dark:` shift is
28
+ // an intentional tone refinement (kept).
26
29
  'data-[state=active]:border-primary-500 data-[state=active]:text-primary-700',
27
30
  'dark:data-[state=active]:text-primary-400',
28
31
  ],
29
32
  },
30
33
  pill: {
31
- list: 'rounded-lg bg-neutral-100 dark:bg-neutral-800 p-1',
34
+ // Pill list bg uses the elevation tier — auto-inverts.
35
+ list: 'rounded-lg bg-neutral-100 p-1',
32
36
  trigger: [
33
- 'px-3 py-1.5 rounded-md text-neutral-600 dark:text-neutral-400',
34
- 'hover:text-neutral-900 dark:hover:text-neutral-50',
35
- 'data-[state=active]:bg-white dark:data-[state=active]:bg-neutral-900',
36
- 'data-[state=active]:text-neutral-900 dark:data-[state=active]:text-neutral-50',
37
+ 'px-3 py-1.5 rounded-md text-neutral-600',
38
+ 'hover:text-neutral-900',
39
+ // Active pill is one tier above the list bg
40
+ // (`bg-neutral-100`-inverted = #171717 in dark).
41
+ // `bg-white` is hardcoded so a `dark:` is required —
42
+ // target `dark:bg-neutral-200` (= #262626 in dark) sits
43
+ // ONE TIER above the list bg, preserving the elevation
44
+ // semantic from light mode (where `bg-white` sits above
45
+ // `bg-neutral-100` = #f5f5f5).
46
+ 'data-[state=active]:bg-white dark:data-[state=active]:bg-neutral-200',
47
+ 'data-[state=active]:text-neutral-900',
37
48
  'data-[state=active]:shadow-sm',
38
49
  ],
39
50
  },
@@ -4,14 +4,24 @@ export const tooltipVariants = tv({
4
4
  slots: {
5
5
  content: [
6
6
  'z-50 max-w-xs rounded-md px-3 py-1.5 text-xs font-medium',
7
+ // Auto-invert via the dashforgePreset() CSS-var swap:
8
+ // - Light mode: `bg-neutral-900` = #171717 (dark surface) +
9
+ // `text-neutral-50` = #fafafa (light text) — DARK tooltip
10
+ // on the light page (high contrast).
11
+ // - Dark mode: `bg-neutral-900` flips to #f5f5f5 (light) +
12
+ // `text-neutral-50` flips to #0a0a0a (dark text) — LIGHT
13
+ // tooltip on the dark page (high contrast against page).
14
+ // Adding `dark:bg-neutral-50 dark:text-neutral-900` (the old
15
+ // pattern) would force the dark-mode tooltip back to a dark
16
+ // surface — invisible against the dark page.
7
17
  'bg-neutral-900 text-neutral-50',
8
- 'dark:bg-neutral-50 dark:text-neutral-900',
9
18
  'shadow-md',
10
19
  'data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95',
11
20
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
12
21
  'motion-reduce:transition-none motion-reduce:duration-0',
13
22
  ],
14
- arrow: 'fill-neutral-900 dark:fill-neutral-50',
23
+ // Arrow tracks the content bg — auto-inverts the same way.
24
+ arrow: 'fill-neutral-900',
15
25
  },
16
26
  });
17
27
 
@@ -219,8 +219,10 @@ describe('<Typography>', () => {
219
219
  const { container } = render(<Typography color={color}>x</Typography>);
220
220
  const cls = container.firstElementChild?.className ?? '';
221
221
  if (color === 'muted') {
222
+ // Sprint 4.3 identity sweep: neutral palette auto-inverts via
223
+ // CSS-var swap — no `dark:` variant needed.
222
224
  expect(cls).toContain('text-neutral-600');
223
- expect(cls).toContain('dark:text-neutral-400');
225
+ expect(cls).not.toContain('dark:text-neutral-');
224
226
  } else {
225
227
  expect(cls).toContain(`text-${color}-700`);
226
228
  expect(cls).toContain(`dark:text-${color}-400`);
@@ -62,8 +62,12 @@ export const typographyVariants = tv({
62
62
  warning: 'text-warning-700 dark:text-warning-400',
63
63
  danger: 'text-danger-700 dark:text-danger-400',
64
64
  info: 'text-info-700 dark:text-info-400',
65
- neutral: 'text-neutral-900 dark:text-neutral-100',
66
- muted: 'text-neutral-600 dark:text-neutral-400',
65
+ // Neutral palette auto-inverts via the dashforgePreset() CSS-var
66
+ // swap — no `dark:` variants. `text-neutral-900` resolves to
67
+ // dark text in light mode and light text in dark mode; same
68
+ // applies to `text-neutral-600` (muted).
69
+ neutral: 'text-neutral-900',
70
+ muted: 'text-neutral-600',
67
71
  },
68
72
 
69
73
  /*