cursedbelt 5.46.2 → 5.47.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 (145) hide show
  1. package/dist/react/charts/IntervalTimeline.d.ts.map +1 -1
  2. package/dist/react/charts/IntervalTimeline.js +2 -2
  3. package/dist/react/charts/IntervalTimeline.js.map +1 -1
  4. package/dist/react/chat/DateMarker.d.ts +4 -0
  5. package/dist/react/chat/DateMarker.d.ts.map +1 -1
  6. package/dist/react/chat/DateMarker.js +15 -2
  7. package/dist/react/chat/DateMarker.js.map +1 -1
  8. package/dist/react/components/Button.d.ts.map +1 -1
  9. package/dist/react/components/Button.js +5 -0
  10. package/dist/react/components/Button.js.map +1 -1
  11. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  12. package/dist/react/components/DataTable/DataTable.js +1 -1
  13. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  14. package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
  15. package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
  16. package/dist/react/components/DataTable/SortHeader.js +22 -5
  17. package/dist/react/components/DataTable/SortHeader.js.map +1 -1
  18. package/dist/react/components/DataTable/types.d.ts +10 -0
  19. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  20. package/dist/react/components/DataTable/types.js.map +1 -1
  21. package/dist/react/components/DropdownMenu.js +10 -4
  22. package/dist/react/components/DropdownMenu.js.map +1 -1
  23. package/dist/react/components/Field.d.ts +9 -1
  24. package/dist/react/components/Field.d.ts.map +1 -1
  25. package/dist/react/components/Field.js +3 -3
  26. package/dist/react/components/Field.js.map +1 -1
  27. package/dist/react/components/SecretInput.d.ts +4 -2
  28. package/dist/react/components/SecretInput.d.ts.map +1 -1
  29. package/dist/react/components/SecretInput.js +2 -2
  30. package/dist/react/components/SecretInput.js.map +1 -1
  31. package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
  32. package/dist/react/components/auth/AuthCard.js +5 -1
  33. package/dist/react/components/auth/AuthCard.js.map +1 -1
  34. package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
  35. package/dist/react/components/auth/PasswordSetFields.js +2 -2
  36. package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
  37. package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  38. package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
  39. package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
  40. package/dist/react/components/auth/RegisterForm.js +1 -1
  41. package/dist/react/components/auth/RegisterForm.js.map +1 -1
  42. package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
  43. package/dist/react/components/auth/SignupForm.js +4 -2
  44. package/dist/react/components/auth/SignupForm.js.map +1 -1
  45. package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
  46. package/dist/react/components/auth/TotpEntry.js +18 -1
  47. package/dist/react/components/auth/TotpEntry.js.map +1 -1
  48. package/dist/react/components/auth/fields.d.ts +3 -1
  49. package/dist/react/components/auth/fields.d.ts.map +1 -1
  50. package/dist/react/components/auth/fields.js +6 -2
  51. package/dist/react/components/auth/fields.js.map +1 -1
  52. package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
  53. package/dist/react/components/calendar/DayAgenda.js +4 -2
  54. package/dist/react/components/calendar/DayAgenda.js.map +1 -1
  55. package/dist/react/components/calendar/EventChip.d.ts +5 -1
  56. package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
  57. package/dist/react/components/calendar/EventChip.js +10 -3
  58. package/dist/react/components/calendar/EventChip.js.map +1 -1
  59. package/dist/react/components/calendar/MonthGrid.js +1 -1
  60. package/dist/react/components/calendar/MonthGrid.js.map +1 -1
  61. package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
  62. package/dist/react/components/calendar/WeekView.js +2 -2
  63. package/dist/react/components/calendar/WeekView.js.map +1 -1
  64. package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
  65. package/dist/react/components/calendar/colorUtils.js +13 -11
  66. package/dist/react/components/calendar/colorUtils.js.map +1 -1
  67. package/dist/react/components/fieldErrorText.d.ts +12 -0
  68. package/dist/react/components/fieldErrorText.d.ts.map +1 -0
  69. package/dist/react/components/fieldErrorText.js +12 -0
  70. package/dist/react/components/fieldErrorText.js.map +1 -0
  71. package/dist/react/deep-link/resume.d.ts.map +1 -1
  72. package/dist/react/deep-link/resume.js +9 -2
  73. package/dist/react/deep-link/resume.js.map +1 -1
  74. package/dist/react/file-tree/FileTree.d.ts +3 -0
  75. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  76. package/dist/react/file-tree/FileTree.js +12 -1
  77. package/dist/react/file-tree/FileTree.js.map +1 -1
  78. package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
  79. package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
  80. package/dist/react/file-tree/FolderViewMenus.js +51 -15
  81. package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
  82. package/dist/react/file-tree/folderView.d.ts +25 -4
  83. package/dist/react/file-tree/folderView.d.ts.map +1 -1
  84. package/dist/react/file-tree/folderView.js +49 -9
  85. package/dist/react/file-tree/folderView.js.map +1 -1
  86. package/dist/react/file-tree/index.d.ts +2 -1
  87. package/dist/react/file-tree/index.d.ts.map +1 -1
  88. package/dist/react/file-tree/index.js +2 -1
  89. package/dist/react/file-tree/index.js.map +1 -1
  90. package/dist/react/file-tree/useFolderView.d.ts +34 -4
  91. package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
  92. package/dist/react/file-tree/useFolderView.js +43 -11
  93. package/dist/react/file-tree/useFolderView.js.map +1 -1
  94. package/dist/styles-areas/analytics.css +26 -25
  95. package/dist/styles-areas/calendar.css +4 -4
  96. package/dist/styles-areas/data-table.css +26 -25
  97. package/dist/styles-utilities.css +24 -9
  98. package/package.json +5 -1
  99. package/scripts/demoCoverage.spec.ts +33 -0
  100. package/scripts/demoCoverage.ts +124 -0
  101. package/src/react/charts/IntervalTimeline.tsx +6 -4
  102. package/src/react/chat/DateMarker.tsx +22 -6
  103. package/src/react/chat/MessageThread.spec.tsx +25 -1
  104. package/src/react/components/Button.spec.tsx +11 -0
  105. package/src/react/components/Button.tsx +5 -0
  106. package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
  107. package/src/react/components/DataTable/DataTable.tsx +1 -0
  108. package/src/react/components/DataTable/SortHeader.tsx +45 -5
  109. package/src/react/components/DataTable/types.ts +10 -0
  110. package/src/react/components/DropdownMenu.spec.tsx +26 -0
  111. package/src/react/components/DropdownMenu.tsx +10 -3
  112. package/src/react/components/Field.spec.tsx +24 -0
  113. package/src/react/components/Field.tsx +15 -5
  114. package/src/react/components/SecretInput.tsx +5 -1
  115. package/src/react/components/auth/AuthCard.spec.tsx +8 -0
  116. package/src/react/components/auth/AuthCard.tsx +4 -1
  117. package/src/react/components/auth/PasswordSetFields.tsx +7 -11
  118. package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
  119. package/src/react/components/auth/RegisterForm.tsx +6 -6
  120. package/src/react/components/auth/SignupForm.spec.tsx +17 -0
  121. package/src/react/components/auth/SignupForm.tsx +11 -10
  122. package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
  123. package/src/react/components/auth/TotpEntry.tsx +18 -1
  124. package/src/react/components/auth/fields.tsx +8 -1
  125. package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
  126. package/src/react/components/calendar/DayAgenda.tsx +5 -2
  127. package/src/react/components/calendar/EventChip.spec.tsx +12 -8
  128. package/src/react/components/calendar/EventChip.tsx +11 -6
  129. package/src/react/components/calendar/MonthGrid.tsx +1 -1
  130. package/src/react/components/calendar/WeekView.tsx +5 -3
  131. package/src/react/components/calendar/colorUtils.ts +13 -11
  132. package/src/react/components/fieldErrorText.tsx +17 -0
  133. package/src/react/deep-link/resume.spec.tsx +16 -0
  134. package/src/react/deep-link/resume.ts +9 -2
  135. package/src/react/file-tree/FileTree.tsx +14 -1
  136. package/src/react/file-tree/FolderViewMenus.tsx +57 -17
  137. package/src/react/file-tree/folderView.spec.ts +63 -1
  138. package/src/react/file-tree/folderView.ts +63 -12
  139. package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
  140. package/src/react/file-tree/index.ts +15 -1
  141. package/src/react/file-tree/useFolderView.ts +73 -12
  142. package/src/styles-areas/analytics.css +26 -25
  143. package/src/styles-areas/calendar.css +4 -4
  144. package/src/styles-areas/data-table.css +26 -25
  145. package/src/styles-utilities.css +24 -9
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `bun run demo:coverage [--prune]` — every exported component has a demo, or it does not ship.
3
+ *
4
+ * ## The trap this exists for
5
+ *
6
+ * Forge task 2466 (2026-10-02) set out to review every exported component against the demo, "the
7
+ * visual contract", and its first step was the list. Generated from the export map rather than
8
+ * remembered, it said **242 of 383** exported components appeared in no demo page at all —
9
+ * `Tabs`, `TopBar`, `Menu`, the whole auth kit, chat, calendar — so no review, matrix or design
10
+ * audit had ever drawn them, and nothing stopped the 384th arriving the same way.
11
+ *
12
+ * ## What counts
13
+ *
14
+ * A COMPONENT is a capitalised value export of a `./react*` subpath (`package.json` `exports`,
15
+ * through its `source` file) whose type is a React component: an exotic component (`memo`,
16
+ * `forwardRef`, `lazy`) or a function returning an element. A component is DEMOED when a file under
17
+ * `demo/` (not `demo/dist/`) names it outside its imports and comments — a demo that imports a
18
+ * component and never renders it has not shown it.
19
+ *
20
+ * `demoCoverage.baseline` lists the ones that had no demo when this was written. It is a CEILING:
21
+ * a component missing from the demo and from the baseline fails; a baseline entry that now HAS a
22
+ * demo (or is no longer exported) fails too, so the list only shrinks — `--prune` removes those,
23
+ * and refuses to add anything.
24
+ */
25
+ import { readFileSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import * as ts from "typescript";
28
+
29
+ const ROOT = join(import.meta.dir, "..");
30
+ export const BASELINE = join(ROOT, "demoCoverage.baseline");
31
+
32
+ /** Every component name → the subpaths that export it. */
33
+ export function exportedComponents(root: string = ROOT): Map<string, string[]> {
34
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { exports: Record<string, unknown> };
35
+ const entries: [string, string][] = [];
36
+ for (const [subpath, target] of Object.entries(pkg.exports)) {
37
+ const source = typeof target === "object" && target !== null ? (target as { source?: unknown }).source : undefined;
38
+ if (subpath.startsWith("./react") && typeof source === "string") entries.push([subpath, join(root, source)]);
39
+ }
40
+ const program = ts.createProgram(
41
+ entries.map(([, file]) => file),
42
+ {
43
+ jsx: ts.JsxEmit.ReactJSX,
44
+ module: ts.ModuleKind.ESNext,
45
+ target: ts.ScriptTarget.ES2022,
46
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
47
+ allowImportingTsExtensions: true,
48
+ noEmit: true,
49
+ skipLibCheck: true,
50
+ },
51
+ );
52
+ const checker = program.getTypeChecker();
53
+ const found = new Map<string, string[]>();
54
+ for (const [subpath, file] of entries) {
55
+ const source = program.getSourceFile(file);
56
+ const module = source && checker.getSymbolAtLocation(source);
57
+ if (!module) continue;
58
+ for (const exported of checker.getExportsOfModule(module)) {
59
+ const name = exported.getName();
60
+ if (!/^[A-Z][a-z0-9]/.test(name)) continue;
61
+ const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
62
+ const declaration = symbol.valueDeclaration;
63
+ if (!(symbol.flags & ts.SymbolFlags.Value) || !declaration) continue;
64
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
65
+ const component =
66
+ /ExoticComponent|FC<|FunctionComponent/.test(checker.typeToString(type)) ||
67
+ type.getCallSignatures().some((s) => /Element|ReactNode|ReactPortal/.test(checker.typeToString(s.getReturnType())));
68
+ if (!component) continue;
69
+ found.set(name, [...(found.get(name) ?? []), subpath]);
70
+ }
71
+ }
72
+ return found;
73
+ }
74
+
75
+ /** Every demo source with its imports and comments taken out, as one text. */
76
+ export function demoText(root: string = ROOT): string {
77
+ const files = [...new Bun.Glob("demo/**/*.{ts,tsx}").scanSync(root)].filter((f) => !f.startsWith("demo/dist/"));
78
+ return files
79
+ .map((f) =>
80
+ readFileSync(join(root, f), "utf8")
81
+ .replace(/\/\*[\s\S]*?\*\//g, "")
82
+ .replace(/(^|[^:])\/\/.*$/gm, "$1")
83
+ .replace(/^\s*import[\s\S]*?from\s+["'][^"']+["'];?/gm, ""),
84
+ )
85
+ .join("\n");
86
+ }
87
+
88
+ /** The components no demo names, sorted. */
89
+ export function undemoed(components: ReadonlyMap<string, string[]>, text: string): string[] {
90
+ return [...components.keys()].filter((name) => !new RegExp(`\\b${name}\\b`).test(text)).sort();
91
+ }
92
+
93
+ export function readBaseline(file: string = BASELINE): string[] {
94
+ return readFileSync(file, "utf8")
95
+ .split("\n")
96
+ .map((l) => l.trim())
97
+ .filter((l) => l && !l.startsWith("#"));
98
+ }
99
+
100
+ /** What the check says: the new gaps (a fault), and the entries that have outlived their gap (also a fault). */
101
+ export function compare(missing: readonly string[], baseline: readonly string[]): { added: string[]; stale: string[] } {
102
+ const now = new Set(missing);
103
+ const allowed = new Set(baseline);
104
+ return { added: missing.filter((n) => !allowed.has(n)), stale: baseline.filter((n) => !now.has(n)) };
105
+ }
106
+
107
+ if (import.meta.main) {
108
+ const components = exportedComponents();
109
+ const missing = undemoed(components, demoText());
110
+ const baseline = readBaseline();
111
+ const { added, stale } = compare(missing, baseline);
112
+ const prune = process.argv.includes("--prune");
113
+ if (prune && stale.length > 0) {
114
+ const text = readFileSync(BASELINE, "utf8");
115
+ const gone = new Set(stale);
116
+ writeFileSync(BASELINE, text.split("\n").filter((l) => !gone.has(l.trim())).join("\n"));
117
+ console.log(`pruned ${stale.length}: ${stale.join(", ")}`);
118
+ }
119
+ for (const name of added) console.error(`✗ ${name} (${components.get(name)?.join(", ")}) has no demo — add it to a demo/sections page before it ships.`);
120
+ if (!prune) for (const name of stale) console.error(`✗ ${name} is in demoCoverage.baseline but no longer needs to be — \`bun run demo:coverage --prune\`.`);
121
+ const faults = added.length + (prune ? 0 : stale.length);
122
+ console.log(`${components.size} exported components · ${components.size - missing.length} demoed · ${missing.length} without a demo (baseline ${baseline.length - (prune ? stale.length : 0)})`);
123
+ process.exit(faults > 0 ? 1 : 0);
124
+ }
@@ -243,22 +243,24 @@ export function IntervalTimeline({
243
243
  </div>
244
244
  )}
245
245
 
246
- <div className='flex justify-between text-caption text-muted-foreground tabular-nums'>
246
+ {/* The two ends of the axis never wrap ("02:30 / PM" at 390px), and the overflow note takes
247
+ its own full-width row instead of squeezing between them (forge task 2591). */}
248
+ <div className='flex flex-wrap justify-between gap-x-3 text-caption text-muted-foreground tabular-nums'>
247
249
  {/* Bands and markers are drawn `aria-hidden` and carry no `title` — a native
248
250
  tooltip does not exist on touch and cannot be themed (DESIGN.md §4). They
249
251
  are NAMED here instead, in text every reader gets. */}
250
- <span>
252
+ <span className='whitespace-nowrap'>
251
253
  {formatTime(view.from)}
252
254
  {bands.length > 0 && ' · shaded = paused'}
253
255
  {markers.filter((m) => m.label).length > 0 &&
254
256
  ` · ${markers.filter((m) => m.label).map((m) => m.label).join(', ')} marked`}
255
257
  </span>
258
+ <span className='whitespace-nowrap'>{formatTime(view.to)}</span>
256
259
  {lanes.overflow > 0 && (
257
- <span className='text-warning'>
260
+ <span className='w-full text-warning'>
258
261
  +{lanes.overflow} more not drawn — the chart stacks {maxLanes} lanes
259
262
  </span>
260
263
  )}
261
- <span>{formatTime(view.to)}</span>
262
264
  </div>
263
265
  </div>
264
266
  );
@@ -17,6 +17,16 @@ function formatMarkerDate(date: Date, now = new Date()): string {
17
17
  return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
18
18
  }
19
19
 
20
+ /**
21
+ * The LOCAL calendar day as `YYYY-MM-DD` — the same day the label names. `toISOString()` is the
22
+ * UTC day, so a message at 19:00 in Chicago was labelled "Wednesday, July 1" and stamped
23
+ * `2026-07-02` (forge task 2591).
24
+ */
25
+ function localDayKey(date: Date): string {
26
+ const pad = (n: number) => String(n).padStart(2, '0');
27
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
28
+ }
29
+
20
30
  // ── Component ─────────────────────────────────────────────────────────────────
21
31
 
22
32
  export interface DateMarkerProps {
@@ -27,23 +37,29 @@ export interface DateMarkerProps {
27
37
  /**
28
38
  * Centered date-separator row inserted between messages from different calendar
29
39
  * days. Renders "Today", "Yesterday", or a full date ("Monday, July 1").
40
+ *
41
+ * A `separator` with the date as its name, not `aria-hidden`: in a transcript the day change is
42
+ * the only place a screen reader learns WHEN the messages below were sent (Slack and iMessage
43
+ * both announce it).
30
44
  */
31
45
  export function DateMarker({ date, className }: DateMarkerProps) {
32
46
  const d = date instanceof Date ? date : new Date(date);
33
47
  const label = formatMarkerDate(d);
34
- const iso = d.toISOString().slice(0, 10);
48
+ const day = localDayKey(d);
35
49
 
36
50
  return (
51
+ // biome-ignore lint/a11y/useSemanticElements: an <hr> cannot hold the date it announces; a named separator role on the row is the labelled-divider pattern
37
52
  <div
38
- data-chat-date={iso}
53
+ data-chat-date={day}
54
+ role='separator'
55
+ aria-label={label}
39
56
  className={cn('flex items-center gap-3 px-4 py-2', className)}
40
- aria-hidden='true'
41
57
  >
42
- <span className='h-px flex-1 bg-border' />
43
- <time dateTime={iso} className='text-xs font-medium text-muted-foreground'>
58
+ <span aria-hidden className='h-px flex-1 bg-border' />
59
+ <time dateTime={day} className='text-xs font-medium text-muted-foreground'>
44
60
  {label}
45
61
  </time>
46
- <span className='h-px flex-1 bg-border' />
62
+ <span aria-hidden className='h-px flex-1 bg-border' />
47
63
  </div>
48
64
  );
49
65
  }
@@ -122,9 +122,33 @@ describe('DateMarker', () => {
122
122
  });
123
123
 
124
124
  test('has data-chat-date attribute', () => {
125
- const { container } = render(<DateMarker date={new Date('2026-07-02T00:00:00Z')} />);
125
+ const { container } = render(<DateMarker date={new Date(2026, 6, 2, 12)} />);
126
126
  expect(container.querySelector('[data-chat-date="2026-07-02"]')).toBeTruthy();
127
127
  });
128
+
129
+ test('stamps the LOCAL day the label names, at either end of it — not the UTC day', () => {
130
+ // `bun test` runs in UTC, where the local and UTC days agree and the bug cannot show — so the
131
+ // zone is set here: west of UTC the late message crosses into tomorrow's UTC day, east of it
132
+ // the early one falls back into yesterday's.
133
+ const zone = process.env.TZ;
134
+ try {
135
+ for (const [tz, hour] of [['America/Chicago', 23], ['Asia/Tokyo', 0]] as const) {
136
+ process.env.TZ = tz;
137
+ const { container, unmount } = render(<DateMarker date={new Date(2026, 6, 2, hour, 30)} />);
138
+ const marker = container.querySelector('[data-chat-date]');
139
+ expect(marker?.getAttribute('data-chat-date')).toBe('2026-07-02');
140
+ expect(marker?.querySelector('time')?.getAttribute('dateTime')).toBe('2026-07-02');
141
+ unmount();
142
+ }
143
+ } finally {
144
+ process.env.TZ = zone;
145
+ }
146
+ });
147
+
148
+ test('is a named separator a screen reader announces, not hidden from it', () => {
149
+ render(<DateMarker date={new Date()} />);
150
+ expect(screen.getByRole('separator', { name: 'Today' })).toBeTruthy();
151
+ });
128
152
  });
129
153
 
130
154
  // ── StatusMarker ──────────────────────────────────────────────────────────────
@@ -34,6 +34,17 @@ describe('Button', () => {
34
34
  expect(cls).toContain('h-[var(--control-h-md)]'); // md, the shared control-height token
35
35
  });
36
36
 
37
+ test('a link is unpadded text at every size, so it lines up with the text around it (task 2466)', () => {
38
+ for (const size of [undefined, 'sm', 'md', 'lg'] as const) {
39
+ const { getByRole, unmount } = render(<Button tooltip='L' variant='link' size={size}>L</Button>);
40
+ const cls = getByRole('button').className.split(/\s+/);
41
+ expect(cls).toContain('px-0');
42
+ expect(cls).toContain('h-auto');
43
+ expect(cls.some((c) => /^px-[1-9]/.test(c) || c.startsWith('h-[var(--control-h'))).toBe(false);
44
+ unmount();
45
+ }
46
+ });
47
+
37
48
  test('primary uses the saturated solid accent fill; destructive is quiet at rest', () => {
38
49
  const { getByRole, rerender } = render(<Button tooltip='P' variant='primary'>P</Button>);
39
50
  expect(getByRole('button').className).toContain('bg-accent-solid');
@@ -119,6 +119,11 @@ export const buttonVariants = cva(
119
119
  'icon-lg': 'h-[var(--control-h-lg)] w-[var(--control-h-lg)] p-0 pointer-coarse:min-w-11',
120
120
  },
121
121
  },
122
+ // 🔴 A link is a TEXT link at every size (forge task 2466). `link`'s own `px-0 h-auto` came
123
+ // before the size's `px-3`/`px-4` and fixed height, so the size won the merge and every link
124
+ // button — sized or on the `md` default — drew a padded box: TotpEntry's "Use a recovery code
125
+ // instead" sat 12px in from the column every other row starts on.
126
+ compoundVariants: [{ variant: 'link', size: ['sm', 'md', 'lg'], class: 'h-auto px-0 py-0' }],
122
127
  defaultVariants: { variant: 'secondary', size: 'md' },
123
128
  },
124
129
  );
