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
@@ -18,35 +18,36 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 148 candidate(s).
21
+ * 154 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("-m-1 -mt-2 -mt-6 -mx-1 -my-2.5 -my-3 @3xl:block @3xl:hidden @max-2xl:[&>*]:hidden");
28
- @source inline("@max-2xl:border-0 @max-2xl:min-w-0! @max-2xl:p-0! @max-2xl:w-0! @max-4xl:[&>*]:hidden");
27
+ @source inline("-m-1 -mt-2 -mt-6 -mx-1 -my-2.5 -my-3 @3xl:block @3xl:hidden @max-2xl @max-2xl:[&>*]:hidden");
28
+ @source inline("@max-2xl:border-0 @max-2xl:inline-flex @max-2xl:justify-center @max-2xl:min-w-0!");
29
+ @source inline("@max-2xl:min-w-11 @max-2xl:p-0! @max-2xl:sr-only @max-2xl:w-0! @max-4xl:[&>*]:hidden");
29
30
  @source inline("@max-4xl:border-0 @max-4xl:min-w-0! @max-4xl:p-0! @max-4xl:w-0! @max-6xl:[&>*]:hidden");
30
31
  @source inline("@max-6xl:border-0 @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:w-0! @xl:block @xl:hidden");
31
- @source inline("[&>*]:hidden [&>*]:max-w-none [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing");
32
- @source inline("appearance appearance-none bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30");
33
- @source inline("bg-muted/40 bg-muted/50 border-accent-solid border-accent/60 border-field-error border-l");
34
- @source inline("border-r col collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
35
- @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
36
- @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95");
37
- @source inline("data-[state=closed]:slide-out-to-bottom data-[state=closed]:slide-out-to-left");
38
- @source inline("data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-bottom");
39
- @source inline("data-[state=open]:slide-in-from-left data-[state=open]:slide-in-from-right disabled:opacity-40");
40
- @source inline("duration-100 fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring");
41
- @source inline("focus-within:ring-2 focus-within:ring-field-ring/30 from-card h-105");
42
- @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-border");
43
- @source inline("hover:bg-muted/40 hover:border-field-ring hover:text-warning italic justify-self-end");
44
- @source inline("last:border-0 left-0 left-11 left-2.5 list-none max-h-64 max-h-88 max-h-[var(--sheet-h-lg)]");
45
- @source inline("max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)] max-w-32 max-w-36 max-w-45 max-w-48 max-w-60");
46
- @source inline("max-w-sm min-h-20 min-h-dvh min-w-0! min-w-20 min-w-24 min-w-52 min-w-[var(--anchor-width)]");
47
- @source inline("min-w-max ml-1.5 mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1");
48
- @source inline("pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3 ring-field-error/30 rounded-l-xl");
49
- @source inline("rounded-r-xl rounded-t-xl self self-center self-stretch shadow-[1px_0_0_0_var(--color-border)]");
50
- @source inline("shrink-[2] size-2 size-7 slide-in-from-top-1 table-fixed text-accent-solid-foreground");
51
- @source inline("text-field-placeholder text-muted-foreground/60 text-success text-xl to-transparent top-3");
52
- @source inline("transition-[transform,opacity] w-0! w-11 w-20 w-32 w-36 w-40 w-88");
32
+ @source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere]");
33
+ @source inline("active:cursor-grabbing appearance appearance-none bg-[var(--cb-frozen-bg)] bg-card/95");
34
+ @source inline("bg-gradient-to-t bg-muted/30 bg-muted/40 bg-muted/50 border-accent-solid border-accent/60");
35
+ @source inline("border-field-error border-l border-r col collapse cursor-grab data-[ending-style]:opacity-0");
36
+ @source inline("data-[ending-style]:scale-95 data-[highlighted]:bg-accent/10");
37
+ @source inline("data-[highlighted]:text-accent-strong data-[starting-style]:opacity-0");
38
+ @source inline("data-[starting-style]:scale-95 data-[state=closed]:slide-out-to-bottom");
39
+ @source inline("data-[state=closed]:slide-out-to-left data-[state=closed]:slide-out-to-right");
40
+ @source inline("data-[state=open]:slide-in-from-bottom data-[state=open]:slide-in-from-left");
41
+ @source inline("data-[state=open]:slide-in-from-right disabled:opacity-40 duration-100 fade-in");
42
+ @source inline("focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
43
+ @source inline("focus-within:ring-field-ring/30 from-card h-105 has-[[aria-invalid=true]]:border-field-error");
44
+ @source inline("hover:bg-background hover:bg-border hover:bg-muted/40 hover:border-field-ring hover:text-warning");
45
+ @source inline("italic justify-self-end last:border-0 left-0 left-11 left-2.5 list-none max-h-64 max-h-88");
46
+ @source inline("max-h-[var(--sheet-h-lg)] max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)] max-w-32 max-w-36");
47
+ @source inline("max-w-45 max-w-48 max-w-60 max-w-sm min-h-20 min-h-dvh min-w-0! min-w-20 min-w-24 min-w-52");
48
+ @source inline("min-w-[var(--anchor-width)] min-w-max ml-1.5 mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0!");
49
+ @source inline("pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3");
50
+ @source inline("ring-field-error/30 rounded-l-xl rounded-r-xl rounded-t-xl self self-center self-stretch");
51
+ @source inline("shadow-[1px_0_0_0_var(--color-border)] shrink-[2] size-2 size-7 slide-in-from-top-1 table-fixed");
52
+ @source inline("text-accent-solid-foreground text-field-placeholder text-muted-foreground/60 text-success");
53
+ @source inline("text-xl to-transparent top-3 transition-[transform,opacity] w-0! w-11 w-20 w-32 w-36 w-40 w-88");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 79 candidate(s).
21
+ * 78 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -32,9 +32,9 @@
32
32
  @source inline("dark:bg-sky-900/40 dark:bg-slate-800 dark:bg-teal-900/40 dark:bg-violet-900/40");
33
33
  @source inline("dark:text-amber-200 dark:text-blue-200 dark:text-emerald-200 dark:text-green-200");
34
34
  @source inline("dark:text-purple-200 dark:text-rose-200 dark:text-sky-200 dark:text-slate-200 dark:text-teal-200");
35
- @source inline("dark:text-violet-200 focus:opacity-100 g grid-cols-[repeat(7,minmax(0,1fr))]");
35
+ @source inline("dark:text-violet-200 focus:opacity-100 grid-cols-[repeat(7,minmax(0,1fr))]");
36
36
  @source inline("group-focus-within:opacity-100 h-3.5 hover:bg-muted/30 hover:bg-muted/40 hover:border-border/80");
37
37
  @source inline("last:border-r-0 line-through min-h-28 min-h-40 outline-1 outline-dashed outline-tone-amber-ink");
38
- @source inline("p-1.5 sm:flex-row sm:gap-1 sm:items-center sm:min-h-0 sm:min-h-24 sm:px-1.5 sm:text-xs");
38
+ @source inline("p-1.5 sm:inline sm:min-h-0 sm:min-h-24 sm:px-1.5 sm:text-xs text-accent-solid-foreground");
39
39
  @source inline("text-amber-900 text-blue-800 text-emerald-800 text-green-800 text-purple-800 text-rose-800");
40
- @source inline("text-sky-800 text-slate-800 text-teal-800 text-tone-amber-ink text-violet-800 w-3.5");
40
+ @source inline("text-sky-800 text-slate-800 text-teal-800 text-tone-amber-ink text-violet-800 w-20 w-3.5");
@@ -18,35 +18,36 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 144 candidate(s).
21
+ * 150 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("-m-1 -mt-2 -mt-6 -mx-1 -my-2.5 -my-3 @3xl:block @3xl:hidden @max-2xl:[&>*]:hidden");
28
- @source inline("@max-2xl:border-0 @max-2xl:min-w-0! @max-2xl:p-0! @max-2xl:w-0! @max-4xl:[&>*]:hidden");
27
+ @source inline("-m-1 -mt-2 -mt-6 -mx-1 -my-2.5 -my-3 @3xl:block @3xl:hidden @max-2xl @max-2xl:[&>*]:hidden");
28
+ @source inline("@max-2xl:border-0 @max-2xl:inline-flex @max-2xl:justify-center @max-2xl:min-w-0!");
29
+ @source inline("@max-2xl:min-w-11 @max-2xl:p-0! @max-2xl:sr-only @max-2xl:w-0! @max-4xl:[&>*]:hidden");
29
30
  @source inline("@max-4xl:border-0 @max-4xl:min-w-0! @max-4xl:p-0! @max-4xl:w-0! @max-6xl:[&>*]:hidden");
30
31
  @source inline("@max-6xl:border-0 @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:w-0! @xl:block @xl:hidden");
31
- @source inline("[&>*]:hidden [&>*]:max-w-none [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing");
32
- @source inline("appearance appearance-none bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30");
33
- @source inline("bg-muted/50 border-accent-solid border-accent/60 border-field-error border-l border-r col");
34
- @source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
35
- @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
36
- @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95");
37
- @source inline("data-[state=closed]:slide-out-to-bottom data-[state=closed]:slide-out-to-left");
38
- @source inline("data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-bottom");
39
- @source inline("data-[state=open]:slide-in-from-left data-[state=open]:slide-in-from-right disabled:opacity-40");
40
- @source inline("duration-100 fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring");
41
- @source inline("focus-within:ring-2 focus-within:ring-field-ring/30 from-card h-105");
42
- @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-background hover:bg-border");
43
- @source inline("hover:bg-muted/40 hover:border-field-ring hover:text-warning justify-self-end last:border-0");
44
- @source inline("left-0 left-11 left-2.5 list-none max-h-64 max-h-88 max-h-[var(--sheet-h-lg)]");
45
- @source inline("max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)] max-w-32 max-w-36 max-w-45 max-w-48 max-w-60");
46
- @source inline("max-w-sm min-h-20 min-h-dvh min-w-0! min-w-24 min-w-52 min-w-[var(--anchor-width)] min-w-max");
47
- @source inline("ml-1.5 mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16");
48
- @source inline("pr-3 pr-9 pt-1 resize-none resize-y right-3 ring-field-error/30 rounded-l-xl rounded-r-xl");
49
- @source inline("rounded-t-xl self self-center self-stretch shadow-[1px_0_0_0_var(--color-border)] size-2 size-7");
50
- @source inline("slide-in-from-top-1 table-fixed text-accent-solid-foreground text-field-placeholder");
51
- @source inline("text-muted-foreground/60 text-success text-xl to-transparent top-3");
52
- @source inline("transition-[transform,opacity] w-0! w-11 w-20 w-32 w-36 w-40 w-88");
32
+ @source inline("[&>*]:hidden [&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere]");
33
+ @source inline("active:cursor-grabbing appearance appearance-none bg-[var(--cb-frozen-bg)] bg-card/95");
34
+ @source inline("bg-gradient-to-t bg-muted/30 bg-muted/50 border-accent-solid border-accent/60 border-field-error");
35
+ @source inline("border-l border-r col collapse cursor-grab data-[ending-style]:opacity-0");
36
+ @source inline("data-[ending-style]:scale-95 data-[highlighted]:bg-accent/10");
37
+ @source inline("data-[highlighted]:text-accent-strong data-[starting-style]:opacity-0");
38
+ @source inline("data-[starting-style]:scale-95 data-[state=closed]:slide-out-to-bottom");
39
+ @source inline("data-[state=closed]:slide-out-to-left data-[state=closed]:slide-out-to-right");
40
+ @source inline("data-[state=open]:slide-in-from-bottom data-[state=open]:slide-in-from-left");
41
+ @source inline("data-[state=open]:slide-in-from-right disabled:opacity-40 duration-100 fade-in");
42
+ @source inline("focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
43
+ @source inline("focus-within:ring-field-ring/30 from-card h-105 has-[[aria-invalid=true]]:border-field-error");
44
+ @source inline("hover:bg-background hover:bg-border hover:bg-muted/40 hover:border-field-ring hover:text-warning");
45
+ @source inline("justify-self-end last:border-0 left-0 left-11 left-2.5 list-none max-h-64 max-h-88");
46
+ @source inline("max-h-[var(--sheet-h-lg)] max-h-[var(--sheet-h-md)] max-h-[var(--sheet-h-sm)] max-w-32 max-w-36");
47
+ @source inline("max-w-45 max-w-48 max-w-60 max-w-sm min-h-20 min-h-dvh min-w-0! min-w-24 min-w-52");
48
+ @source inline("min-w-[var(--anchor-width)] min-w-max ml-1.5 mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0!");
49
+ @source inline("pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3");
50
+ @source inline("ring-field-error/30 rounded-l-xl rounded-r-xl rounded-t-xl self self-center self-stretch");
51
+ @source inline("shadow-[1px_0_0_0_var(--color-border)] size-2 size-7 slide-in-from-top-1 table-fixed");
52
+ @source inline("text-accent-solid-foreground text-field-placeholder text-muted-foreground/60 text-success");
53
+ @source inline("text-xl to-transparent top-3 transition-[transform,opacity] w-0! w-11 w-20 w-32 w-36 w-40 w-88");
@@ -4337,18 +4337,9 @@
4337
4337
  .sm\:grid-cols-\[minmax\(7rem\,34rem\)_minmax\(4rem\,1fr\)_auto\] {
4338
4338
  grid-template-columns: minmax(7rem,34rem) minmax(4rem,1fr) auto;
4339
4339
  }
4340
- .sm\:flex-row {
4341
- flex-direction: row;
4342
- }
4343
4340
  .sm\:flex-nowrap {
4344
4341
  flex-wrap: nowrap;
4345
4342
  }
4346
- .sm\:items-center {
4347
- align-items: center;
4348
- }
4349
- .sm\:gap-1 {
4350
- gap: var(--spacing);
4351
- }
4352
4343
  .sm\:gap-3 {
4353
4344
  gap: calc(var(--spacing) * 3);
4354
4345
  }
@@ -4504,12 +4495,32 @@
4504
4495
  }
4505
4496
  }
