@hoardodile/ui 0.0.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 (356) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +70 -0
  3. package/dist/components/alert.d.ts +13 -0
  4. package/dist/components/alert.js +97 -0
  5. package/dist/components/alert.js.map +1 -0
  6. package/dist/components/app-dialog.d.ts +62 -0
  7. package/dist/components/app-dialog.js +656 -0
  8. package/dist/components/app-dialog.js.map +1 -0
  9. package/dist/components/badge.d.ts +11 -0
  10. package/dist/components/badge.js +70 -0
  11. package/dist/components/badge.js.map +1 -0
  12. package/dist/components/breadcrumb.d.ts +12 -0
  13. package/dist/components/breadcrumb.js +617 -0
  14. package/dist/components/breadcrumb.js.map +1 -0
  15. package/dist/components/button.d.ts +17 -0
  16. package/dist/components/button.js +78 -0
  17. package/dist/components/button.js.map +1 -0
  18. package/dist/components/caption-bar.d.ts +44 -0
  19. package/dist/components/caption-bar.js +827 -0
  20. package/dist/components/caption-bar.js.map +1 -0
  21. package/dist/components/chart-card.d.ts +15 -0
  22. package/dist/components/chart-card.js +27 -0
  23. package/dist/components/chart-card.js.map +1 -0
  24. package/dist/components/checkbox.d.ts +6 -0
  25. package/dist/components/checkbox.js +76 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip-button.d.ts +28 -0
  28. package/dist/components/chip-button.js +87 -0
  29. package/dist/components/chip-button.js.map +1 -0
  30. package/dist/components/close-confirm-dialog.d.ts +19 -0
  31. package/dist/components/close-confirm-dialog.js +743 -0
  32. package/dist/components/close-confirm-dialog.js.map +1 -0
  33. package/dist/components/color-picker.d.ts +32 -0
  34. package/dist/components/color-picker.js +667 -0
  35. package/dist/components/color-picker.js.map +1 -0
  36. package/dist/components/combobox.d.ts +32 -0
  37. package/dist/components/combobox.js +1211 -0
  38. package/dist/components/combobox.js.map +1 -0
  39. package/dist/components/confirm-by-typing-dialog.d.ts +48 -0
  40. package/dist/components/confirm-by-typing-dialog.js +759 -0
  41. package/dist/components/confirm-by-typing-dialog.js.map +1 -0
  42. package/dist/components/confirm-dialog.d.ts +34 -0
  43. package/dist/components/confirm-dialog.js +710 -0
  44. package/dist/components/confirm-dialog.js.map +1 -0
  45. package/dist/components/count-badge.d.ts +13 -0
  46. package/dist/components/count-badge.js +39 -0
  47. package/dist/components/count-badge.js.map +1 -0
  48. package/dist/components/dialog.d.ts +40 -0
  49. package/dist/components/dialog.js +571 -0
  50. package/dist/components/dialog.js.map +1 -0
  51. package/dist/components/dropdown-menu.d.ts +45 -0
  52. package/dist/components/dropdown-menu.js +1003 -0
  53. package/dist/components/dropdown-menu.js.map +1 -0
  54. package/dist/components/dropdown-select.d.ts +29 -0
  55. package/dist/components/dropdown-select.js +1036 -0
  56. package/dist/components/dropdown-select.js.map +1 -0
  57. package/dist/components/empty.d.ts +15 -0
  58. package/dist/components/empty.js +119 -0
  59. package/dist/components/empty.js.map +1 -0
  60. package/dist/components/filter-rail.d.ts +48 -0
  61. package/dist/components/filter-rail.js +231 -0
  62. package/dist/components/filter-rail.js.map +1 -0
  63. package/dist/components/fixed-action-bar.d.ts +24 -0
  64. package/dist/components/fixed-action-bar.js +48 -0
  65. package/dist/components/fixed-action-bar.js.map +1 -0
  66. package/dist/components/font-picker.d.ts +45 -0
  67. package/dist/components/font-picker.js +759 -0
  68. package/dist/components/font-picker.js.map +1 -0
  69. package/dist/components/form.d.ts +17 -0
  70. package/dist/components/form.js +150 -0
  71. package/dist/components/form.js.map +1 -0
  72. package/dist/components/group-label.d.ts +12 -0
  73. package/dist/components/group-label.js +10 -0
  74. package/dist/components/group-label.js.map +1 -0
  75. package/dist/components/icon-tile.d.ts +16 -0
  76. package/dist/components/icon-tile.js +67 -0
  77. package/dist/components/icon-tile.js.map +1 -0
  78. package/dist/components/icon-toggle.d.ts +24 -0
  79. package/dist/components/icon-toggle.js +81 -0
  80. package/dist/components/icon-toggle.js.map +1 -0
  81. package/dist/components/icon.d.ts +2 -0
  82. package/dist/components/icon.js +49 -0
  83. package/dist/components/icon.js.map +1 -0
  84. package/dist/components/image-crop-panel.d.ts +80 -0
  85. package/dist/components/image-crop-panel.js +1104 -0
  86. package/dist/components/image-crop-panel.js.map +1 -0
  87. package/dist/components/image-cropper.d.ts +63 -0
  88. package/dist/components/image-cropper.js +166 -0
  89. package/dist/components/image-cropper.js.map +1 -0
  90. package/dist/components/input-group.d.ts +22 -0
  91. package/dist/components/input-group.js +270 -0
  92. package/dist/components/input-group.js.map +1 -0
  93. package/dist/components/input.d.ts +16 -0
  94. package/dist/components/input.js +60 -0
  95. package/dist/components/input.js.map +1 -0
  96. package/dist/components/label.d.ts +5 -0
  97. package/dist/components/label.js +40 -0
  98. package/dist/components/label.js.map +1 -0
  99. package/dist/components/list-empty-row.d.ts +16 -0
  100. package/dist/components/list-empty-row.js +40 -0
  101. package/dist/components/list-empty-row.js.map +1 -0
  102. package/dist/components/management-empty.d.ts +14 -0
  103. package/dist/components/management-empty.js +40 -0
  104. package/dist/components/management-empty.js.map +1 -0
  105. package/dist/components/management-skeleton.d.ts +11 -0
  106. package/dist/components/management-skeleton.js +72 -0
  107. package/dist/components/management-skeleton.js.map +1 -0
  108. package/dist/components/meta-chip.d.ts +17 -0
  109. package/dist/components/meta-chip.js +49 -0
  110. package/dist/components/meta-chip.js.map +1 -0
  111. package/dist/components/mobile-drawer.d.ts +29 -0
  112. package/dist/components/mobile-drawer.js +331 -0
  113. package/dist/components/mobile-drawer.js.map +1 -0
  114. package/dist/components/page-header.d.ts +20 -0
  115. package/dist/components/page-header.js +44 -0
  116. package/dist/components/page-header.js.map +1 -0
  117. package/dist/components/page-scaffold.d.ts +34 -0
  118. package/dist/components/page-scaffold.js +157 -0
  119. package/dist/components/page-scaffold.js.map +1 -0
  120. package/dist/components/pagination-bar.d.ts +22 -0
  121. package/dist/components/pagination-bar.js +703 -0
  122. package/dist/components/pagination-bar.js.map +1 -0
  123. package/dist/components/pagination.d.ts +22 -0
  124. package/dist/components/pagination.js +680 -0
  125. package/dist/components/pagination.js.map +1 -0
  126. package/dist/components/panel-toolbar.d.ts +37 -0
  127. package/dist/components/panel-toolbar.js +781 -0
  128. package/dist/components/panel-toolbar.js.map +1 -0
  129. package/dist/components/pill-tabs.d.ts +33 -0
  130. package/dist/components/pill-tabs.js +66 -0
  131. package/dist/components/pill-tabs.js.map +1 -0
  132. package/dist/components/plugin-download-consent.d.ts +52 -0
  133. package/dist/components/plugin-download-consent.js +780 -0
  134. package/dist/components/plugin-download-consent.js.map +1 -0
  135. package/dist/components/popover.d.ts +26 -0
  136. package/dist/components/popover.js +343 -0
  137. package/dist/components/popover.js.map +1 -0
  138. package/dist/components/progress.d.ts +10 -0
  139. package/dist/components/progress.js +103 -0
  140. package/dist/components/progress.js.map +1 -0
  141. package/dist/components/query-state-view.d.ts +36 -0
  142. package/dist/components/query-state-view.js +17 -0
  143. package/dist/components/query-state-view.js.map +1 -0
  144. package/dist/components/radio-group.d.ts +8 -0
  145. package/dist/components/radio-group.js +60 -0
  146. package/dist/components/radio-group.js.map +1 -0
  147. package/dist/components/scroll-area.d.ts +7 -0
  148. package/dist/components/scroll-area.js +80 -0
  149. package/dist/components/scroll-area.js.map +1 -0
  150. package/dist/components/search-field.d.ts +49 -0
  151. package/dist/components/search-field.js +586 -0
  152. package/dist/components/search-field.js.map +1 -0
  153. package/dist/components/secondary-stat.d.ts +13 -0
  154. package/dist/components/secondary-stat.js +56 -0
  155. package/dist/components/secondary-stat.js.map +1 -0
  156. package/dist/components/section-header.d.ts +23 -0
  157. package/dist/components/section-header.js +76 -0
  158. package/dist/components/section-header.js.map +1 -0
  159. package/dist/components/section-label.d.ts +20 -0
  160. package/dist/components/section-label.js +46 -0
  161. package/dist/components/section-label.js.map +1 -0
  162. package/dist/components/section-tabs.d.ts +47 -0
  163. package/dist/components/section-tabs.js +145 -0
  164. package/dist/components/section-tabs.js.map +1 -0
  165. package/dist/components/separator.d.ts +14 -0
  166. package/dist/components/separator.js +49 -0
  167. package/dist/components/separator.js.map +1 -0
  168. package/dist/components/sheet.d.ts +22 -0
  169. package/dist/components/sheet.js +469 -0
  170. package/dist/components/sheet.js.map +1 -0
  171. package/dist/components/sidebar.d.ts +69 -0
  172. package/dist/components/sidebar.js +1645 -0
  173. package/dist/components/sidebar.js.map +1 -0
  174. package/dist/components/skeleton.d.ts +8 -0
  175. package/dist/components/skeleton.js +41 -0
  176. package/dist/components/skeleton.js.map +1 -0
  177. package/dist/components/slider.d.ts +6 -0
  178. package/dist/components/slider.js +75 -0
  179. package/dist/components/slider.js.map +1 -0
  180. package/dist/components/sortable-chip-list.d.ts +26 -0
  181. package/dist/components/sortable-chip-list.js +59 -0
  182. package/dist/components/sortable-chip-list.js.map +1 -0
  183. package/dist/components/spinner.d.ts +5 -0
  184. package/dist/components/spinner.js +65 -0
  185. package/dist/components/spinner.js.map +1 -0
  186. package/dist/components/surface.d.ts +13 -0
  187. package/dist/components/surface.js +51 -0
  188. package/dist/components/surface.js.map +1 -0
  189. package/dist/components/switch.d.ts +8 -0
  190. package/dist/components/switch.js +53 -0
  191. package/dist/components/switch.js.map +1 -0
  192. package/dist/components/table.d.ts +12 -0
  193. package/dist/components/table.js +129 -0
  194. package/dist/components/table.js.map +1 -0
  195. package/dist/components/textarea.d.ts +15 -0
  196. package/dist/components/textarea.js +58 -0
  197. package/dist/components/textarea.js.map +1 -0
  198. package/dist/components/toast.d.ts +19 -0
  199. package/dist/components/toast.js +805 -0
  200. package/dist/components/toast.js.map +1 -0
  201. package/dist/components/toggle.d.ts +12 -0
  202. package/dist/components/toggle.js +63 -0
  203. package/dist/components/toggle.js.map +1 -0
  204. package/dist/components/tooltip.d.ts +9 -0
  205. package/dist/components/tooltip.js +82 -0
  206. package/dist/components/tooltip.js.map +1 -0
  207. package/dist/hooks/use-dialog-footer-actions.d.ts +18 -0
  208. package/dist/hooks/use-dialog-footer-actions.js +11 -0
  209. package/dist/hooks/use-dialog-footer-actions.js.map +1 -0
  210. package/dist/hooks/use-form-field.d.ts +33 -0
  211. package/dist/hooks/use-form-field.js +36 -0
  212. package/dist/hooks/use-form-field.js.map +1 -0
  213. package/dist/hooks/use-mobile.d.ts +34 -0
  214. package/dist/hooks/use-mobile.js +29 -0
  215. package/dist/hooks/use-mobile.js.map +1 -0
  216. package/dist/hooks/use-pinch-pan.d.ts +56 -0
  217. package/dist/hooks/use-pinch-pan.js +223 -0
  218. package/dist/hooks/use-pinch-pan.js.map +1 -0
  219. package/dist/hooks/useMobileBackToClose.d.ts +61 -0
  220. package/dist/hooks/useMobileBackToClose.js +234 -0
  221. package/dist/hooks/useMobileBackToClose.js.map +1 -0
  222. package/dist/icon-BOgL5bpL.d.ts +85 -0
  223. package/dist/icons/actions.d.ts +4 -0
  224. package/dist/icons/actions.js +520 -0
  225. package/dist/icons/actions.js.map +1 -0
  226. package/dist/icons/icon-style.d.ts +2 -0
  227. package/dist/icons/icon-style.js +51 -0
  228. package/dist/icons/icon-style.js.map +1 -0
  229. package/dist/icons/marks.d.ts +15 -0
  230. package/dist/icons/marks.js +48 -0
  231. package/dist/icons/marks.js.map +1 -0
  232. package/dist/icons/registry.d.ts +177 -0
  233. package/dist/icons/registry.js +1180 -0
  234. package/dist/icons/registry.js.map +1 -0
  235. package/dist/index.d.ts +6 -0
  236. package/dist/index.js +653 -0
  237. package/dist/index.js.map +1 -0
  238. package/dist/lib/merge-config.d.ts +15 -0
  239. package/dist/lib/merge-config.js +20 -0
  240. package/dist/lib/merge-config.js.map +1 -0
  241. package/dist/lib/pagination.d.ts +19 -0
  242. package/dist/lib/pagination.js +26 -0
  243. package/dist/lib/pagination.js.map +1 -0
  244. package/dist/lib/utils.d.ts +5 -0
  245. package/dist/lib/utils.js +26 -0
  246. package/dist/lib/utils.js.map +1 -0
  247. package/dist/styles/theme.css +480 -0
  248. package/dist/styles/theme.css.map +1 -0
  249. package/dist/styles/theme.d.ts +2 -0
  250. package/dist/viewport.d.ts +64 -0
  251. package/dist/viewport.js +13 -0
  252. package/dist/viewport.js.map +1 -0
  253. package/package.json +122 -0
  254. package/src/components/alert.tsx +76 -0
  255. package/src/components/app-dialog.tsx +187 -0
  256. package/src/components/badge.tsx +52 -0
  257. package/src/components/breadcrumb.tsx +129 -0
  258. package/src/components/button.tsx +70 -0
  259. package/src/components/caption-bar.test.tsx +151 -0
  260. package/src/components/caption-bar.tsx +392 -0
  261. package/src/components/chart-card.tsx +31 -0
  262. package/src/components/checkbox.tsx +28 -0
  263. package/src/components/chip-button.tsx +69 -0
  264. package/src/components/close-confirm-dialog.tsx +99 -0
  265. package/src/components/color-picker.tsx +186 -0
  266. package/src/components/combobox.tsx +335 -0
  267. package/src/components/confirm-by-typing-dialog.tsx +130 -0
  268. package/src/components/confirm-dialog.test.tsx +39 -0
  269. package/src/components/confirm-dialog.tsx +98 -0
  270. package/src/components/count-badge.tsx +23 -0
  271. package/src/components/dialog.tsx +315 -0
  272. package/src/components/dropdown-menu.tsx +311 -0
  273. package/src/components/dropdown-select.tsx +211 -0
  274. package/src/components/empty.tsx +104 -0
  275. package/src/components/filter-rail.tsx +139 -0
  276. package/src/components/fixed-action-bar.tsx +46 -0
  277. package/src/components/font-picker.tsx +257 -0
  278. package/src/components/form.tsx +127 -0
  279. package/src/components/group-label.tsx +13 -0
  280. package/src/components/icon-tile.tsx +35 -0
  281. package/src/components/icon-toggle.tsx +57 -0
  282. package/src/components/icon.tsx +66 -0
  283. package/src/components/image-crop-panel.test.tsx +167 -0
  284. package/src/components/image-crop-panel.tsx +465 -0
  285. package/src/components/image-cropper.test.tsx +144 -0
  286. package/src/components/image-cropper.tsx +265 -0
  287. package/src/components/input-group.tsx +156 -0
  288. package/src/components/input.tsx +45 -0
  289. package/src/components/label.tsx +20 -0
  290. package/src/components/list-empty-row.tsx +27 -0
  291. package/src/components/management-empty.tsx +25 -0
  292. package/src/components/management-skeleton.tsx +23 -0
  293. package/src/components/meta-chip.tsx +40 -0
  294. package/src/components/mobile-drawer.test.tsx +52 -0
  295. package/src/components/mobile-drawer.tsx +98 -0
  296. package/src/components/page-header.tsx +47 -0
  297. package/src/components/page-scaffold.test.tsx +24 -0
  298. package/src/components/page-scaffold.tsx +131 -0
  299. package/src/components/pagination-bar.test.tsx +87 -0
  300. package/src/components/pagination-bar.tsx +139 -0
  301. package/src/components/pagination.tsx +139 -0
  302. package/src/components/panel-toolbar.tsx +99 -0
  303. package/src/components/pill-tabs.test.tsx +84 -0
  304. package/src/components/pill-tabs.tsx +77 -0
  305. package/src/components/plugin-download-consent.test.tsx +90 -0
  306. package/src/components/plugin-download-consent.tsx +183 -0
  307. package/src/components/popover.tsx +133 -0
  308. package/src/components/progress.tsx +88 -0
  309. package/src/components/query-state-view.tsx +47 -0
  310. package/src/components/radio-group.tsx +36 -0
  311. package/src/components/scroll-area.tsx +52 -0
  312. package/src/components/search-field.test.tsx +86 -0
  313. package/src/components/search-field.tsx +119 -0
  314. package/src/components/secondary-stat.tsx +24 -0
  315. package/src/components/section-header.tsx +54 -0
  316. package/src/components/section-label.tsx +40 -0
  317. package/src/components/section-tabs.test.tsx +154 -0
  318. package/src/components/section-tabs.tsx +190 -0
  319. package/src/components/separator.tsx +38 -0
  320. package/src/components/sheet.tsx +170 -0
  321. package/src/components/sidebar.tsx +725 -0
  322. package/src/components/skeleton.tsx +18 -0
  323. package/src/components/slider.tsx +52 -0
  324. package/src/components/sortable-chip-list.tsx +56 -0
  325. package/src/components/spinner.tsx +35 -0
  326. package/src/components/surface.tsx +37 -0
  327. package/src/components/switch.tsx +30 -0
  328. package/src/components/table.tsx +116 -0
  329. package/src/components/textarea.tsx +41 -0
  330. package/src/components/toast.tsx +239 -0
  331. package/src/components/toggle.tsx +45 -0
  332. package/src/components/tooltip.tsx +64 -0
  333. package/src/hooks/use-dialog-footer-actions.ts +23 -0
  334. package/src/hooks/use-form-field.ts +62 -0
  335. package/src/hooks/use-mobile.ts +54 -0
  336. package/src/hooks/use-pinch-pan.test.tsx +42 -0
  337. package/src/hooks/use-pinch-pan.ts +340 -0
  338. package/src/hooks/useMobileBackToClose.test.tsx +140 -0
  339. package/src/hooks/useMobileBackToClose.ts +508 -0
  340. package/src/icons/actions.ts +10 -0
  341. package/src/icons/icon-style.test.tsx +69 -0
  342. package/src/icons/icon-style.ts +104 -0
  343. package/src/icons/marks.tsx +72 -0
  344. package/src/icons/registry.test.ts +65 -0
  345. package/src/icons/registry.ts +1163 -0
  346. package/src/index.ts +22 -0
  347. package/src/lib/merge-config.test.ts +109 -0
  348. package/src/lib/merge-config.ts +22 -0
  349. package/src/lib/pagination.test.ts +55 -0
  350. package/src/lib/pagination.ts +42 -0
  351. package/src/lib/utils.ts +6 -0
  352. package/src/styles/scan-contract.test.ts +74 -0
  353. package/src/styles/theme.css +798 -0
  354. package/src/test/i18n.tsx +22 -0
  355. package/src/test/setup.ts +104 -0
  356. package/src/viewport.ts +69 -0
