cursedbelt 5.46.0 → 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 (169) 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/ListToolbar.d.ts +5 -0
  28. package/dist/react/components/ListToolbar.d.ts.map +1 -1
  29. package/dist/react/components/ListToolbar.js +14 -2
  30. package/dist/react/components/ListToolbar.js.map +1 -1
  31. package/dist/react/components/SecretInput.d.ts +4 -2
  32. package/dist/react/components/SecretInput.d.ts.map +1 -1
  33. package/dist/react/components/SecretInput.js +2 -2
  34. package/dist/react/components/SecretInput.js.map +1 -1
  35. package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
  36. package/dist/react/components/auth/AuthCard.js +5 -1
  37. package/dist/react/components/auth/AuthCard.js.map +1 -1
  38. package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
  39. package/dist/react/components/auth/PasswordSetFields.js +2 -2
  40. package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
  41. package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  42. package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
  43. package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
  44. package/dist/react/components/auth/RegisterForm.js +1 -1
  45. package/dist/react/components/auth/RegisterForm.js.map +1 -1
  46. package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
  47. package/dist/react/components/auth/SignupForm.js +4 -2
  48. package/dist/react/components/auth/SignupForm.js.map +1 -1
  49. package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
  50. package/dist/react/components/auth/TotpEntry.js +18 -1
  51. package/dist/react/components/auth/TotpEntry.js.map +1 -1
  52. package/dist/react/components/auth/fields.d.ts +3 -1
  53. package/dist/react/components/auth/fields.d.ts.map +1 -1
  54. package/dist/react/components/auth/fields.js +6 -2
  55. package/dist/react/components/auth/fields.js.map +1 -1
  56. package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
  57. package/dist/react/components/calendar/DayAgenda.js +4 -2
  58. package/dist/react/components/calendar/DayAgenda.js.map +1 -1
  59. package/dist/react/components/calendar/EventChip.d.ts +5 -1
  60. package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
  61. package/dist/react/components/calendar/EventChip.js +10 -3
  62. package/dist/react/components/calendar/EventChip.js.map +1 -1
  63. package/dist/react/components/calendar/MonthGrid.js +1 -1
  64. package/dist/react/components/calendar/MonthGrid.js.map +1 -1
  65. package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
  66. package/dist/react/components/calendar/WeekView.js +2 -2
  67. package/dist/react/components/calendar/WeekView.js.map +1 -1
  68. package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
  69. package/dist/react/components/calendar/colorUtils.js +13 -11
  70. package/dist/react/components/calendar/colorUtils.js.map +1 -1
  71. package/dist/react/components/fieldErrorText.d.ts +12 -0
  72. package/dist/react/components/fieldErrorText.d.ts.map +1 -0
  73. package/dist/react/components/fieldErrorText.js +12 -0
  74. package/dist/react/components/fieldErrorText.js.map +1 -0
  75. package/dist/react/deep-link/resume.d.ts.map +1 -1
  76. package/dist/react/deep-link/resume.js +9 -2
  77. package/dist/react/deep-link/resume.js.map +1 -1
  78. package/dist/react/file-tree/FileTree.d.ts +3 -0
  79. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  80. package/dist/react/file-tree/FileTree.js +12 -1
  81. package/dist/react/file-tree/FileTree.js.map +1 -1
  82. package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
  83. package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
  84. package/dist/react/file-tree/FolderViewMenus.js +51 -15
  85. package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
  86. package/dist/react/file-tree/folderView.d.ts +25 -4
  87. package/dist/react/file-tree/folderView.d.ts.map +1 -1
  88. package/dist/react/file-tree/folderView.js +49 -9
  89. package/dist/react/file-tree/folderView.js.map +1 -1
  90. package/dist/react/file-tree/index.d.ts +2 -1
  91. package/dist/react/file-tree/index.d.ts.map +1 -1
  92. package/dist/react/file-tree/index.js +2 -1
  93. package/dist/react/file-tree/index.js.map +1 -1
  94. package/dist/react/file-tree/useFolderView.d.ts +34 -4
  95. package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
  96. package/dist/react/file-tree/useFolderView.js +43 -11
  97. package/dist/react/file-tree/useFolderView.js.map +1 -1
  98. package/dist/react/media/PlayerStrip.d.ts +9 -1
  99. package/dist/react/media/PlayerStrip.d.ts.map +1 -1
  100. package/dist/react/media/PlayerStrip.js +30 -6
  101. package/dist/react/media/PlayerStrip.js.map +1 -1
  102. package/dist/react/media/splitActions.d.ts +15 -0
  103. package/dist/react/media/splitActions.d.ts.map +1 -0
  104. package/dist/react/media/splitActions.js +16 -0
  105. package/dist/react/media/splitActions.js.map +1 -0
  106. package/dist/react/nav/TopBar.d.ts +1 -0
  107. package/dist/react/nav/TopBar.d.ts.map +1 -1
  108. package/dist/react/nav/TopBar.js +5 -1
  109. package/dist/react/nav/TopBar.js.map +1 -1
  110. package/dist/styles-areas/analytics.css +26 -25
  111. package/dist/styles-areas/calendar.css +4 -4
  112. package/dist/styles-areas/data-table.css +26 -25
  113. package/dist/styles-areas/workbench.css +5 -5
  114. package/dist/styles-utilities.css +32 -8
  115. package/package.json +5 -1
  116. package/scripts/demoCoverage.spec.ts +33 -0
  117. package/scripts/demoCoverage.ts +124 -0
  118. package/src/react/charts/IntervalTimeline.tsx +6 -4
  119. package/src/react/chat/DateMarker.tsx +22 -6
  120. package/src/react/chat/MessageThread.spec.tsx +25 -1
  121. package/src/react/components/Button.spec.tsx +11 -0
  122. package/src/react/components/Button.tsx +5 -0
  123. package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
  124. package/src/react/components/DataTable/DataTable.tsx +1 -0
  125. package/src/react/components/DataTable/SortHeader.tsx +45 -5
  126. package/src/react/components/DataTable/types.ts +10 -0
  127. package/src/react/components/DropdownMenu.spec.tsx +26 -0
  128. package/src/react/components/DropdownMenu.tsx +10 -3
  129. package/src/react/components/Field.spec.tsx +24 -0
  130. package/src/react/components/Field.tsx +15 -5
  131. package/src/react/components/ListToolbar.tsx +20 -3
  132. package/src/react/components/SecretInput.tsx +5 -1
  133. package/src/react/components/auth/AuthCard.spec.tsx +8 -0
  134. package/src/react/components/auth/AuthCard.tsx +4 -1
  135. package/src/react/components/auth/PasswordSetFields.tsx +7 -11
  136. package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
  137. package/src/react/components/auth/RegisterForm.tsx +6 -6
  138. package/src/react/components/auth/SignupForm.spec.tsx +17 -0
  139. package/src/react/components/auth/SignupForm.tsx +11 -10
  140. package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
  141. package/src/react/components/auth/TotpEntry.tsx +18 -1
  142. package/src/react/components/auth/fields.tsx +8 -1
  143. package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
  144. package/src/react/components/calendar/DayAgenda.tsx +5 -2
  145. package/src/react/components/calendar/EventChip.spec.tsx +12 -8
  146. package/src/react/components/calendar/EventChip.tsx +11 -6
  147. package/src/react/components/calendar/MonthGrid.tsx +1 -1
  148. package/src/react/components/calendar/WeekView.tsx +5 -3
  149. package/src/react/components/calendar/colorUtils.ts +13 -11
  150. package/src/react/components/fieldErrorText.tsx +17 -0
  151. package/src/react/deep-link/resume.spec.tsx +16 -0
  152. package/src/react/deep-link/resume.ts +9 -2
  153. package/src/react/file-tree/FileTree.tsx +14 -1
  154. package/src/react/file-tree/FolderViewMenus.tsx +57 -17
  155. package/src/react/file-tree/folderView.spec.ts +63 -1
  156. package/src/react/file-tree/folderView.ts +63 -12
  157. package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
  158. package/src/react/file-tree/index.ts +15 -1
  159. package/src/react/file-tree/useFolderView.ts +73 -12
  160. package/src/react/media/PlayerStrip.tsx +53 -4
  161. package/src/react/media/playerStrip.spec.tsx +20 -0
  162. package/src/react/media/splitActions.ts +23 -0
  163. package/src/react/nav/TopBar.spec.tsx +8 -0
  164. package/src/react/nav/TopBar.tsx +5 -0
  165. package/src/styles-areas/analytics.css +26 -25
  166. package/src/styles-areas/calendar.css +4 -4
  167. package/src/styles-areas/data-table.css +26 -25
  168. package/src/styles-areas/workbench.css +5 -5
  169. package/src/styles-utilities.css +32 -8