@@ -568,3 +568,46 @@ describe('hidePagination shows every row', () => {
568
568
  expect(bodyRows(container).length).toBe(50);
569
569
  });
570
570
  });
571
+
572
+ /**
573
+ * 🔴 Forge task 2577: apps/music's Length header read "LE…" at 390px. `headerIcon` paints a glyph
574
+ * instead of the label below a TABLE width, and the label stays the header's accessible name.
575
+ */
576
+ describe('ColDef.headerIcon', () => {
577
+ const CLOCK = <svg data-testid='clock' />;
578
+ const WITH_ICON: ColDef<Person>[] = [
579
+ { key: 'name', label: 'Name', type: 'text', sortable: true },
580
+ { key: 'age', label: 'Length', type: 'number', sortable: true, headerIcon: CLOCK },
581
+ { key: 'nick', label: 'Plain', type: 'text', headerIcon: CLOCK },
582
+ ];
583
+
584
+ test('the glyph replaces the label below the table step; the label stays for assistive tech', () => {
585
+ const { container } = render(<DataTable columns={WITH_ICON} data={PEOPLE} />);
586
+ const heads = [...container.querySelectorAll('thead th')];
587
+ const length = heads.find((th) => th.textContent?.includes('Length')) as HTMLElement;
588
+ const label = length.querySelector('[data-cb-header-label]') as HTMLElement;
589
+ const icon = length.querySelector('[data-cb-header-icon]') as HTMLElement;
590
+ expect(label.className).toContain('@max-2xl:sr-only');
591
+ expect(icon.className).toContain('hidden @max-2xl:inline-flex');
592
+ expect(icon.getAttribute('aria-hidden')).not.toBeNull();
593
+ expect(icon.querySelector('[data-testid="clock"]')).not.toBeNull();
594
+ // A sortable header still sorts by its label's name, and stays a 44px target without it.
595
+ expect(length.querySelector('button')?.textContent).toContain('Length');
596
+ expect(length.querySelector('button')?.className).toContain('@max-2xl:min-w-11');
597
+
598
+ const plain = heads.find((th) => th.textContent?.includes('Plain')) as HTMLElement;
599
+ // A plain (unsortable) header swaps the same way.
600
+ expect(plain.querySelector('[data-cb-header-label]')?.className).toContain('@max-2xl:sr-only');
601
+ });
602
+
603
+ test('a column without one renders exactly as before', () => {
604
+ const { container } = render(<DataTable columns={COLUMNS} data={PEOPLE} />);
605
+ expect(container.querySelector('[data-cb-header-icon]')).toBeNull();
606
+ expect(container.querySelector('[data-cb-header-label]')).toBeNull();
607
+ });
608
+
609
+ test('the utility build carries every class the swap uses', async () => {
610
+ const css = await Bun.file(new URL('../../../styles-utilities.css', import.meta.url)).text();
611
+ for (const cls of ['sr-only', 'inline-flex', 'min-w-11', 'justify-center']) expect(css).toContain(`.\\@max-2xl\\:${cls}`);
612
+ });
613
+ });
@@ -1171,6 +1171,7 @@ export function DataTable<TRow extends Record<string, unknown>>({
1171
1171
  <SortHeader
1172
1172
  column={column}
1173
1173
  label={col.label}
1174
+ icon={col.headerIcon}
1174
1175
  sortable={col.sortable}
1175
1176
  sortIndex={stack.length > 1 ? stack.findIndex((s) => s.id === col.key) : -1}
1176
1177
  onSort={() => toggleSortKey(col.key)}
@@ -23,9 +23,36 @@ const withLabelTip = (label: string, children: ReactNode) => (
23
23
  </Tooltip>
24
24
  );
25
25
 
26
+ /**
27
+ * `ColDef.headerIcon`'s swap below the table's 42rem step (`@max-2xl`; the table is the
28
+ * `@container`). The label goes `sr-only`, not `hidden`, so the header keeps its name for
29
+ * assistive tech at every width. One step only: each step costs every consuming app its CSS.
30
+ */
31
+ const COMPACT_LABEL = '@max-2xl:sr-only';
32
+ const COMPACT_ICON = 'hidden @max-2xl:inline-flex';
33
+ /** A glyph and an arrow are ~30px; the label was what made the button wide enough to tap. */
34
+ const COMPACT_BUTTON = ' @max-2xl:min-w-11 @max-2xl:justify-center';
35
+
36
+ /** The label, and — with an icon — the glyph that replaces it on a narrow table. */
37
+ function HeaderLabel({ label, icon }: { label: string; icon?: ReactNode }) {
38
+ if (icon == null) return <span className='min-w-0 truncate'>{label}</span>;
39
+ return (
40
+ <>
41
+ <span data-cb-header-label className={`min-w-0 truncate ${COMPACT_LABEL}`}>
42
+ {label}
43
+ </span>
44
+ <span data-cb-header-icon aria-hidden className={`shrink-0 items-center [&>svg]:size-3.5 ${COMPACT_ICON}`}>
45
+ {icon}
46
+ </span>
47
+ </>
48
+ );
49
+ }
50
+
26
51
  export interface SortHeaderProps<TRow> {
27
52
  column: Column<TRow, unknown>;
28
53
  label: string;
54
+ /** `ColDef.headerIcon` — drawn instead of the label when the table is narrower than 42rem. */
55
+ icon?: ReactNode;
29
56
  sortable?: boolean;
30
57
  /**
31
58
  * This column's position in the sort STACK, or `-1` when it is not in one (or
@@ -58,6 +85,7 @@ export interface SortHeaderProps<TRow> {
58
85
  export function SortHeader<TRow>({
59
86
  column,
60
87
  label,
88
+ icon,
61
89
  sortable,
62
90
  sortIndex = -1,
63
91
  onSort,
@@ -69,8 +97,20 @@ export function SortHeader<TRow>({
69
97
  // that overflows a `<th>` paints on top of the NEXT header rather than
70
98
  // clipping, which reads as a rendering fault ("Sask"/"ACTEMPY" in orch's
71
99
  // fleet table, 2026-08-11). `title` keeps the full label one hover away.
72
- if (!sortable) return withLabelTip(label, <span className='block truncate'>{label}</span>);
100
+ if (!sortable)
101
+ return withLabelTip(
102
+ label,
103
+ icon == null ? (
104
+ <span className='block truncate'>{label}</span>
105
+ ) : (
106
+ <span className='flex min-w-0 items-center'>
107
+ <HeaderLabel label={label} icon={icon} />
108
+ </span>
109
+ ),
110
+ );
73
111
  const sorted = column.getIsSorted();
112
+ // The design law's LAY-13 floor is 44px, and music's audit caught the bare glyph at 390 (task 2577).
113
+ const compact = icon == null ? '' : COMPACT_BUTTON;
74
114
  if (disabledReason)
75
115
  // Visible and explained, never hidden — and `aria-disabled` rather than `disabled`, because
76
116
  // a disabled button fires no pointer events and the reason could never be hovered.
@@ -81,9 +121,9 @@ export function SortHeader<TRow>({
81
121
  type='button'
82
122
  aria-disabled
83
123
  aria-label={`${label} — ${disabledReason}`}
84
- className={`-mx-1 -my-2.5 inline-flex max-w-full cursor-not-allowed items-center gap-1 rounded px-1 py-2.5 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`}
124
+ className={`-mx-1 -my-2.5 inline-flex max-w-full cursor-not-allowed items-center gap-1 rounded px-1 py-2.5 uppercase focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}${compact}`}
85
125
  >
86
- <span className='min-w-0 truncate'>{label}</span>
126
+ <HeaderLabel label={label} icon={icon} />
87
127
  </button>,
88
128
  );
89
129
  return withLabelTip(
@@ -104,11 +144,11 @@ export function SortHeader<TRow>({
104
144
  // computed styles on orch's Fleet tab, 2026-08-12: `th` uppercase,
105
145
  // `button` none. Letter-spacing does inherit (0.55px on both), so only
106
146
  // the case needs restating.
107
- className={`-mx-1 -my-2.5 inline-flex max-w-full items-center gap-1 rounded px-1 py-2.5 uppercase transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`}
147
+ className={`-mx-1 -my-2.5 inline-flex max-w-full items-center gap-1 rounded px-1 py-2.5 uppercase transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}${compact}`}
108
148
  >
109
149
  {/* min-w-0 so the label is what gives way in a narrow track — the sort
110
150
  arrow is the affordance and must survive at any width. */}
111
- <span className='min-w-0 truncate'>{label}</span>
151
+ <HeaderLabel label={label} icon={icon} />
112
152
  {sorted === 'asc' ? (
113
153
  <ArrowUp className='size-3 shrink-0' aria-hidden />
114
154
  ) : sorted === 'desc' ? (
@@ -90,6 +90,16 @@ export interface ColDef<TRow = Record<string, unknown>> {
90
90
  * Applies to the grid; the card tiers already choose their own fields.
91
91
  */
92
92
  hideBelow?: 'sm' | 'md' | 'lg';
93
+ /**
94
+ * A glyph the header shows INSTEAD of its label when the table (its container) is narrower
95
+ * than 42rem — {@link hideBelow}'s `'sm'` step — Spotify's clock over the duration column. `label` stays the
96
+ * accessible name, the hover tip and the Columns-menu name; only the painted text gives way.
97
+ *
98
+ * 🔴 Why (forge task 2577): apps/music's Length column read "LE…" at 390px. "LENGTH" and its
99
+ * sort arrow need ~90px, and on a phone those pixels belong to the title, which is already
100
+ * truncating real titles. A truncated word is a header nobody can read; a glyph is one.
101
+ */
102
+ headerIcon?: ReactNode;
93
103
  /**
94
104
  * Drop order when the table is too NARROW for its declared tracks — HIGHER numbers go first.
95
105
  * Columns without one never drop. The table measures its own width and collapses the
@@ -91,4 +91,30 @@ describe('DropdownMenu — a list of verbs in groups, destructive last (OVL-7)',
91
91
  const items = Array.from(open.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent);
92
92
  expect(items.at(-1)).toBe('Delete');
93
93
  });
94
+
95
+ test('once one row can be checked, every row keeps its column — icons line up', () => {
96
+ // The player's ⋯ (forge task 2568): Keep offline and Tag are toggles, Share and Delete are
97
+ // not, and the toggles' check column pushed their icons 24px right of the others'.
98
+ render(
99
+ <DropdownMenu
100
+ items={[
101
+ { key: 'share', label: 'Share', icon: <span>s</span> },
102
+ { key: 'keep', label: 'Keep offline', icon: <span>k</span>, checked: false },
103
+ { key: 'del', label: 'Delete', icon: <span>d</span>, destructive: true },
104
+ ]}
105
+ trigger={<B>actions</B>}
106
+ />,
107
+ );
108
+ fireEvent.pointerDown(screen.getByText('actions'), { button: 0, ctrlKey: false });
109
+ const rows = Array.from((menu() as HTMLElement).querySelectorAll('[role^="menuitem"]')) as HTMLElement[];
110
+ expect(rows.map((row) => row.getAttribute('role'))).toEqual(['menuitem', 'menuitemcheckbox', 'menuitem']);
111
+ for (const row of rows) expect(row.className.split(/\s+/)).toContain('pl-8');
112
+ });
113
+
114
+ test('a menu with no row to check keeps no empty check column', () => {
115
+ render(<DropdownMenu items={[{ key: 'share', label: 'Share' }]} trigger={<B>actions</B>} />);
116
+ fireEvent.pointerDown(screen.getByText('actions'), { button: 0, ctrlKey: false });
117
+ const row = (menu() as HTMLElement).querySelector('[role="menuitem"]') as HTMLElement;
118
+ expect(row.className.split(/\s+/)).not.toContain('pl-8');
119
+ });
94
120
  });
@@ -155,11 +155,17 @@ export function DropdownMenu({
155
155
 
156
156
  function MenuSections({ items }: { items: readonly DropdownMenuItem[] }) {
157
157
  const sections = useMemo(() => arrangeMenuItems(items), [items]);
158
+ /*
159
+ * One row that can be checked gives every row of this menu the check column, as a native menu
160
+ * does: otherwise the toggles' icons sit 24px right of their neighbours' (the player's ⋯, forge
161
+ * task 2568). A submenu is its own menu and decides for itself.
162
+ */
163
+ const inset = items.some((item) => !item.divider && item.checked !== undefined);
158
164
  return sections.map((section, index) => (
159
165
  <Fragment key={section[0]?.key ?? index}>
160
166
  {index > 0 && <RDrop.Separator className='my-1 h-px bg-border' />}
161
167
  {section.map((item) => (
162
- <DropdownItem key={item.key} item={item} />
168
+ <DropdownItem key={item.key} item={item} inset={inset} />
163
169
  ))}
164
170
  </Fragment>
165
171
  ));
@@ -195,7 +201,7 @@ function ItemTrailing({ item }: { item: DropdownMenuAction }) {
195
201
  );
196
202
  }
197
203
 
198
- function DropdownItem({ item }: { item: DropdownMenuItem }) {
204
+ function DropdownItem({ item, inset }: { item: DropdownMenuItem; inset: boolean }) {
199
205
  const container = useFloatingContainer();
200
206
  if (item.divider) {
201
207
  return <RDrop.Separator className='my-1 h-px bg-border' />;
@@ -204,7 +210,7 @@ function DropdownItem({ item }: { item: DropdownMenuItem }) {
204
210
  if (item.children?.length) {
205
211
  return (
206
212
  <RDrop.Sub>
207
- <RDrop.SubTrigger className={cn(FLOATING_ITEM_BASE, 'justify-between')}>
213
+ <RDrop.SubTrigger className={cn(FLOATING_ITEM_BASE, 'justify-between', inset && 'pl-8')}>
208
214
  <span className='flex items-center gap-2'>
209
215
  {item.icon && (
210
216
  <span className='inline-flex size-4 items-center justify-center' aria-hidden>
@@ -262,6 +268,7 @@ function DropdownItem({ item }: { item: DropdownMenuItem }) {
262
268
  title={item.title ?? item.disabledReason}
263
269
  className={cn(
264
270
  FLOATING_ITEM_BASE,
271
+ inset && 'pl-8',
265
272
  item.destructive && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
266
273
  )}
267
274
  >
@@ -200,6 +200,30 @@ describe('Field', () => {
200
200
  expect(container.querySelector('div')?.childElementCount).toBe(before as number);
201
201
  });
202
202
 
203
+ test('a `status` (the strength meter) holds the reserved row directly under the control, and gives it to an error (task 2466)', () => {
204
+ const { container } = render(
205
+ <Harness>
206
+ <Field name='email' label='Email' reserveErrorSpace status={<span>Strong</span>}>
207
+ <input
208
+ // guardrails-ignore no-raw-inputs: test fixture, not product UI
209
+ />
210
+ </Field>
211
+ </Harness>,
212
+ );
213
+ const slot = () => container.querySelector('[data-cb-field-status]');
214
+ // In the reserved row's place — not under an empty one.
215
+ expect(container.querySelector('[data-cc-field-error-reserved]')).toBeNull();
216
+ expect(slot()?.textContent).toBe('Strong');
217
+ const prev = slot()?.previousElementSibling;
218
+ expect(prev?.tagName === 'INPUT' || prev?.querySelector('input') != null).toBe(true);
219
+ const before = container.querySelector('div')?.childElementCount;
220
+
221
+ setError('email', 'Required');
222
+ expect(slot()).toBeNull();
223
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain('Required');
224
+ expect(container.querySelector('div')?.childElementCount).toBe(before as number);
225
+ });
226
+
203
227
  test('is OFF by default — turning it on globally is a density change to every form', () => {
204
228
  const { container } = render(
205
229
  <Harness>
@@ -1,7 +1,7 @@
1
- import { AlertCircle } from 'lucide-react';
2
1
  import { cloneElement, isValidElement, type ReactElement, type ReactNode, useId } from 'react';
3
2
  import { get, useFormContext } from 'react-hook-form';
4
3
  import { cn } from '../lib/cn.js';
4
+ import { FieldErrorText } from './fieldErrorText.js';
5
5
  import {
6
6
  DESCRIPTION_FOLD_CHARS,
7
7
  FIELD_WIDTH_CLASS,
@@ -64,6 +64,14 @@ interface FieldBaseProps {
64
64
  * reachability gate caught exactly that: "Create an account" did nothing.
65
65
  */
66
66
  reserveErrorSpace?: boolean;
67
+ /**
68
+ * Live feedback on the VALUE — a password-strength meter, a character count — in the error's
69
+ * slot under the control: shown while there is no error, giving the slot to the error while
70
+ * one shows (FRM-10), and never folded into the ⓘ the way help is. With `reserveErrorSpace`
71
+ * it holds the reserved line itself, so it must fit one line of `text-sm`. (Forge task 2466:
72
+ * the meter used to sit BELOW the reserved empty line, 34px from the field it measures.)
73
+ */
74
+ status?: ReactNode;
67
75
  /**
68
76
  * How wide the CONTROL may grow — a cap, never a fixed width (see {@link FieldWidth}). The
69
77
  * label, description and error keep the row's full width.
@@ -140,6 +148,7 @@ export function Field({
140
148
  children,
141
149
  className,
142
150
  reserveErrorSpace = false,
151
+ status,
143
152
  width,
144
153
  descriptionOnNarrow = 'auto',
145
154
  layout = 'stacked',
@@ -293,10 +302,11 @@ export function Field({
293
302
  ) : null}
294
303
 
295
304
  {error ? (
296
- <p id={errId} role='alert' className='m-0 flex items-center gap-1.5 text-sm text-field-error' data-cb-field-error=''>
297
- <AlertCircle className='size-4 shrink-0' aria-hidden />
298
- {error}
299
- </p>
305
+ <FieldErrorText id={errId}>{error}</FieldErrorText>
306
+ ) : status != null ? (
307
+ <div className='min-h-[1lh] text-sm' data-cb-field-status=''>
308
+ {status}
309
+ </div>
300
310
  ) : reserveErrorSpace && (description == null || foldsAlways) ? (
301
311
  // `aria-hidden` + no `role`: this is LAYOUT, and a screen reader must not
302
312
  // meet an empty alert region. The height is one line of the same `text-sm`
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
1
+ import { type CSSProperties, type FocusEvent, type KeyboardEvent, type ReactNode, useState } from 'react';
2
2
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
3
3
  import { CONTROL_HEIGHT_CLASS, type ControlSize, declareFieldWidth } from '../lib/field.js';
4
4
  import { resolveClass, resolveStyle, type StyleableProps } from '../styling.js';
@@ -123,6 +123,8 @@ export interface SecretInputProps extends StyleableProps<SecretInputSlot> {
123
123
  disabled?: boolean;
124
124
  /** Fires on Enter in THIS field — the login/rotation forms submit from their last field. */
125
125
  onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
126
+ /** Fires when the INPUT loses focus — a form library's touched/validate-on-blur hook. */
127
+ onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
126
128
  className?: string;
127
129
  style?: CSSProperties;
128
130
  /** Container height on the shared control-height scale (`--control-h-*`, task
@@ -156,6 +158,7 @@ export const SecretInput = ({
156
158
  autoFocus = false,
157
159
  disabled,
158
160
  onEnter,
161
+ onBlur,
159
162
  className,
160
163
  style,
161
164
  classNames,
@@ -206,6 +209,7 @@ export const SecretInput = ({
206
209
  value={value}
207
210
  readOnly={readOnly}
208
211
  onChange={onChange ? (e) => onChange(e.target.value) : undefined}
212
+ onBlur={onBlur}
209
213
  onKeyDown={onEnter ? (e) => {
210
214
  if (e.key === 'Enter') onEnter(e);
211
215
  } : undefined}
@@ -23,6 +23,14 @@ describe('AuthCard', () => {
23
23
  expect(getByText('Footer')).not.toBeNull();
24
24
  });
25
25
 
26
+ test('embedded, it paints no page background of its own — the host’s surface shows through (task 2466)', () => {
27
+ const page = render(<AuthCard>x</AuthCard>);
28
+ expect((page.container.firstElementChild as HTMLElement).className).toContain('bg-background');
29
+ page.unmount();
30
+ const embedded = render(<AuthCard fullScreen={false}>x</AuthCard>);
31
+ expect((embedded.container.firstElementChild as HTMLElement).className).not.toContain('bg-background');
32
+ });
33
+
26
34
  test('omits the header block entirely when no title/subtitle/logo', () => {
27
35
  const { container } = render(
28
36
  <AuthCard>