4506
4497
  @container (width < 42rem) {
4498
+ .\@max-2xl\:sr-only {
4499
+ position: absolute;
4500
+ width: 1px;
4501
+ height: 1px;
4502
+ padding: 0;
4503
+ margin: -1px;
4504
+ overflow: hidden;
4505
+ clip-path: inset(50%);
4506
+ white-space: nowrap;
4507
+ border-width: 0;
4508
+ }
4509
+ .\@max-2xl\:inline-flex {
4510
+ display: inline-flex;
4511
+ }
4507
4512
  .\@max-2xl\:w-0\! {
4508
4513
  width: 0px !important;
4509
4514
  }
4510
4515
  .\@max-2xl\:min-w-0\! {
4511
4516
  min-width: 0px !important;
4512
4517
  }
4518
+ .\@max-2xl\:min-w-11 {
4519
+ min-width: calc(var(--spacing) * 11);
4520
+ }
4521
+ .\@max-2xl\:justify-center {
4522
+ justify-content: center;
4523
+ }
4513
4524
  .\@max-2xl\:border-0 {
4514
4525
  border-style: var(--tw-border-style);
4515
4526
  border-width: 0px;
@@ -4957,6 +4968,10 @@
4957
4968
  .\[\&\>span\>button\]\:rounded-none > span > button {
4958
4969
  border-radius: 0;
4959
4970
  }
4971
+ .\[\&\>svg\]\:size-3\.5 > svg {
4972
+ width: calc(var(--spacing) * 3.5);
4973
+ height: calc(var(--spacing) * 3.5);
4974
+ }
4960
4975
  }
4961
4976
  @property --tw-translate-x {
4962
4977
  syntax: "*";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.46.2",
3
+ "version": "5.48.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -69,6 +69,7 @@
69
69
  "typecheck:scripts": "tsc --noEmit --project tsconfig.scripts.json",
70
70
  "demo:check": "tsc --noEmit --project demo/tsconfig.json",
71
71
  "demo:paths": "bun run demo/generatePaths.ts --write",
72
+ "demo:coverage": "bun run scripts/demoCoverage.ts",
72
73
  "guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy,no-raw-popover,no-hand-rolled-scroll-memory,no-hand-rolled-surface,no-inline-style,no-arbitrary-length,opt-out-count,opt-out-needs-cause --scope cursedbelt src/react src/styles.css",
73
74
  "lint": "biome lint --error-on-warnings src e2e demo scripts",
74
75
  "styles": "bun run styles:static && bun run styles:utilities && bun run styles:areas",
@@ -902,6 +903,9 @@
902
903
  {
903
904
  "script": "demo:check"
904
905
  },
906
+ {
907
+ "script": "demo:coverage"
908
+ },
905
909
  {
906
910
  "script": "lint"
907
911
  },
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BASELINE, compare, demoText, exportedComponents, readBaseline, undemoed } from "./demoCoverage";
3
+
4
+ describe("demo coverage — a component with no demo does not ship (forge task 2466)", () => {
5
+ test("a component named only in an import or a comment is NOT demoed", () => {
6
+ const components = new Map([
7
+ ["Shown", ["./react"]],
8
+ ["Imported", ["./react"]],
9
+ ["Mentioned", ["./react"]],
10
+ ]);
11
+ // demoText strips these the same way before matching; this is what survives of a section.
12
+ const text = "export function Section() { return <Shown label='x' />; }";
13
+ expect(undemoed(components, text)).toEqual(["Imported", "Mentioned"]);
14
+ });
15
+
16
+ test("🔴 a new component with no demo is a fault, and so is a baseline entry that has outlived its gap", () => {
17
+ expect(compare(["Old", "New"], ["Old", "Fixed"])).toEqual({ added: ["New"], stale: ["Fixed"] });
18
+ expect(compare(["Old"], ["Old"])).toEqual({ added: [], stale: [] });
19
+ });
20
+
21
+ test("the export map is read through the type checker — components in, hooks and types out", () => {
22
+ const components = exportedComponents();
23
+ expect(components.get("Button")).toContain("./react/button");
24
+ expect(components.has("DataTable")).toBe(true);
25
+ expect(components.has("useToast")).toBe(false);
26
+ expect(components.has("ButtonProps")).toBe(false);
27
+ });
28
+
29
+ test("this repo matches its baseline exactly", () => {
30
+ const missing = undemoed(exportedComponents(), demoText());
31
+ expect(compare(missing, readBaseline(BASELINE))).toEqual({ added: [], stale: [] });
32
+ });
33
+ });
@@ -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)}