@@ -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`
@@ -77,6 +77,11 @@ export interface ListToolbarProps {
77
77
  * keeps the controls right when everything fits on one line and lets them ride down as
78
78
  * a group when it does not.
79
79
  *
80
+ * Except with chips, from `sm:`: a strip sized to its content took the whole row and wrapped the
81
+ * controls under it (18 genres in flix, at 1440 AND 2560 — forge task 2560), breaking the one
82
+ * line this component exists for. There the row stops wrapping and the strip becomes a scroll
83
+ * lane (`flex-1`, `min-w-0` from its own root) between a fixed search box and fixed controls.
84
+ *
80
85
  * No `role='toolbar'`: that role promises arrow-key roving focus between its controls,
81
86
  * which would take the arrow keys away from the text box and the native `<select>`
82
87
  * that live on this line. Tab order is the correct affordance here.
@@ -115,7 +120,16 @@ export function ListToolbar({
115
120
  const showRight = Boolean(sort) || Boolean(view) || Boolean(actions);
116
121
 
117
122
  return (
118
- <div className={cn('flex flex-wrap items-center gap-x-3 gap-y-2', className)}>
123
+ <div
124
+ className={cn(
125
+ 'flex flex-wrap items-center gap-x-3 gap-y-2',
126
+ // From `sm:` a row WITH chips never wraps: the chips are the one item that can give way
127
+ // (a scroll lane), so they take what the search box and the controls leave. Without
128
+ // chips nothing on the line can scroll, so it keeps wrapping rather than overflow.
129
+ showChips && 'sm:flex-nowrap',
130
+ className,
131
+ )}
132
+ >
119
133
  {showSearch ? (
120
134
  // The one search box (magnifier + themed Input). A bare Input drew an empty, unmarked
121
135
  // rectangle whenever a caller passed no placeholder (forge task 2514). `syntax={false}`:
@@ -129,7 +143,7 @@ export function ListToolbar({
129
143
  // Full width on a phone (the chips then wrap beneath it, still tappable);
130
144
  // a fixed, modest column from `sm:` so the chips share the line rather than
131
145
  // being pushed off it by a box that grows with the container.
132
- className='w-full min-w-40 sm:w-56'
146
+ className='w-full min-w-40 sm:w-56 sm:shrink-0'
133
147
  />
134
148
  ) : null}
135
149
  {showChips ? (
@@ -139,11 +153,14 @@ export function ListToolbar({
139
153
  onChange={onChipChange}
140
154
  multi={multiChips}
141
155
  {...(chipsAllLabel === undefined ? {} : { allLabel: chipsAllLabel })}
156
+ // The scroll lane. Sized to its CONTENT, twenty chips took the whole row and pushed
157
+ // sort/view onto a second line at 1440 and 2560 (forge task 2560, flix's genres).
158
+ className='sm:flex-1'
142
159
  />
143
160
  ) : null}
144
161
  {filters}
145
162
  {showRight ? (
146
- <span className='ml-auto flex flex-wrap items-center gap-2'>
163
+ <span className={cn('ml-auto flex flex-wrap items-center gap-2', showChips && 'sm:shrink-0 sm:flex-nowrap')}>
147
164
  {sort}
148
165
  {view}
149
166
  {actions}
@@ -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>
@@ -57,7 +57,10 @@ export function AuthCard({
57
57
  <div
58
58
  className={cn(
59
59
  'flex w-full flex-col items-center justify-center gap-6 p-4',
60
- aurora ? 'dark cb-aurora cb-auth-aurora' : 'bg-background',
60
+ // The page colour belongs to the full-screen page only. Embedded, the card sits on its
61
+ // host's surface — painting `bg-background` there drew a slab around it, a box on a box
62
+ // inside the host's panel (forge task 2466).
63
+ aurora ? 'dark cb-aurora cb-auth-aurora' : fullScreen && 'bg-background',
61
64
  fullScreen && 'min-h-dvh',
62
65
  className,
63
66
  )}
@@ -36,17 +36,13 @@ export function PasswordSetFields({
36
36
 
37
37
  return (
38
38
  <>
39
- <div className='flex flex-col gap-1.5'>
40
- <AuthPasswordField
41
- name={passwordName}
42
- label={passwordLabel}
43
- autoComplete='new-password'
44
- autoFocus={autoFocus}
45
- />
46
- {showStrength ? (
47
- <PasswordStrengthMeter value={password ?? ''} minLength={minPasswordLength} />
48
- ) : null}
49
- </div>
39
+ <AuthPasswordField
40
+ name={passwordName}
41
+ label={passwordLabel}
42
+ autoComplete='new-password'
43
+ autoFocus={autoFocus}
44
+ status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
45
+ />
50
46
  {showConfirm ? (
51
47
  <AuthPasswordField name={confirmName} label={confirmLabel} autoComplete='new-password' />
52
48
  ) : null}
@@ -40,9 +40,11 @@ export function PasswordStrengthMeter({
40
40
  if (!value) return null;
41
41
  const tone = SEGMENT_TONE[level as Exclude<PasswordStrengthLevel, 'empty'>];
42
42
 
43
+ // ONE line (forge task 2466): it sits in a field's error slot (`Field status`), which is one
44
+ // line of `text-sm` tall, so the bars and the word share a row rather than stacking.
43
45
  return (
44
- <div className={cn('flex flex-col gap-1', className)}>
45
- <div className='flex gap-1' aria-hidden>
46
+ <div className={cn('flex min-h-[1lh] items-center gap-3 text-sm', className)}>
47
+ <div className='flex flex-1 gap-1' aria-hidden>
46
48
  {[0, 1, 2, 3].map((i) => (
47
49
  <span
48
50
  key={i}
@@ -54,7 +56,7 @@ export function PasswordStrengthMeter({
54
56
  ))}
55
57
  </div>
56
58
  <p
57
- className='text-xs text-muted-foreground'
59
+ className='m-0 shrink-0 text-xs text-muted-foreground'
58
60
  role='status'
59
61
  aria-label={`Password strength: ${LABEL[level]}`}
60
62
  >
@@ -82,12 +82,12 @@ export function RegisterForm({
82
82
  >
83
83
  {children}
84
84
 
85
- <div className='flex flex-col gap-1.5'>
86
- <AuthPasswordField name='password' label='Password' autoComplete='new-password' />
87
- {showStrength ? (
88
- <PasswordStrengthMeter value={password} minLength={minPasswordLength} />
89
- ) : null}
90
- </div>
85
+ <AuthPasswordField
86
+ name='password'
87
+ label='Password'
88
+ autoComplete='new-password'
89
+ status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
90
+ />
91
91
 
92
92
  <AuthPasswordField
93
93
  name='confirmPassword'
@@ -52,6 +52,23 @@ describe('SignupForm', () => {
52
52
  expect(queryByText(/Password strength/)).not.toBeNull();
53
53
  });
54
54
 
55
+ test('a password validates on BLUR, like every other field — not only on submit (task 2466)', async () => {
56
+ const { container, findByText } = render(<SignupForm onSubmit={async () => {}} />);
57
+ const password = container.querySelector('#cb-f-password') as HTMLInputElement;
58
+ fireEvent.change(password, { target: { value: 'short' } });
59
+ fireEvent.blur(password);
60
+ await findByText('Use at least 8 characters');
61
+ });
62
+
63
+ test("the terms error is a field error like every other — icon, and wired to the checkbox (task 2466)", async () => {
64
+ const { getByRole, findByText } = render(<SignupForm onSubmit={async () => {}} terms='I agree to the terms' />);
65
+ fireEvent.click(getByRole('button', { name: 'Create account' }));
66
+ const error = (await findByText('Please accept to continue')).closest('p') as HTMLElement;
67
+ expect(error.hasAttribute('data-cb-field-error')).toBe(true);
68
+ expect(error.querySelector('svg')).not.toBeNull();
69
+ expect(getByRole('checkbox').getAttribute('aria-describedby')).toBe(error.id);
70
+ });
71
+
55
72
  test('requires the terms checkbox when terms are given', async () => {
56
73
  const onSubmit = mock(async () => {});
57
74
  const { container, getByRole, findByText } = render(
@@ -1,8 +1,9 @@
1
- import { type ReactNode, useMemo } from 'react';
1
+ import { type ReactNode, useId, useMemo } from 'react';
2
2
  import { Controller, useForm } from 'react-hook-form';
3
3
  import { z } from 'zod';
4
4
  import { makeFormConfig } from '../../lib/form.js';
5
5
  import { Checkbox } from '../Checkbox.js';
6
+ import { FieldErrorText } from '../fieldErrorText.js';
6
7
  import { AuthForm } from './AuthForm.js';
7
8
  import { AuthPasswordField, AuthTextField } from './fields.js';
8
9
  import { PasswordStrengthMeter } from './PasswordStrengthMeter.js';
@@ -93,6 +94,7 @@ export function SignupForm({
93
94
  );
94
95
 
95
96
  const password = form.watch('password');
97
+ const termsErrorId = useId();
96
98
 
97
99
  return (
98
100
  <AuthForm
@@ -115,12 +117,12 @@ export function SignupForm({
115
117
 
116
118
  {children}
117
119
 
118
- <div className='flex flex-col gap-1.5'>
119
- <AuthPasswordField name='password' label='Password' autoComplete='new-password' />
120
- {showStrength ? (
121
- <PasswordStrengthMeter value={password} minLength={minPasswordLength} />
122
- ) : null}
123
- </div>
120
+ <AuthPasswordField
121
+ name='password'
122
+ label='Password'
123
+ autoComplete='new-password'
124
+ status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
125
+ />
124
126
 
125
127
  {showConfirm ? (
126
128
  <AuthPasswordField
@@ -140,12 +142,11 @@ export function SignupForm({
140
142
  checked={field.value}
141
143
  onCheckedChange={(v) => field.onChange(v === true)}
142
144
  aria-invalid={form.formState.errors.acceptTerms ? true : undefined}
145
+ aria-describedby={form.formState.errors.acceptTerms ? termsErrorId : undefined}
143
146
  label={terms}
144
147
  />
145
148
  {form.formState.errors.acceptTerms ? (
146
- <p role='alert' className='text-sm text-field-error'>
147
- {form.formState.errors.acceptTerms.message as string}
148
- </p>
149
+ <FieldErrorText id={termsErrorId}>{form.formState.errors.acceptTerms.message as string}</FieldErrorText>
149
150
  ) : null}
150
151
  </div>
151
152
  )}
@@ -32,6 +32,33 @@ describe('TotpEntry', () => {
32
32
  expect(onSubmit).toHaveBeenCalledWith({ code: 'ABCDE-FGHIJ', recovery: true });
33
33
  });
34
34
 
35
+ test('a refused code is cleared and the caret goes back to the first box (task 2466)', async () => {
36
+ const onSubmit = mock(async () => {
37
+ throw { message: 'That code has expired.' };
38
+ });
39
+ const { container, getByRole, findByText } = render(<TotpEntry onSubmit={onSubmit} autoSubmit={false} />);
40
+ const boxes = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
41
+ for (let i = 0; i < 6; i++) fireEvent.change(boxes[i], { target: { value: String(i + 1) } });
42
+ fireEvent.click(getByRole('button', { name: 'Verify' }));
43
+ await findByText('That code has expired.');
44
+ const after = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
45
+ await waitFor(() => expect(after.map((b) => b.value).join('')).toBe(''));
46
+ expect(document.activeElement).toBe(after[0]);
47
+ });
48
+
49
+ test('a refused RECOVERY code stays, for the user to correct', async () => {
50
+ const onSubmit = mock(async () => {
51
+ throw { message: 'That recovery code was already used.' };
52
+ });
53
+ const { container, getByRole, findByText } = render(<TotpEntry onSubmit={onSubmit} autoSubmit={false} />);
54
+ fireEvent.click(getByRole('button', { name: 'Use a recovery code instead' }));
55
+ const input = container.querySelector('#cb-f-code') as HTMLInputElement;
56
+ fireEvent.change(input, { target: { value: 'ABCDE-FGHIJ' } });
57
+ fireEvent.click(getByRole('button', { name: 'Verify' }));
58
+ await findByText('That recovery code was already used.');
59
+ expect((container.querySelector('#cb-f-code') as HTMLInputElement).value).toBe('ABCDE-FGHIJ');
60
+ });
61
+
35
62
  test('omits the recovery toggle when allowRecoveryCode is false', () => {
36
63
  const { queryByRole } = render(
37
64
  <TotpEntry onSubmit={async () => {}} allowRecoveryCode={false} autoSubmit={false} />,
@@ -72,11 +72,26 @@ export function TotpEntry({
72
72
  form.clearErrors();
73
73
  };
74
74
 
75
+ // A refused authenticator code is dead — it rotates in seconds — so clear it and put the caret
76
+ // back on the first box, the way GitHub and Stripe do (forge task 2466). A recovery code is
77
+ // long and typed by hand, so it stays for the user to correct.
78
+ const submit: AuthSubmitHandler<TotpValues> = async (values) => {
79
+ try {
80
+ return await onSubmit(values);
81
+ } catch (err) {
82
+ if (!values.recovery) {
83
+ form.setValue('code', '');
84
+ formRef.current?.querySelector<HTMLInputElement>('input')?.focus();
85
+ }
86
+ throw err;
87
+ }
88
+ };
89
+
75
90
  return (
76
91
  <AuthForm
77
92
  form={form}
78
93
  formRef={formRef}
79
- onSubmit={onSubmit}
94
+ onSubmit={submit}
80
95
  onSuccess={onSuccess}
81
96
  mapError={mapError}
82
97
  submitLabel={submitLabel}
@@ -128,6 +143,8 @@ export function TotpEntry({
128
143
  value={field.value}
129
144
  onChange={field.onChange}
130
145
  length={length}
146
+ // Spread across the column, so the boxes end where every other row does.
147
+ className='w-full justify-between'
131
148
  onComplete={autoSubmit ? () => formRef.current?.requestSubmit() : undefined}
132
149
  aria-label='Authentication code'
133
150
  aria-invalid={aria['aria-invalid']}
@@ -68,6 +68,8 @@ export interface AuthPasswordFieldProps {
68
68
  description?: ReactNode;
69
69
  required?: boolean;
70
70
  autoFocus?: boolean;
71
+ /** Live feedback under the field — the strength meter — in its error slot ({@link Field}'s `status`). */
72
+ status?: ReactNode;
71
73
  }
72
74
 
73
75
  /**
@@ -83,10 +85,11 @@ export function AuthPasswordField({
83
85
  description,
84
86
  required,
85
87
  autoFocus,
88
+ status,
86
89
  }: AuthPasswordFieldProps) {
87
90
  const { control } = useFormContext();
88
91
  return (
89
- <Field name={name} label={label} description={description} required={required} reserveErrorSpace>
92
+ <Field name={name} label={label} description={description} required={required} reserveErrorSpace status={status}>
90
93
  {(aria) => (
91
94
  <Controller
92
95
  name={name}
@@ -97,6 +100,10 @@ export function AuthPasswordField({
97
100
  name={field.name}
98
101
  value={field.value ?? ''}
99
102
  onChange={field.onChange}
103
+ // 🔴 Without this a password never validated on blur, only on submit — the forms
104
+ // are `mode: 'onBlur'`, and RHF only learns of a blur through `field.onBlur`
105
+ // (forge task 2466: "Use at least 8 characters" never showed until submit).
106
+ onBlur={field.onBlur}
100
107
  label={labelText(label, name)}
101
108
  placeholder={placeholder}
102
109
  autoComplete={autoComplete}