chati-dev 4.3.0 → 4.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.
Files changed (156) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +32 -4
  3. package/framework/agents/build/dev.md +9 -5
  4. package/framework/agents/discover/brief.md +2 -0
  5. package/framework/agents/discover/brownfield-wu.md +2 -0
  6. package/framework/agents/discover/greenfield-wu.md +18 -1
  7. package/framework/agents/plan/detail.md +2 -0
  8. package/framework/agents/plan/tasks.md +31 -0
  9. package/framework/agents/plan/ux-brand-architect.md +21 -2
  10. package/framework/agents/plan/ux-component-engineer.md +10 -0
  11. package/framework/agents/quality/qa-implementation.md +1 -1
  12. package/framework/agents/quality/qa-planning.md +1 -1
  13. package/framework/agents/quality/qa-visual.md +33 -21
  14. package/framework/config.yaml +5 -4
  15. package/framework/constitution.md +58 -11
  16. package/framework/context/governance.md +12 -10
  17. package/framework/context/root.md +2 -2
  18. package/framework/data/entity-registry.yaml +12 -4
  19. package/framework/data/qa-rubrics.yaml +335 -0
  20. package/framework/domains/constitution.yaml +3 -3
  21. package/framework/executors/json-validate.js +80 -0
  22. package/framework/executors/npm-script.js +72 -0
  23. package/framework/executors/path-check.js +67 -0
  24. package/framework/executors/registry.yaml +36 -0
  25. package/framework/hooks/advance-trigger.js +47 -3
  26. package/framework/hooks/constitution-guard.js +9 -4
  27. package/framework/hooks/git-push-authority.js +113 -0
  28. package/framework/hooks/license-guard.js +59 -9
  29. package/framework/hooks/model-governance.js +40 -17
  30. package/framework/hooks/prism-engine.js +78 -11
  31. package/framework/hooks/session-digest.js +12 -3
  32. package/framework/hooks/settings.json +7 -3
  33. package/framework/hooks/style-guard.js +52 -3
  34. package/framework/hooks/team-quality-gate.js +43 -5
  35. package/framework/i18n/en.yaml +3 -3
  36. package/framework/i18n/es.yaml +3 -3
  37. package/framework/i18n/fr.yaml +3 -3
  38. package/framework/i18n/pt.yaml +3 -3
  39. package/framework/intelligence/confidence.yaml +85 -6
  40. package/framework/intelligence/context-engine.md +9 -5
  41. package/framework/intelligence/decision-engine.md +1 -1
  42. package/framework/orchestrator/chati-router.js +59 -5
  43. package/framework/orchestrator/chati-update.md +19 -3
  44. package/framework/orchestrator/chati.md +20 -1
  45. package/framework/quality-gates/planning-gate.md +2 -0
  46. package/framework/rules/human-writing-style.md +47 -0
  47. package/framework/scaffold/motion-premium/README.md +12 -2
  48. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
  49. package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
  50. package/framework/scaffold/motion-premium-3d/README.md +10 -0
  51. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  52. package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
  53. package/framework/scaffold/saas-dashboard/README.md +58 -0
  54. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  55. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  56. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  57. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  58. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  59. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  60. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  61. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  62. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  63. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  64. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  65. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  66. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  67. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  68. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  69. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  70. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  71. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  72. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  73. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  74. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  75. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  76. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  77. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  78. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  79. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  80. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  81. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  82. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  83. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  84. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  85. package/framework/schemas/session.schema.json +5 -0
  86. package/framework/schemas/task.schema.json +9 -0
  87. package/framework/scripts/visual-qa.js +101 -3
  88. package/framework/tasks/orchestrator-health.md +4 -4
  89. package/framework/tasks/qa-impl-verdict.md +11 -5
  90. package/framework/templates/brandbook-html-tmpl.md +1 -1
  91. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  92. package/package.json +4 -3
  93. package/src/config/context-file-generator.js +0 -6
  94. package/src/dashboard/renderer.js +0 -36
  95. package/src/executors/runner.js +204 -0
  96. package/src/installer/core.js +13 -3
  97. package/src/installer/templates.js +5 -6
  98. package/src/intelligence/registry-manager.js +1 -1
  99. package/src/license/client.js +9 -3
  100. package/src/license/commands.js +12 -2
  101. package/src/license/machine-id.js +42 -1
  102. package/src/memory/gotchas.js +58 -40
  103. package/src/memory/magic-docs.js +1 -1
  104. package/src/memory/session-digest.js +9 -4
  105. package/src/orchestrator/cli.js +186 -15
  106. package/src/orchestrator/doctor.js +98 -16
  107. package/src/orchestrator/pipeline-manager.js +44 -22
  108. package/src/orchestrator/session-manager.js +64 -8
  109. package/src/telemetry/sender.js +7 -19
  110. package/src/terminal/run-team.js +3 -3
  111. package/src/upgrade/tracked-files-detector.js +34 -16
  112. package/src/utils/feature-flags.js +1 -1
  113. package/src/utils/flatten-entities.js +4 -40
  114. package/src/utils/schema-validator.js +0 -14
  115. package/src/wizard/i18n.js +3 -3
  116. package/src/api/index.js +0 -120
  117. package/src/autonomy/autonomous-gate.js +0 -294
  118. package/src/autonomy/build-loop.js +0 -281
  119. package/src/autonomy/build-state.js +0 -286
  120. package/src/autonomy/cause-analyzer.js +0 -177
  121. package/src/autonomy/escalation.js +0 -214
  122. package/src/autonomy/index.js +0 -51
  123. package/src/autonomy/mode-manager.js +0 -225
  124. package/src/autonomy/mode-suggester.js +0 -283
  125. package/src/autonomy/progress-reporter.js +0 -275
  126. package/src/autonomy/safety-net.js +0 -370
  127. package/src/config/agent-customizer.js +0 -231
  128. package/src/decision/analyzer.js +0 -291
  129. package/src/decision/engine.js +0 -250
  130. package/src/decision/index.js +0 -38
  131. package/src/decision/registry-healer.js +0 -468
  132. package/src/decision/registry-updater.js +0 -339
  133. package/src/extensions/loader.js +0 -145
  134. package/src/extensions/registry.js +0 -134
  135. package/src/gates/circuit-breaker.js +0 -151
  136. package/src/gates/g1-planning-complete.js +0 -154
  137. package/src/gates/g2-qa-planning.js +0 -156
  138. package/src/gates/g3-implementation.js +0 -215
  139. package/src/gates/g4-qa-implementation.js +0 -240
  140. package/src/gates/g5-deploy-ready.js +0 -181
  141. package/src/gates/gate-base.js +0 -185
  142. package/src/gates/index.js +0 -46
  143. package/src/health/auto-fix.js +0 -216
  144. package/src/health/engine.js +0 -246
  145. package/src/merger/semantic-merger.js +0 -292
  146. package/src/preview/detector.js +0 -238
  147. package/src/preview/index.js +0 -20
  148. package/src/preview/launcher.js +0 -235
  149. package/src/preview/log-buffer.js +0 -103
  150. package/src/quality/metrics-collector.js +0 -281
  151. package/src/quality/test-runner.js +0 -366
  152. package/src/tasks/executor.js +0 -195
  153. package/src/tasks/index.js +0 -4
  154. package/src/tasks/loader.js +0 -210
  155. package/src/tasks/router.js +0 -182
  156. package/src/utils/event-bus.js +0 -126