@@ -0,0 +1,151 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import userEvent from "@testing-library/user-event"
3
+ import { describe, expect, it, vi } from "vitest"
4
+ import {
5
+ CaptionBar,
6
+ type CaptionHistoryControls,
7
+ type CaptionWindowControls,
8
+ } from "./caption-bar"
9
+
10
+ function fakeControls(
11
+ overrides: Partial<CaptionWindowControls> = {},
12
+ ): CaptionWindowControls {
13
+ return {
14
+ minimize: vi.fn(),
15
+ toggleMaximize: vi.fn(),
16
+ close: vi.fn(),
17
+ isMaximized: async () => false,
18
+ onMaximizedChange: () => () => undefined,
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ function fakeHistory(
24
+ overrides: Partial<CaptionHistoryControls> = {},
25
+ ): CaptionHistoryControls {
26
+ return {
27
+ canGoBack: true,
28
+ canGoForward: true,
29
+ back: vi.fn(),
30
+ forward: vi.fn(),
31
+ reload: vi.fn(),
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ describe("CaptionBar", () => {
37
+ it("invokes window controls from the caption buttons", async () => {
38
+ const user = userEvent.setup()
39
+ const controls = fakeControls()
40
+ render(<CaptionBar controls={controls} history={fakeHistory()} />)
41
+
42
+ await user.click(screen.getByTestId("desktop-caption-minimize"))
43
+ await user.click(screen.getByTestId("desktop-caption-maximize"))
44
+ await user.click(screen.getByTestId("desktop-caption-close"))
45
+
46
+ expect(controls.minimize).toHaveBeenCalledTimes(1)
47
+ expect(controls.toggleMaximize).toHaveBeenCalledTimes(1)
48
+ expect(controls.close).toHaveBeenCalledTimes(1)
49
+ expect(screen.queryByText("hoardodile")).toBeNull()
50
+ })
51
+
52
+ it("hides the DevTools button when the control is absent", () => {
53
+ render(<CaptionBar controls={fakeControls()} history={fakeHistory()} />)
54
+ expect(screen.queryByTestId("desktop-caption-devtools")).toBeNull()
55
+ })
56
+
57
+ it("toggles DevTools from the caption button", async () => {
58
+ const user = userEvent.setup()
59
+ const toggleDevtools = vi.fn()
60
+ render(
61
+ <CaptionBar
62
+ controls={fakeControls({ toggleDevtools })}
63
+ history={fakeHistory()}
64
+ />,
65
+ )
66
+ await user.click(screen.getByTestId("desktop-caption-devtools"))
67
+ expect(toggleDevtools).toHaveBeenCalledTimes(1)
68
+ })
69
+
70
+ it("does not double-toggle on double-click of the drag region (native handles it)", async () => {
71
+ const user = userEvent.setup()
72
+ const controls = fakeControls()
73
+ render(<CaptionBar controls={controls} history={fakeHistory()} />)
74
+
75
+ await user.dblClick(screen.getByTestId("desktop-caption-drag"))
76
+ expect(controls.toggleMaximize).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it("keeps reload a plain browser reload (never routed through the desktop bridge)", async () => {
80
+ const user = userEvent.setup()
81
+ const retryLoad = vi.fn()
82
+ Reflect.set(window, "hoardodileDesktop", { retryLoad })
83
+ try {
84
+ render(<CaptionBar controls={fakeControls()} />)
85
+ await user.click(screen.getByTestId("desktop-caption-reload"))
86
+ expect(retryLoad).not.toHaveBeenCalled()
87
+ } finally {
88
+ Reflect.deleteProperty(window, "hoardodileDesktop")
89
+ }
90
+ })
91
+
92
+ it("blurs a caption button focused during load so no stray ring persists", async () => {
93
+ const { unmount } = render(
94
+ <CaptionBar controls={fakeControls()} history={fakeHistory()} />,
95
+ )
96
+ const back = screen.getByTestId("desktop-caption-back")
97
+ back.focus()
98
+ expect(document.activeElement).toBe(back)
99
+ window.dispatchEvent(new Event("load"))
100
+ expect(document.activeElement).not.toBe(back)
101
+ unmount()
102
+ })
103
+
104
+ it("invokes history controls from the navigation buttons", async () => {
105
+ const user = userEvent.setup()
106
+ const history = fakeHistory()
107
+ render(<CaptionBar controls={fakeControls()} history={history} />)
108
+
109
+ await user.click(screen.getByTestId("desktop-caption-back"))
110
+ await user.click(screen.getByTestId("desktop-caption-forward"))
111
+ await user.click(screen.getByTestId("desktop-caption-reload"))
112
+
113
+ expect(history.back).toHaveBeenCalledTimes(1)
114
+ expect(history.forward).toHaveBeenCalledTimes(1)
115
+ expect(history.reload).toHaveBeenCalledTimes(1)
116
+ })
117
+
118
+ it("disables back and forward when the stack cannot move", () => {
119
+ render(
120
+ <CaptionBar
121
+ controls={fakeControls()}
122
+ history={fakeHistory({ canGoBack: false, canGoForward: false })}
123
+ />,
124
+ )
125
+
126
+ expect(screen.getByTestId("desktop-caption-back")).toBeDisabled()
127
+ expect(screen.getByTestId("desktop-caption-forward")).toBeDisabled()
128
+ expect(screen.getByTestId("desktop-caption-reload")).toBeEnabled()
129
+ })
130
+
131
+ it("renders the leading slot leftmost, before the history buttons", () => {
132
+ render(
133
+ <CaptionBar
134
+ controls={fakeControls()}
135
+ history={fakeHistory()}
136
+ leading={
137
+ <button type="button" data-testid="desktop-caption-leading">
138
+ menu
139
+ </button>
140
+ }
141
+ />,
142
+ )
143
+
144
+ const leading = screen.getByTestId("desktop-caption-leading")
145
+ const back = screen.getByTestId("desktop-caption-back")
146
+ expect(screen.getByTestId("desktop-caption-bar")).toContainElement(leading)
147
+ expect(
148
+ leading.compareDocumentPosition(back) & Node.DOCUMENT_POSITION_FOLLOWING,
149
+ ).toBeTruthy()
150
+ })
151
+ })
@@ -0,0 +1,392 @@
1
+ "use client"
2
+
3
+ import { Icon } from "@hoardodile/ui/components/icon"
4
+ import { Refresh } from "@hoardodile/ui/icons/registry"
5
+ import { cn } from "@hoardodile/ui/lib/utils"
6
+ import { type ReactNode, useEffect, useState } from "react"
7
+ import { useTranslation } from "react-i18next"
8
+
9
+ export type CaptionWindowControls = {
10
+ minimize: () => void
11
+ toggleMaximize: () => void
12
+ close: () => void
13
+ /**
14
+ * Toggle the DevTools dock. Present only on dev (unpackaged) runs —
15
+ * the desktop bridge carries it only there, so packaged builds never
16
+ * render the button.
17
+ */
18
+ toggleDevtools?: () => void
19
+ isMaximized: () => Promise<boolean>
20
+ onMaximizedChange: (listener: (maximized: boolean) => void) => () => void
21
+ }
22
+
23
+ const captionChromeButtonClassName =
24
+ "flex h-nav w-[46px] items-center justify-center text-secondary-foreground enabled:hover:bg-muted enabled:hover:text-foreground disabled:text-muted-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
25
+
26
+ export type CaptionHistoryControls = {
27
+ readonly canGoBack: boolean
28
+ readonly canGoForward: boolean
29
+ back: () => void
30
+ forward: () => void
31
+ reload: () => void
32
+ }
33
+
34
+ export type CaptionBarProps = {
35
+ readonly controls: CaptionWindowControls
36
+ /** Test seam; production uses the window history stack. */
37
+ readonly history?: CaptionHistoryControls
38
+ /**
39
+ * Leftmost slot in the strip's no-drag group, before back / forward /
40
+ * reload. Used by the SPA to host the global sidebar toggle below the
41
+ * sidebar breakpoint; the wizard leaves it empty.
42
+ */
43
+ readonly leading?: ReactNode
44
+ readonly className?: string
45
+ }
46
+
47
+ /**
48
+ * Frameless-window caption strip (DESIGN.md: 38px `h-nav`). An optional
49
+ * `leading` control, then back / forward / reload on the left; drag region
50
+ * in the middle; Windows caption buttons on the right. Shared by the
51
+ * first-run wizard and the SPA so the two pages cannot drift.
52
+ */
53
+ function CaptionBar({
54
+ controls,
55
+ history,
56
+ leading,
57
+ className,
58
+ }: CaptionBarProps) {
59
+ const { t } = useTranslation("ui", { useSuspense: false })
60
+ const windowHistory = useWindowHistoryControls()
61
+ const nav = history ?? windowHistory
62
+ const [maximized, setMaximized] = useState(false)
63
+
64
+ useEffect(() => {
65
+ let cancelled = false
66
+ void controls.isMaximized().then((value) => {
67
+ if (!cancelled) setMaximized(value)
68
+ })
69
+ const unsubscribe = controls.onMaximizedChange(setMaximized)
70
+ return () => {
71
+ cancelled = true
72
+ unsubscribe()
73
+ }
74
+ }, [controls])
75
+
76
+ // Chromium may restore focus to the last-focused caption button when
77
+ // the window or session comes back, leaving a stray focus ring on the
78
+ // chrome. Clear focus only when it sits on a caption button — never
79
+ // blur an input the user is typing into. Runs at mount (restored focus
80
+ // may already be present) and again on load (restored afterwards).
81
+ useEffect(() => {
82
+ function clearChromeFocus() {
83
+ const el = document.activeElement
84
+ if (
85
+ el instanceof HTMLElement &&
86
+ el.closest('[data-testid="desktop-caption-bar"]') !== null
87
+ ) {
88
+ el.blur()
89
+ }
90
+ }
91
+ clearChromeFocus()
92
+ window.addEventListener("load", clearChromeFocus)
93
+ return () => {
94
+ window.removeEventListener("load", clearChromeFocus)
95
+ }
96
+ }, [])
97
+
98
+ return (
99
+ <div
100
+ data-testid="desktop-caption-bar"
101
+ className={cn(
102
+ "flex h-nav shrink-0 select-none items-stretch bg-background text-foreground",
103
+ className
104
+ )}
105
+ >
106
+ <div className="flex shrink-0 [-webkit-app-region:no-drag]">
107
+ {leading}
108
+ <button
109
+ type="button"
110
+ title={t("caption.back")}
111
+ aria-label={t("caption.back")}
112
+ data-testid="desktop-caption-back"
113
+ disabled={!nav.canGoBack}
114
+ className={captionChromeButtonClassName}
115
+ onClick={() => {
116
+ nav.back()
117
+ }}
118
+ >
119
+ <ChevronLeftGlyph />
120
+ </button>
121
+ <button
122
+ type="button"
123
+ title={t("caption.forward")}
124
+ aria-label={t("caption.forward")}
125
+ data-testid="desktop-caption-forward"
126
+ disabled={!nav.canGoForward}
127
+ className={captionChromeButtonClassName}
128
+ onClick={() => {
129
+ nav.forward()
130
+ }}
131
+ >
132
+ <ChevronRightGlyph />
133
+ </button>
134
+ <button
135
+ type="button"
136
+ title={t("caption.reload")}
137
+ aria-label={t("caption.reload")}
138
+ data-testid="desktop-caption-reload"
139
+ className={captionChromeButtonClassName}
140
+ onClick={() => {
141
+ nav.reload()
142
+ }}
143
+ >
144
+ <Icon icon={Refresh} />
145
+ </button>
146
+ </div>
147
+ <div
148
+ data-testid="desktop-caption-drag"
149
+ className="min-w-0 flex-1 [-webkit-app-region:drag]"
150
+ />
151
+ {/* Double-click toggles maximize natively on Windows drag regions;
152
+ a JS handler here would double-toggle (native + JS). */}
153
+ <div className="flex shrink-0 [-webkit-app-region:no-drag]">
154
+ {controls.toggleDevtools !== undefined ? (
155
+ <button
156
+ type="button"
157
+ title={t("caption.devtools")}
158
+ aria-label={t("caption.devtools")}
159
+ data-testid="desktop-caption-devtools"
160
+ className={captionChromeButtonClassName}
161
+ onClick={() => {
162
+ controls.toggleDevtools?.()
163
+ }}
164
+ >
165
+ <DevToolsGlyph />
166
+ </button>
167
+ ) : null}
168
+ <button
169
+ type="button"
170
+ aria-label={t("caption.minimize")}
171
+ data-testid="desktop-caption-minimize"
172
+ className={captionChromeButtonClassName}
173
+ onClick={() => {
174
+ controls.minimize()
175
+ }}
176
+ >
177
+ <MinimizeGlyph />
178
+ </button>
179
+ <button
180
+ type="button"
181
+ aria-label={maximized ? t("caption.restore") : t("caption.maximize")}
182
+ data-testid="desktop-caption-maximize"
183
+ className={captionChromeButtonClassName}
184
+ onClick={() => {
185
+ controls.toggleMaximize()
186
+ }}
187
+ >
188
+ {maximized ? <RestoreGlyph /> : <MaximizeGlyph />}
189
+ </button>
190
+ <button
191
+ type="button"
192
+ aria-label={t("caption.close")}
193
+ data-testid="desktop-caption-close"
194
+ className="flex h-nav w-[46px] items-center justify-center text-secondary-foreground hover:bg-[#c42b1c] hover:text-white"
195
+ onClick={() => {
196
+ controls.close()
197
+ }}
198
+ >
199
+ <CloseGlyph />
200
+ </button>
201
+ </div>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ type WindowNavigationSource = {
207
+ readonly canGoBack: boolean
208
+ readonly canGoForward: boolean
209
+ addEventListener: (type: string, listener: () => void) => void
210
+ removeEventListener: (type: string, listener: () => void) => void
211
+ }
212
+
213
+ function isWindowNavigationSource(
214
+ value: unknown,
215
+ ): value is WindowNavigationSource {
216
+ if (typeof value !== "object" || value === null) return false
217
+ if (!("canGoBack" in value) || typeof value.canGoBack !== "boolean") {
218
+ return false
219
+ }
220
+ if (!("canGoForward" in value) || typeof value.canGoForward !== "boolean") {
221
+ return false
222
+ }
223
+ if (
224
+ !("addEventListener" in value) ||
225
+ typeof value.addEventListener !== "function"
226
+ ) {
227
+ return false
228
+ }
229
+ if (
230
+ !("removeEventListener" in value) ||
231
+ typeof value.removeEventListener !== "function"
232
+ ) {
233
+ return false
234
+ }
235
+ return true
236
+ }
237
+
238
+ function readWindowNavigation(): WindowNavigationSource | undefined {
239
+ if (typeof window === "undefined") return undefined
240
+ const value = Reflect.get(window, "navigation")
241
+ return isWindowNavigationSource(value) ? value : undefined
242
+ }
243
+
244
+ function useWindowHistoryControls(): CaptionHistoryControls {
245
+ const [canGoBack, setCanGoBack] = useState(false)
246
+ const [canGoForward, setCanGoForward] = useState(false)
247
+
248
+ useEffect(() => {
249
+ function sync() {
250
+ const source = readWindowNavigation()
251
+ if (source !== undefined) {
252
+ setCanGoBack(source.canGoBack)
253
+ setCanGoForward(source.canGoForward)
254
+ return
255
+ }
256
+ setCanGoBack(window.history.length > 1)
257
+ setCanGoForward(false)
258
+ }
259
+
260
+ sync()
261
+ const source = readWindowNavigation()
262
+ if (source !== undefined) {
263
+ source.addEventListener("currententrychange", sync)
264
+ return () => {
265
+ source.removeEventListener("currententrychange", sync)
266
+ }
267
+ }
268
+ window.addEventListener("popstate", sync)
269
+ return () => {
270
+ window.removeEventListener("popstate", sync)
271
+ }
272
+ }, [])
273
+
274
+ // Plain browser reload. The app's own first frame (index.html splash)
275
+ // covers the pre-paint gap, so routing this through the desktop shell
276
+ // would only add unnecessary hops.
277
+ function reload() {
278
+ window.location.reload()
279
+ }
280
+
281
+ return {
282
+ canGoBack,
283
+ canGoForward,
284
+ back() {
285
+ window.history.back()
286
+ },
287
+ forward() {
288
+ window.history.forward()
289
+ },
290
+ reload,
291
+ }
292
+ }
293
+
294
+ function ChevronLeftGlyph() {
295
+ return (
296
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
297
+ <path
298
+ d="M10 3.5L5 8l5 4.5"
299
+ fill="none"
300
+ stroke="currentColor"
301
+ strokeWidth="1.5"
302
+ />
303
+ </svg>
304
+ )
305
+ }
306
+
307
+ function ChevronRightGlyph() {
308
+ return (
309
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
310
+ <path
311
+ d="M6 3.5L11 8l-5 4.5"
312
+ fill="none"
313
+ stroke="currentColor"
314
+ strokeWidth="1.5"
315
+ />
316
+ </svg>
317
+ )
318
+ }
319
+
320
+ function MinimizeGlyph() {
321
+ return (
322
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
323
+ <path d="M0 5h10" fill="none" stroke="currentColor" strokeWidth="1" />
324
+ </svg>
325
+ )
326
+ }
327
+
328
+ function DevToolsGlyph() {
329
+ return (
330
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
331
+ <path
332
+ d="M4.5 4.5L2 7l2.5 2.5M9.5 4.5L12 7l-2.5 2.5"
333
+ fill="none"
334
+ stroke="currentColor"
335
+ strokeWidth="1"
336
+ />
337
+ </svg>
338
+ )
339
+ }
340
+
341
+ function MaximizeGlyph() {
342
+ return (
343
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
344
+ <rect
345
+ x="0.5"
346
+ y="0.5"
347
+ width="9"
348
+ height="9"
349
+ fill="none"
350
+ stroke="currentColor"
351
+ strokeWidth="1"
352
+ />
353
+ </svg>
354
+ )
355
+ }
356
+
357
+ function RestoreGlyph() {
358
+ return (
359
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
360
+ <rect
361
+ x="2.5"
362
+ y="0.5"
363
+ width="7"
364
+ height="7"
365
+ fill="none"
366
+ stroke="currentColor"
367
+ strokeWidth="1"
368
+ />
369
+ <path
370
+ d="M0.5 2.5h7v7h-7z"
371
+ fill="none"
372
+ stroke="currentColor"
373
+ strokeWidth="1"
374
+ />
375
+ </svg>
376
+ )
377
+ }
378
+
379
+ function CloseGlyph() {
380
+ return (
381
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
382
+ <path
383
+ d="M1 1l8 8M9 1L1 9"
384
+ fill="none"
385
+ stroke="currentColor"
386
+ strokeWidth="1"
387
+ />
388
+ </svg>
389
+ )
390
+ }
391
+
392
+ export { CaptionBar }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ /**
4
+ * Chart card — the stats page's standard chart surface: a floating card
5
+ * with the chart's name and a muted trailing note, the chart below.
6
+ */
7
+ export function ChartCard({
8
+ title,
9
+ subtitle,
10
+ children,
11
+ className,
12
+ }: {
13
+ readonly title: string
14
+ readonly subtitle: string
15
+ readonly children: ReactNode
16
+ readonly className?: string
17
+ }) {
18
+ return (
19
+ <div
20
+ className={`rounded-xl border border-border bg-card p-5 shadow-card ${className ?? ""}`}
21
+ >
22
+ <div className="flex items-baseline justify-between gap-3">
23
+ <span className="text-ui font-semibold text-foreground">{title}</span>
24
+ <span className="shrink-0 text-tiny text-muted-foreground">
25
+ {subtitle}
26
+ </span>
27
+ </div>
28
+ <div className="mt-4">{children}</div>
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,28 @@
1
+ "use client"
2
+
3
+ import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
4
+
5
+ import { cn } from "@hoardodile/ui/lib/utils"
6
+ import { Check } from "@hoardodile/ui/icons/marks"
7
+
8
+ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
9
+ return (
10
+ <CheckboxPrimitive.Root
11
+ data-slot="checkbox"
12
+ className={cn(
13
+ "peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm bg-muted outline-none group-has-disabled/field:opacity-50 focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:bg-destructive/20 aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-checked:bg-primary data-checked:text-primary-foreground",
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ <CheckboxPrimitive.Indicator
19
+ data-slot="checkbox-indicator"
20
+ className="grid place-content-center text-current [&>svg]:size-3"
21
+ >
22
+ <Check />
23
+ </CheckboxPrimitive.Indicator>
24
+ </CheckboxPrimitive.Root>
25
+ )
26
+ }
27
+
28
+ export { Checkbox }
@@ -0,0 +1,69 @@
1
+ import type { IconType } from "@hoardodile/ui/components/icon"
2
+ import { Icon } from "@hoardodile/ui/components/icon"
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+ import type { ReactNode } from "react"
5
+
6
+ export type ChipButtonProps = {
7
+ readonly icon?: IconType
8
+ /** Latched-toggle state — accent fill, no hover shift. */
9
+ readonly active?: boolean
10
+ /** The dashed outline (the Unused triage filter) instead of the quiet
11
+ text idle state. */
12
+ readonly dashed?: boolean
13
+ readonly disabled?: boolean
14
+ readonly children: ReactNode
15
+ readonly className?: string
16
+ readonly onClick?: () => void
17
+ readonly testId?: string
18
+ }
19
+
20
+ /**
21
+ * Chip button: the toolbar's compact toggle, h-chip height. Two kinds:
22
+ * the quiet toggle (Reorder) wears medium weight in both states so its
23
+ * width never moves; the dashed filter (Unused) is an indicator, not a
24
+ * toggle, so it stays regular weight with a dashed outline. `active`
25
+ * latches the accent fill on both; the dashed outline keeps a transparent
26
+ * border when active so the width never shifts.
27
+ */
28
+ export function ChipButton(props: ChipButtonProps) {
29
+ const {
30
+ icon,
31
+ active,
32
+ dashed,
33
+ disabled,
34
+ children,
35
+ className,
36
+ onClick,
37
+ testId,
38
+ } = props
39
+ return (
40
+ <button
41
+ type="button"
42
+ onClick={disabled ? undefined : onClick}
43
+ data-testid={testId}
44
+ disabled={disabled}
45
+ className={cn(
46
+ "inline-flex h-chip shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs whitespace-nowrap",
47
+ !dashed && "font-medium",
48
+ disabled ? "cursor-default" : "cursor-pointer",
49
+ active && "bg-accent text-foreground",
50
+ // The dashed outline keeps a 1px transparent border when
51
+ // active so the width never moves with the state.
52
+ active && dashed && "border border-transparent",
53
+ !active && disabled && "text-muted-foreground",
54
+ !active &&
55
+ !disabled &&
56
+ dashed &&
57
+ "border border-dashed border-border-strong bg-card text-muted-foreground hover:text-secondary-foreground",
58
+ !active &&
59
+ !disabled &&
60
+ !dashed &&
61
+ "text-secondary-foreground hover:text-foreground",
62
+ className,
63
+ )}
64
+ >
65
+ {icon !== undefined ? <Icon icon={icon} /> : null}
66
+ {children}
67
+ </button>
68
+ )
69
+ }