cursedbelt 5.46.2 → 5.48.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 (170) 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/react/media/DenseGalleryShell.d.ts +8 -1
  95. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  96. package/dist/react/media/DenseGalleryShell.js +33 -8
  97. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  98. package/dist/react/media/VideoPlayer.d.ts +12 -1
  99. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  100. package/dist/react/media/VideoPlayer.js +30 -2
  101. package/dist/react/media/VideoPlayer.js.map +1 -1
  102. package/dist/react/media/controlBar.d.ts +3 -2
  103. package/dist/react/media/controlBar.d.ts.map +1 -1
  104. package/dist/react/media/controlBar.js +16 -10
  105. package/dist/react/media/controlBar.js.map +1 -1
  106. package/dist/react/media/denseGallery.css +56 -18
  107. package/dist/react/media/videoPlayer.css +2 -1
  108. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  109. package/dist/react/media-gallery/MediaGallery.js +8 -4
  110. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  111. package/dist/styles-areas/analytics.css +26 -25
  112. package/dist/styles-areas/calendar.css +4 -4
  113. package/dist/styles-areas/data-table.css +26 -25
  114. package/dist/styles-utilities.css +24 -9
  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/SecretInput.tsx +5 -1
  132. package/src/react/components/auth/AuthCard.spec.tsx +8 -0
  133. package/src/react/components/auth/AuthCard.tsx +4 -1
  134. package/src/react/components/auth/PasswordSetFields.tsx +7 -11
  135. package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
  136. package/src/react/components/auth/RegisterForm.tsx +6 -6
  137. package/src/react/components/auth/SignupForm.spec.tsx +17 -0
  138. package/src/react/components/auth/SignupForm.tsx +11 -10
  139. package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
  140. package/src/react/components/auth/TotpEntry.tsx +18 -1
  141. package/src/react/components/auth/fields.tsx +8 -1
  142. package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
  143. package/src/react/components/calendar/DayAgenda.tsx +5 -2
  144. package/src/react/components/calendar/EventChip.spec.tsx +12 -8
  145. package/src/react/components/calendar/EventChip.tsx +11 -6
  146. package/src/react/components/calendar/MonthGrid.tsx +1 -1
  147. package/src/react/components/calendar/WeekView.tsx +5 -3
  148. package/src/react/components/calendar/colorUtils.ts +13 -11
  149. package/src/react/components/fieldErrorText.tsx +17 -0
  150. package/src/react/deep-link/resume.spec.tsx +16 -0
  151. package/src/react/deep-link/resume.ts +9 -2
  152. package/src/react/file-tree/FileTree.tsx +14 -1
  153. package/src/react/file-tree/FolderViewMenus.tsx +57 -17
  154. package/src/react/file-tree/folderView.spec.ts +63 -1
  155. package/src/react/file-tree/folderView.ts +63 -12
  156. package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
  157. package/src/react/file-tree/index.ts +15 -1
  158. package/src/react/file-tree/useFolderView.ts +73 -12
  159. package/src/react/media/DenseGalleryShell.spec.tsx +9 -7
  160. package/src/react/media/DenseGalleryShell.tsx +53 -11
  161. package/src/react/media/VideoPlayer.tsx +78 -2
  162. package/src/react/media/controlBar.tsx +17 -10
  163. package/src/react/media/denseGallery.css +56 -18
  164. package/src/react/media/videoPlayer.css +2 -1
  165. package/src/react/media-gallery/MediaGallery.spec.tsx +4 -4
  166. package/src/react/media-gallery/MediaGallery.tsx +12 -0
  167. package/src/styles-areas/analytics.css +26 -25
  168. package/src/styles-areas/calendar.css +4 -4
  169. package/src/styles-areas/data-table.css +26 -25
  170. package/src/styles-utilities.css +24 -9
@@ -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>
@@ -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}
@@ -24,6 +24,15 @@ describe('DayAgenda', () => {
24
24
  expect(screen.getByText('Nothing planned')).toBeTruthy();
25
25
  });
26
26
 
27
+ test('an empty day points at Add only when there is an Add to press', () => {
28
+ const { unmount } = render(<DayAgenda dayDate={DAY} events={[]} onSelectOccurrence={() => {}} />);
29
+ expect(screen.getByText('No events on this day.')).toBeTruthy();
30
+ expect(screen.queryByText(/Add/)).toBeNull();
31
+ unmount();
32
+ render(<DayAgenda dayDate={DAY} events={[]} onSelectOccurrence={() => {}} onAdd={() => {}} />);
33
+ expect(screen.getByText('Use Add to create an event on this day.')).toBeTruthy();
34
+ });
35
+
27
36
  test('renders an event title', () => {
28
37
  const events = [makeEvent({ id: 'e1', title: 'Morning run' })];
29
38
  render(<DayAgenda dayDate={DAY} events={events} onSelectOccurrence={() => {}} />);
@@ -89,7 +89,8 @@ export function DayAgenda({
89
89
  <EmptyState
90
90
  icon={<CalendarIcon className='h-6 w-6' />}
91
91
  title='Nothing planned'
92
- description="Tap 'Add' to create an event."
92
+ // Only point at a button that is there: a read-only agenda has no Add (forge task 2591).
93
+ description={onAdd ? 'Use Add to create an event on this day.' : 'No events on this day.'}
93
94
  />
94
95
  ) : (
95
96
  <ul className='flex flex-col gap-1'>
@@ -140,7 +141,9 @@ function AgendaItem({ occurrence, color, calendarName, onSelect }: AgendaItemPro
140
141
  style={dotStyle}
141
142
  aria-hidden='true'
142
143
  />
143
- <span className='w-16 shrink-0 font-semibold tabular-nums text-muted-foreground'>
144
+ {/* Wide enough for "12:00 PM" on one line — at w-16 it wrapped and pushed its title
145
+ out of line with every other row's (forge task 2591). */}
146
+ <span className='w-20 shrink-0 whitespace-nowrap font-semibold tabular-nums text-muted-foreground'>
144
147
  {occurrence.allDay ? 'All day' : fmtTime(occurrence.startAt)}
145
148
  </span>
146
149
  <span