@@ -0,0 +1,623 @@
1
+ /**
2
+ * components/data/DataTable.tsx
3
+ * ============================================================
4
+ * WHY THIS COMPONENT EXISTS
5
+ * -------------------------
6
+ * SaaS dashboards are fundamentally about tabular data. A reusable,
7
+ * premium data table component eliminates the most common source of
8
+ * quality inconsistency in dashboard UIs: each feature team
9
+ * hand-rolling its own table with slightly different row heights,
10
+ * hover states, loading patterns, and accessibility implementations.
11
+ *
12
+ * ARCHITECTURE
13
+ * ------------
14
+ * DataTable<T> is a generic React component where T is the row
15
+ * datum type. This eliminates `any` casts in cell renderers and
16
+ * gives callers full type inference on the `onRowClick` callback.
17
+ *
18
+ * The component is intentionally server-friendly: it receives
19
+ * already-sorted, already-paginated data via props. Sort state
20
+ * changes call `onSort` so the parent can decide whether to sort
21
+ * client-side or re-fetch. This matches the most common dashboard
22
+ * pattern (server-side pagination + sort) without forcing it.
23
+ *
24
+ * ACCESSIBILITY APPROACH
25
+ * ----------------------
26
+ * - <table> with a mandatory `caption` prop (not aria-label) so
27
+ * screen readers announce the table purpose before reading cells.
28
+ * - <th scope="col"> on all column headers (required for WCAG 1.3.1).
29
+ * - Sort buttons are real <button> elements with aria-sort on the
30
+ * <th>, not just styled divs.
31
+ * - Selection checkboxes have visually hidden labels via aria-label.
32
+ * - Row click targets are keyboard accessible via tabIndex + Enter/Space.
33
+ * - Loading skeleton rows use aria-hidden to prevent screen readers
34
+ * from announcing placeholder content.
35
+ *
36
+ * MOTION DESIGN
37
+ * -------------
38
+ * Row hover: background-color transition, duration-fast (120ms),
39
+ * ease-out. No transform on rows (layout-shift risk with variable
40
+ * row height and sticky header).
41
+ * Sort icon swap: opacity + transform (translateY 4px), duration-fast.
42
+ * Skeleton shimmer: translateX sweep on a pseudo-element, 1.4s
43
+ * ease-in-out infinite. Collapses to static bg on reduced-motion.
44
+ *
45
+ * SPACING DECISIONS
46
+ * -----------------
47
+ * Row height: min-height 44px (--space-11) via vertical cell padding
48
+ * of 12px (--space-3) top+bottom on a 14px font = ~40px + 4px border.
49
+ * Meets WCAG 2.5.5 target size guideline for interactive rows.
50
+ * Header: 40px (--space-10) via 10px v-padding, slightly denser than
51
+ * data rows to establish visual hierarchy without extra markup.
52
+ * ============================================================
53
+ */
54
+
55
+ 'use client';
56
+
57
+ import React, { useState, useCallback, useId } from 'react';
58
+ import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
59
+ import type { ColumnDef, SortState, SortDirection, PaginationState } from './DataTable.types';
60
+ import { DataTablePagination } from './DataTablePagination';
61
+ import { SkeletonBlock } from './SkeletonBlock';
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Types
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface DataTableProps<T> {
68
+ /**
69
+ * Column definitions. Each column controls header label, cell renderer,
70
+ * sort capability, and optional fixed width.
71
+ */
72
+ columns: ColumnDef<T>[];
73
+
74
+ /** Row data. The component is display-only; sorting/paging is external. */
75
+ data: T[];
76
+
77
+ /**
78
+ * Required for screen reader announcement. Describes the table content
79
+ * (e.g., "Monthly revenue by customer").
80
+ * NOTE: visually hidden by default (className="sr-only"). Pass
81
+ * captionVisible to show it above the table.
82
+ */
83
+ caption: string;
84
+
85
+ /** When true, renders the caption as a visible heading above the table. */
86
+ captionVisible?: boolean;
87
+
88
+ /** When true, shows 6 skeleton rows instead of data rows. */
89
+ loading?: boolean;
90
+
91
+ /**
92
+ * Rendered when data is empty and loading is false.
93
+ * Defaults to a minimal "No results" message.
94
+ */
95
+ emptyState?: React.ReactNode;
96
+
97
+ /**
98
+ * Called when a sortable column header is clicked.
99
+ * The parent is responsible for sorting data and re-rendering.
100
+ */
101
+ onSort?: (sort: SortState) => void;
102
+
103
+ /**
104
+ * Current sort state. Controls the active sort indicator.
105
+ * When omitted, no sort indicator is shown.
106
+ */
107
+ sortState?: SortState;
108
+
109
+ /**
110
+ * Called when a data row is clicked. When provided, rows receive
111
+ * cursor-pointer and are keyboard-focusable (tabIndex=0).
112
+ */
113
+ onRowClick?: (row: T) => void;
114
+
115
+ /**
116
+ * When true, the header row is sticky (position: sticky, top: 0).
117
+ * Requires the table's scroll container to have overflow-y: auto.
118
+ * The AppShell main content area provides this automatically.
119
+ */
120
+ stickyHeader?: boolean;
121
+
122
+ /** Pagination props. When omitted, no pagination row is rendered. */
123
+ pagination?: PaginationState & {
124
+ onPageChange: (page: number) => void;
125
+ onPageSizeChange: (size: number) => void;
126
+ };
127
+
128
+ /**
129
+ * Row selection. When provided, adds a checkbox column as the first column.
130
+ * selectedKeys is a Set of string row keys.
131
+ */
132
+ selection?: {
133
+ selectedKeys: Set<string>;
134
+ getRowKey: (row: T) => string;
135
+ onSelectionChange: (keys: Set<string>) => void;
136
+ };
137
+
138
+ /** Additional className applied to the <div> wrapper. */
139
+ className?: string;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Skeleton row count for loading state
144
+ // ---------------------------------------------------------------------------
145
+ const SKELETON_ROWS = 6;
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Sort icon helper
149
+ // ---------------------------------------------------------------------------
150
+ function SortIcon({
151
+ columnKey,
152
+ sortState,
153
+ }: {
154
+ columnKey: string;
155
+ sortState?: SortState;
156
+ }) {
157
+ const isActive = sortState?.key === columnKey;
158
+ const direction: SortDirection = isActive ? sortState!.direction : 'none';
159
+
160
+ return (
161
+ <span
162
+ aria-hidden="true"
163
+ style={{
164
+ display: 'inline-flex',
165
+ alignItems: 'center',
166
+ marginLeft: 'var(--space-1)',
167
+ opacity: isActive ? 1 : 0.35,
168
+ transition: `opacity var(--duration-fast) var(--ease-out),
169
+ transform var(--duration-fast) var(--ease-out)`,
170
+ transform: isActive && direction === 'desc' ? 'translateY(1px)' : 'translateY(0)',
171
+ }}
172
+ >
173
+ {direction === 'asc' ? (
174
+ <ChevronUp size={13} />
175
+ ) : direction === 'desc' ? (
176
+ <ChevronDown size={13} />
177
+ ) : (
178
+ <ChevronsUpDown size={13} />
179
+ )}
180
+ </span>
181
+ );
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Derive next sort direction in the 3-state cycle
186
+ // ---------------------------------------------------------------------------
187
+ function nextDirection(current: SortDirection): SortDirection {
188
+ if (current === 'none') return 'asc';
189
+ if (current === 'asc') return 'desc';
190
+ return 'none';
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Main component
195
+ // ---------------------------------------------------------------------------
196
+ export function DataTable<T>(props: DataTableProps<T>) {
197
+ const {
198
+ columns,
199
+ data,
200
+ caption,
201
+ captionVisible = false,
202
+ loading = false,
203
+ emptyState,
204
+ onSort,
205
+ sortState,
206
+ onRowClick,
207
+ stickyHeader = false,
208
+ pagination,
209
+ selection,
210
+ className,
211
+ } = props;
212
+
213
+ const tableId = useId();
214
+ const captionId = `${tableId}-caption`;
215
+
216
+ // ------------------------------------------------------------------
217
+ // Row selection logic
218
+ // ------------------------------------------------------------------
219
+ const allKeys = selection
220
+ ? data.map((row) => selection.getRowKey(row))
221
+ : [];
222
+ const allSelected =
223
+ selection != null &&
224
+ allKeys.length > 0 &&
225
+ allKeys.every((k) => selection.selectedKeys.has(k));
226
+ const someSelected =
227
+ selection != null &&
228
+ allKeys.some((k) => selection.selectedKeys.has(k));
229
+
230
+ const handleSelectAll = useCallback(() => {
231
+ if (!selection) return;
232
+ if (allSelected) {
233
+ selection.onSelectionChange(new Set());
234
+ } else {
235
+ selection.onSelectionChange(new Set(allKeys));
236
+ }
237
+ }, [selection, allKeys, allSelected]);
238
+
239
+ const handleSelectRow = useCallback(
240
+ (key: string) => {
241
+ if (!selection) return;
242
+ const next = new Set(selection.selectedKeys);
243
+ if (next.has(key)) {
244
+ next.delete(key);
245
+ } else {
246
+ next.add(key);
247
+ }
248
+ selection.onSelectionChange(next);
249
+ },
250
+ [selection],
251
+ );
252
+
253
+ // ------------------------------------------------------------------
254
+ // Sort handler
255
+ // ------------------------------------------------------------------
256
+ const handleSort = useCallback(
257
+ (columnKey: string) => {
258
+ if (!onSort) return;
259
+ const currentDirection: SortDirection =
260
+ sortState?.key === columnKey ? sortState.direction : 'none';
261
+ onSort({ key: columnKey, direction: nextDirection(currentDirection) });
262
+ },
263
+ [onSort, sortState],
264
+ );
265
+
266
+ // ------------------------------------------------------------------
267
+ // Derived aria-sort value
268
+ // ------------------------------------------------------------------
269
+ function ariaSortFor(columnKey: string): React.AriaAttributes['aria-sort'] {
270
+ if (!sortState || sortState.key !== columnKey) return 'none';
271
+ if (sortState.direction === 'asc') return 'ascending';
272
+ if (sortState.direction === 'desc') return 'descending';
273
+ return 'none';
274
+ }
275
+
276
+ // ------------------------------------------------------------------
277
+ // Total columns count (including checkbox column)
278
+ // ------------------------------------------------------------------
279
+ const totalCols = columns.length + (selection ? 1 : 0);
280
+
281
+ // ------------------------------------------------------------------
282
+ // Styles (inline CSS custom properties -- no raw values)
283
+ // ------------------------------------------------------------------
284
+ const headerThStyle: React.CSSProperties = {
285
+ position: stickyHeader ? 'sticky' : undefined,
286
+ top: stickyHeader ? 0 : undefined,
287
+ zIndex: stickyHeader ? 1 : undefined,
288
+ backgroundColor: 'var(--color-bg-overlay)',
289
+ color: 'var(--color-text-secondary)',
290
+ fontSize: 'var(--type-caption)',
291
+ fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
292
+ letterSpacing: '0.04em',
293
+ textTransform: 'uppercase',
294
+ padding: 'var(--space-2) var(--space-4)',
295
+ borderBottom: '1px solid var(--color-border-default)',
296
+ whiteSpace: 'nowrap',
297
+ userSelect: 'none',
298
+ };
299
+
300
+ return (
301
+ <div
302
+ className={className}
303
+ style={{
304
+ borderRadius: 'var(--radius-default)',
305
+ border: '1px solid var(--color-border-default)',
306
+ overflow: 'hidden',
307
+ backgroundColor: 'var(--color-bg-raised)',
308
+ }}
309
+ >
310
+ {/* Table scroll container -- required for sticky header to work */}
311
+ <div style={{ overflowX: 'auto', overflowY: 'auto' }}>
312
+ <table
313
+ aria-labelledby={captionId}
314
+ style={{
315
+ width: '100%',
316
+ borderCollapse: 'collapse',
317
+ tableLayout: 'fixed',
318
+ fontFamily: 'var(--font-sans)',
319
+ fontSize: 'var(--type-small)',
320
+ color: 'var(--color-text-primary)',
321
+ }}
322
+ >
323
+ {/* Caption: always present for SR; optionally visible */}
324
+ <caption
325
+ id={captionId}
326
+ style={{
327
+ captionSide: 'top',
328
+ padding: captionVisible ? 'var(--space-4) var(--space-6)' : undefined,
329
+ fontSize: captionVisible ? 'var(--type-h4)' : undefined,
330
+ fontWeight: captionVisible ? 'var(--font-weight-semibold)' : undefined,
331
+ color: captionVisible ? 'var(--color-text-primary)' : undefined,
332
+ textAlign: captionVisible ? 'left' : undefined,
333
+ // visually hidden when captionVisible=false
334
+ ...(captionVisible
335
+ ? {}
336
+ : {
337
+ position: 'absolute',
338
+ width: '1px',
339
+ height: '1px',
340
+ padding: 0,
341
+ margin: '-1px',
342
+ overflow: 'hidden',
343
+ clip: 'rect(0,0,0,0)',
344
+ whiteSpace: 'nowrap',
345
+ border: 0,
346
+ }),
347
+ }}
348
+ >
349
+ {caption}
350
+ </caption>
351
+
352
+ {/* Column width hints */}
353
+ <colgroup>
354
+ {selection && (
355
+ <col style={{ width: '40px' }} />
356
+ )}
357
+ {columns.map((col) => (
358
+ <col
359
+ key={col.key}
360
+ style={{
361
+ width:
362
+ col.width != null
363
+ ? typeof col.width === 'number'
364
+ ? `${col.width}px`
365
+ : col.width
366
+ : undefined,
367
+ }}
368
+ />
369
+ ))}
370
+ </colgroup>
371
+
372
+ {/* Header */}
373
+ <thead>
374
+ <tr>
375
+ {/* Select-all checkbox */}
376
+ {selection && (
377
+ <th
378
+ scope="col"
379
+ style={{ ...headerThStyle, textAlign: 'center', padding: 'var(--space-2)' }}
380
+ >
381
+ <input
382
+ type="checkbox"
383
+ aria-label="Select all rows"
384
+ checked={allSelected}
385
+ ref={(el) => {
386
+ if (el) el.indeterminate = someSelected && !allSelected;
387
+ }}
388
+ onChange={handleSelectAll}
389
+ style={checkboxStyle}
390
+ />
391
+ </th>
392
+ )}
393
+
394
+ {columns.map((col) => (
395
+ <th
396
+ key={col.key}
397
+ scope="col"
398
+ aria-sort={col.sortable ? ariaSortFor(col.key) : undefined}
399
+ style={{
400
+ ...headerThStyle,
401
+ textAlign: col.align ?? 'left',
402
+ }}
403
+ >
404
+ {col.sortable ? (
405
+ <button
406
+ type="button"
407
+ onClick={() => handleSort(col.key)}
408
+ style={sortButtonStyle}
409
+ aria-label={`Sort by ${col.header}${
410
+ sortState?.key === col.key
411
+ ? `, currently ${sortState.direction}ending`
412
+ : ''
413
+ }`}
414
+ >
415
+ {col.header}
416
+ {col.srDescription && (
417
+ <span
418
+ style={{
419
+ position: 'absolute',
420
+ width: '1px',
421
+ height: '1px',
422
+ overflow: 'hidden',
423
+ clip: 'rect(0,0,0,0)',
424
+ }}
425
+ >
426
+ {col.srDescription}
427
+ </span>
428
+ )}
429
+ <SortIcon columnKey={col.key} sortState={sortState} />
430
+ </button>
431
+ ) : (
432
+ <>
433
+ {col.header}
434
+ {col.srDescription && (
435
+ <span
436
+ style={{
437
+ position: 'absolute',
438
+ width: '1px',
439
+ height: '1px',
440
+ overflow: 'hidden',
441
+ clip: 'rect(0,0,0,0)',
442
+ }}
443
+ >
444
+ {col.srDescription}
445
+ </span>
446
+ )}
447
+ </>
448
+ )}
449
+ </th>
450
+ ))}
451
+ </tr>
452
+ </thead>
453
+
454
+ {/* Body */}
455
+ <tbody>
456
+ {/* Loading skeleton */}
457
+ {loading &&
458
+ Array.from({ length: SKELETON_ROWS }).map((_, rowIdx) => (
459
+ <tr key={`skel-${rowIdx}`} aria-hidden="true">
460
+ {selection && (
461
+ <td style={cellStyle('left')}>
462
+ <SkeletonBlock width={16} height={16} borderRadius="var(--radius-sm)" />
463
+ </td>
464
+ )}
465
+ {columns.map((col, colIdx) => (
466
+ <td key={col.key} style={cellStyle(col.align ?? 'left')}>
467
+ <SkeletonBlock
468
+ height={14}
469
+ width={colIdx === 0 ? '70%' : colIdx % 3 === 0 ? '50%' : '80%'}
470
+ borderRadius="var(--radius-sharp)"
471
+ />
472
+ </td>
473
+ ))}
474
+ </tr>
475
+ ))}
476
+
477
+ {/* Empty state */}
478
+ {!loading && data.length === 0 && (
479
+ <tr>
480
+ <td
481
+ colSpan={totalCols}
482
+ style={{
483
+ padding: 'var(--space-16) var(--space-6)',
484
+ textAlign: 'center',
485
+ color: 'var(--color-text-secondary)',
486
+ }}
487
+ >
488
+ {emptyState ?? (
489
+ <span style={{ fontSize: 'var(--type-body)' }}>No results found.</span>
490
+ )}
491
+ </td>
492
+ </tr>
493
+ )}
494
+
495
+ {/* Data rows */}
496
+ {!loading &&
497
+ data.map((row, rowIdx) => {
498
+ const rowKey = selection?.getRowKey(row) ?? String(rowIdx);
499
+ const isSelected = selection?.selectedKeys.has(rowKey) ?? false;
500
+ const isInteractive = !!onRowClick;
501
+
502
+ return (
503
+ <tr
504
+ key={rowKey}
505
+ className="data-table-row"
506
+ onClick={isInteractive ? () => onRowClick!(row) : undefined}
507
+ onKeyDown={
508
+ isInteractive
509
+ ? (e) => {
510
+ if (e.key === 'Enter' || e.key === ' ') {
511
+ e.preventDefault();
512
+ onRowClick!(row);
513
+ }
514
+ }
515
+ : undefined
516
+ }
517
+ tabIndex={isInteractive ? 0 : undefined}
518
+ role={isInteractive ? 'button' : undefined}
519
+ aria-selected={selection ? isSelected : undefined}
520
+ style={rowStyle(isSelected, isInteractive)}
521
+ >
522
+ {/* Selection checkbox */}
523
+ {selection && (
524
+ <td
525
+ style={{ ...cellStyle('center'), padding: 'var(--space-2)' }}
526
+ onClick={(e) => {
527
+ e.stopPropagation();
528
+ handleSelectRow(rowKey);
529
+ }}
530
+ >
531
+ <input
532
+ type="checkbox"
533
+ aria-label={`Select row ${rowIdx + 1}`}
534
+ checked={isSelected}
535
+ onChange={() => handleSelectRow(rowKey)}
536
+ style={checkboxStyle}
537
+ />
538
+ </td>
539
+ )}
540
+
541
+ {/* Data cells */}
542
+ {columns.map((col) => {
543
+ const content = col.render
544
+ ? col.render(row)
545
+ : String((row as Record<string, unknown>)[col.key] ?? '');
546
+ return (
547
+ <td key={col.key} style={cellStyle(col.align ?? 'left')}>
548
+ {content}
549
+ </td>
550
+ );
551
+ })}
552
+ </tr>
553
+ );
554
+ })}
555
+ </tbody>
556
+ </table>
557
+ </div>
558
+
559
+ {/* Pagination */}
560
+ {pagination && !loading && (
561
+ <DataTablePagination
562
+ page={pagination.page}
563
+ pageSize={pagination.pageSize}
564
+ total={pagination.total}
565
+ onPageChange={pagination.onPageChange}
566
+ onPageSizeChange={pagination.onPageSizeChange}
567
+ />
568
+ )}
569
+ </div>
570
+ );
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // Style helpers (all values reference CSS custom properties)
575
+ // ---------------------------------------------------------------------------
576
+
577
+ function cellStyle(align: 'left' | 'center' | 'right'): React.CSSProperties {
578
+ return {
579
+ padding: 'var(--space-3) var(--space-4)',
580
+ borderBottom: '1px solid var(--color-border-default)',
581
+ textAlign: align,
582
+ verticalAlign: 'middle',
583
+ lineHeight: 'var(--type-body-lh)',
584
+ color: 'var(--color-text-primary)',
585
+ };
586
+ }
587
+
588
+ function rowStyle(isSelected: boolean, isInteractive: boolean): React.CSSProperties {
589
+ return {
590
+ backgroundColor: isSelected
591
+ ? 'var(--color-bg-highlight)'
592
+ : 'transparent',
593
+ cursor: isInteractive ? 'pointer' : 'default',
594
+ transition: `background-color var(--duration-fast) var(--ease-out)`,
595
+ outline: 'none',
596
+ // Hover is handled via CSS class in a companion stylesheet or via
597
+ // onMouseEnter/Leave state. Using inline style for the base state
598
+ // keeps the component self-contained. A global .data-table-row:hover
599
+ // rule in globals.css handles the hover affordance.
600
+ };
601
+ }
602
+
603
+ const sortButtonStyle: React.CSSProperties = {
604
+ background: 'none',
605
+ border: 'none',
606
+ padding: 0,
607
+ cursor: 'pointer',
608
+ color: 'inherit',
609
+ font: 'inherit',
610
+ letterSpacing: 'inherit',
611
+ textTransform: 'inherit',
612
+ display: 'inline-flex',
613
+ alignItems: 'center',
614
+ gap: 0,
615
+ width: '100%',
616
+ };
617
+
618
+ const checkboxStyle: React.CSSProperties = {
619
+ width: '16px',
620
+ height: '16px',
621
+ accentColor: 'var(--color-accent)',
622
+ cursor: 'pointer',
623
+ };