@vaam-apps/ui 0.1.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 (285) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +286 -0
  4. package/dist/components/data/code.d.ts +37 -0
  5. package/dist/components/data/code.d.ts.map +1 -0
  6. package/dist/components/data/code.js +37 -0
  7. package/dist/components/data/code.js.map +1 -0
  8. package/dist/components/data/copy-button.d.ts +39 -0
  9. package/dist/components/data/copy-button.d.ts.map +1 -0
  10. package/dist/components/data/copy-button.js +50 -0
  11. package/dist/components/data/copy-button.js.map +1 -0
  12. package/dist/components/data/detail-row.d.ts +69 -0
  13. package/dist/components/data/detail-row.d.ts.map +1 -0
  14. package/dist/components/data/detail-row.js +27 -0
  15. package/dist/components/data/detail-row.js.map +1 -0
  16. package/dist/components/data/id-display.d.ts +33 -0
  17. package/dist/components/data/id-display.d.ts.map +1 -0
  18. package/dist/components/data/id-display.js +25 -0
  19. package/dist/components/data/id-display.js.map +1 -0
  20. package/dist/components/data/masked-value.d.ts +47 -0
  21. package/dist/components/data/masked-value.d.ts.map +1 -0
  22. package/dist/components/data/masked-value.js +46 -0
  23. package/dist/components/data/masked-value.js.map +1 -0
  24. package/dist/components/data/money.d.ts +28 -0
  25. package/dist/components/data/money.d.ts.map +1 -0
  26. package/dist/components/data/money.js +22 -0
  27. package/dist/components/data/money.js.map +1 -0
  28. package/dist/components/data/phone-display.d.ts +45 -0
  29. package/dist/components/data/phone-display.d.ts.map +1 -0
  30. package/dist/components/data/phone-display.js +23 -0
  31. package/dist/components/data/phone-display.js.map +1 -0
  32. package/dist/components/data/stat-tile.d.ts +33 -0
  33. package/dist/components/data/stat-tile.d.ts.map +1 -0
  34. package/dist/components/data/stat-tile.js +18 -0
  35. package/dist/components/data/stat-tile.js.map +1 -0
  36. package/dist/components/data/timestamp-display.d.ts +14 -0
  37. package/dist/components/data/timestamp-display.d.ts.map +1 -0
  38. package/dist/components/data/timestamp-display.js +77 -0
  39. package/dist/components/data/timestamp-display.js.map +1 -0
  40. package/dist/components/patterns/inline-banner.d.ts +22 -0
  41. package/dist/components/patterns/inline-banner.d.ts.map +1 -0
  42. package/dist/components/patterns/inline-banner.js +11 -0
  43. package/dist/components/patterns/inline-banner.js.map +1 -0
  44. package/dist/components/patterns/inline-empty-state.d.ts +20 -0
  45. package/dist/components/patterns/inline-empty-state.d.ts.map +1 -0
  46. package/dist/components/patterns/inline-empty-state.js +11 -0
  47. package/dist/components/patterns/inline-empty-state.js.map +1 -0
  48. package/dist/components/patterns/live-row.d.ts +23 -0
  49. package/dist/components/patterns/live-row.d.ts.map +1 -0
  50. package/dist/components/patterns/live-row.js +48 -0
  51. package/dist/components/patterns/live-row.js.map +1 -0
  52. package/dist/components/patterns/payload-inspector.d.ts +25 -0
  53. package/dist/components/patterns/payload-inspector.d.ts.map +1 -0
  54. package/dist/components/patterns/payload-inspector.js +33 -0
  55. package/dist/components/patterns/payload-inspector.js.map +1 -0
  56. package/dist/components/patterns/route-skeleton.d.ts +15 -0
  57. package/dist/components/patterns/route-skeleton.d.ts.map +1 -0
  58. package/dist/components/patterns/route-skeleton.js +7 -0
  59. package/dist/components/patterns/route-skeleton.js.map +1 -0
  60. package/dist/components/patterns/screen-layout.d.ts +14 -0
  61. package/dist/components/patterns/screen-layout.d.ts.map +1 -0
  62. package/dist/components/patterns/screen-layout.js +11 -0
  63. package/dist/components/patterns/screen-layout.js.map +1 -0
  64. package/dist/components/patterns/stale-write-banner.d.ts +26 -0
  65. package/dist/components/patterns/stale-write-banner.d.ts.map +1 -0
  66. package/dist/components/patterns/stale-write-banner.js +28 -0
  67. package/dist/components/patterns/stale-write-banner.js.map +1 -0
  68. package/dist/components/patterns/state-timeline.d.ts +58 -0
  69. package/dist/components/patterns/state-timeline.d.ts.map +1 -0
  70. package/dist/components/patterns/state-timeline.js +103 -0
  71. package/dist/components/patterns/state-timeline.js.map +1 -0
  72. package/dist/components/primitives/badge.d.ts +27 -0
  73. package/dist/components/primitives/badge.d.ts.map +1 -0
  74. package/dist/components/primitives/badge.js +33 -0
  75. package/dist/components/primitives/badge.js.map +1 -0
  76. package/dist/components/primitives/button.d.ts +12 -0
  77. package/dist/components/primitives/button.d.ts.map +1 -0
  78. package/dist/components/primitives/button.js +42 -0
  79. package/dist/components/primitives/button.js.map +1 -0
  80. package/dist/components/primitives/calendar.d.ts +39 -0
  81. package/dist/components/primitives/calendar.d.ts.map +1 -0
  82. package/dist/components/primitives/calendar.js +102 -0
  83. package/dist/components/primitives/calendar.js.map +1 -0
  84. package/dist/components/primitives/card.d.ts +24 -0
  85. package/dist/components/primitives/card.d.ts.map +1 -0
  86. package/dist/components/primitives/card.js +28 -0
  87. package/dist/components/primitives/card.js.map +1 -0
  88. package/dist/components/primitives/checkbox.d.ts +42 -0
  89. package/dist/components/primitives/checkbox.d.ts.map +1 -0
  90. package/dist/components/primitives/checkbox.js +31 -0
  91. package/dist/components/primitives/checkbox.js.map +1 -0
  92. package/dist/components/primitives/chip-select.d.ts +38 -0
  93. package/dist/components/primitives/chip-select.d.ts.map +1 -0
  94. package/dist/components/primitives/chip-select.js +25 -0
  95. package/dist/components/primitives/chip-select.js.map +1 -0
  96. package/dist/components/primitives/command-menu.d.ts +54 -0
  97. package/dist/components/primitives/command-menu.d.ts.map +1 -0
  98. package/dist/components/primitives/command-menu.js +24 -0
  99. package/dist/components/primitives/command-menu.js.map +1 -0
  100. package/dist/components/primitives/confirm-dialog.d.ts +52 -0
  101. package/dist/components/primitives/confirm-dialog.d.ts.map +1 -0
  102. package/dist/components/primitives/confirm-dialog.js +30 -0
  103. package/dist/components/primitives/confirm-dialog.js.map +1 -0
  104. package/dist/components/primitives/date-picker.d.ts +68 -0
  105. package/dist/components/primitives/date-picker.d.ts.map +1 -0
  106. package/dist/components/primitives/date-picker.js +107 -0
  107. package/dist/components/primitives/date-picker.js.map +1 -0
  108. package/dist/components/primitives/dialog.d.ts +50 -0
  109. package/dist/components/primitives/dialog.d.ts.map +1 -0
  110. package/dist/components/primitives/dialog.js +90 -0
  111. package/dist/components/primitives/dialog.js.map +1 -0
  112. package/dist/components/primitives/drawer.d.ts +71 -0
  113. package/dist/components/primitives/drawer.d.ts.map +1 -0
  114. package/dist/components/primitives/drawer.js +93 -0
  115. package/dist/components/primitives/drawer.js.map +1 -0
  116. package/dist/components/primitives/dropdown-menu.d.ts +40 -0
  117. package/dist/components/primitives/dropdown-menu.d.ts.map +1 -0
  118. package/dist/components/primitives/dropdown-menu.js +53 -0
  119. package/dist/components/primitives/dropdown-menu.js.map +1 -0
  120. package/dist/components/primitives/form-field.d.ts +94 -0
  121. package/dist/components/primitives/form-field.d.ts.map +1 -0
  122. package/dist/components/primitives/form-field.js +27 -0
  123. package/dist/components/primitives/form-field.js.map +1 -0
  124. package/dist/components/primitives/inline-confirm.d.ts +62 -0
  125. package/dist/components/primitives/inline-confirm.d.ts.map +1 -0
  126. package/dist/components/primitives/inline-confirm.js +11 -0
  127. package/dist/components/primitives/inline-confirm.js.map +1 -0
  128. package/dist/components/primitives/input.d.ts +4 -0
  129. package/dist/components/primitives/input.d.ts.map +1 -0
  130. package/dist/components/primitives/input.js +13 -0
  131. package/dist/components/primitives/input.js.map +1 -0
  132. package/dist/components/primitives/label.d.ts +26 -0
  133. package/dist/components/primitives/label.d.ts.map +1 -0
  134. package/dist/components/primitives/label.js +33 -0
  135. package/dist/components/primitives/label.js.map +1 -0
  136. package/dist/components/primitives/pagination.d.ts +54 -0
  137. package/dist/components/primitives/pagination.d.ts.map +1 -0
  138. package/dist/components/primitives/pagination.js +31 -0
  139. package/dist/components/primitives/pagination.js.map +1 -0
  140. package/dist/components/primitives/popover.d.ts +12 -0
  141. package/dist/components/primitives/popover.d.ts.map +1 -0
  142. package/dist/components/primitives/popover.js +22 -0
  143. package/dist/components/primitives/popover.js.map +1 -0
  144. package/dist/components/primitives/progress.d.ts +25 -0
  145. package/dist/components/primitives/progress.d.ts.map +1 -0
  146. package/dist/components/primitives/progress.js +25 -0
  147. package/dist/components/primitives/progress.js.map +1 -0
  148. package/dist/components/primitives/radio-group.d.ts +47 -0
  149. package/dist/components/primitives/radio-group.d.ts.map +1 -0
  150. package/dist/components/primitives/radio-group.js +11 -0
  151. package/dist/components/primitives/radio-group.js.map +1 -0
  152. package/dist/components/primitives/select.d.ts +39 -0
  153. package/dist/components/primitives/select.d.ts.map +1 -0
  154. package/dist/components/primitives/select.js +142 -0
  155. package/dist/components/primitives/select.js.map +1 -0
  156. package/dist/components/primitives/separator.d.ts +9 -0
  157. package/dist/components/primitives/separator.d.ts.map +1 -0
  158. package/dist/components/primitives/separator.js +16 -0
  159. package/dist/components/primitives/separator.js.map +1 -0
  160. package/dist/components/primitives/side-nav.d.ts +65 -0
  161. package/dist/components/primitives/side-nav.d.ts.map +1 -0
  162. package/dist/components/primitives/side-nav.js +100 -0
  163. package/dist/components/primitives/side-nav.js.map +1 -0
  164. package/dist/components/primitives/skeleton.d.ts +22 -0
  165. package/dist/components/primitives/skeleton.d.ts.map +1 -0
  166. package/dist/components/primitives/skeleton.js +25 -0
  167. package/dist/components/primitives/skeleton.js.map +1 -0
  168. package/dist/components/primitives/spinner.d.ts +20 -0
  169. package/dist/components/primitives/spinner.d.ts.map +1 -0
  170. package/dist/components/primitives/spinner.js +21 -0
  171. package/dist/components/primitives/spinner.js.map +1 -0
  172. package/dist/components/primitives/switch.d.ts +29 -0
  173. package/dist/components/primitives/switch.d.ts.map +1 -0
  174. package/dist/components/primitives/switch.js +24 -0
  175. package/dist/components/primitives/switch.js.map +1 -0
  176. package/dist/components/primitives/table.d.ts +23 -0
  177. package/dist/components/primitives/table.d.ts.map +1 -0
  178. package/dist/components/primitives/table.js +81 -0
  179. package/dist/components/primitives/table.js.map +1 -0
  180. package/dist/components/primitives/tabs.d.ts +38 -0
  181. package/dist/components/primitives/tabs.d.ts.map +1 -0
  182. package/dist/components/primitives/tabs.js +84 -0
  183. package/dist/components/primitives/tabs.js.map +1 -0
  184. package/dist/components/primitives/textarea.d.ts +4 -0
  185. package/dist/components/primitives/textarea.d.ts.map +1 -0
  186. package/dist/components/primitives/textarea.js +11 -0
  187. package/dist/components/primitives/textarea.js.map +1 -0
  188. package/dist/components/primitives/toast.d.ts +24 -0
  189. package/dist/components/primitives/toast.d.ts.map +1 -0
  190. package/dist/components/primitives/toast.js +58 -0
  191. package/dist/components/primitives/toast.js.map +1 -0
  192. package/dist/components/primitives/tooltip.d.ts +30 -0
  193. package/dist/components/primitives/tooltip.d.ts.map +1 -0
  194. package/dist/components/primitives/tooltip.js +6 -0
  195. package/dist/components/primitives/tooltip.js.map +1 -0
  196. package/dist/components/status/state-chip.d.ts +53 -0
  197. package/dist/components/status/state-chip.d.ts.map +1 -0
  198. package/dist/components/status/state-chip.js +8 -0
  199. package/dist/components/status/state-chip.js.map +1 -0
  200. package/dist/components/status/state-mark.d.ts +21 -0
  201. package/dist/components/status/state-mark.d.ts.map +1 -0
  202. package/dist/components/status/state-mark.js +90 -0
  203. package/dist/components/status/state-mark.js.map +1 -0
  204. package/dist/components/status/status-pill.d.ts +62 -0
  205. package/dist/components/status/status-pill.d.ts.map +1 -0
  206. package/dist/components/status/status-pill.js +65 -0
  207. package/dist/components/status/status-pill.js.map +1 -0
  208. package/dist/components/status/status-tokens.d.ts +110 -0
  209. package/dist/components/status/status-tokens.d.ts.map +1 -0
  210. package/dist/components/status/status-tokens.js +102 -0
  211. package/dist/components/status/status-tokens.js.map +1 -0
  212. package/dist/index.d.ts +76 -0
  213. package/dist/index.d.ts.map +1 -0
  214. package/dist/index.js +95 -0
  215. package/dist/index.js.map +1 -0
  216. package/dist/lib/cn.d.ts +11 -0
  217. package/dist/lib/cn.d.ts.map +1 -0
  218. package/dist/lib/cn.js +78 -0
  219. package/dist/lib/cn.js.map +1 -0
  220. package/dist/lib/money.d.ts +73 -0
  221. package/dist/lib/money.d.ts.map +1 -0
  222. package/dist/lib/money.js +123 -0
  223. package/dist/lib/money.js.map +1 -0
  224. package/dist/lib/omit-undefined.d.ts +17 -0
  225. package/dist/lib/omit-undefined.d.ts.map +1 -0
  226. package/dist/lib/omit-undefined.js +24 -0
  227. package/dist/lib/omit-undefined.js.map +1 -0
  228. package/package.json +100 -0
  229. package/src/components/data/code.tsx +43 -0
  230. package/src/components/data/copy-button.tsx +97 -0
  231. package/src/components/data/detail-row.tsx +113 -0
  232. package/src/components/data/id-display.tsx +49 -0
  233. package/src/components/data/masked-value.tsx +96 -0
  234. package/src/components/data/money.tsx +46 -0
  235. package/src/components/data/phone-display.tsx +60 -0
  236. package/src/components/data/stat-tile.tsx +68 -0
  237. package/src/components/data/timestamp-display.tsx +106 -0
  238. package/src/components/patterns/inline-banner.tsx +60 -0
  239. package/src/components/patterns/inline-empty-state.tsx +45 -0
  240. package/src/components/patterns/live-row.tsx +75 -0
  241. package/src/components/patterns/payload-inspector.tsx +157 -0
  242. package/src/components/patterns/route-skeleton.tsx +77 -0
  243. package/src/components/patterns/screen-layout.tsx +44 -0
  244. package/src/components/patterns/stale-write-banner.tsx +44 -0
  245. package/src/components/patterns/state-timeline.tsx +229 -0
  246. package/src/components/primitives/badge.tsx +40 -0
  247. package/src/components/primitives/button.tsx +54 -0
  248. package/src/components/primitives/calendar.tsx +136 -0
  249. package/src/components/primitives/card.tsx +70 -0
  250. package/src/components/primitives/checkbox.tsx +101 -0
  251. package/src/components/primitives/chip-select.tsx +105 -0
  252. package/src/components/primitives/command-menu.tsx +92 -0
  253. package/src/components/primitives/confirm-dialog.tsx +109 -0
  254. package/src/components/primitives/date-picker.tsx +271 -0
  255. package/src/components/primitives/dialog.tsx +195 -0
  256. package/src/components/primitives/drawer.tsx +326 -0
  257. package/src/components/primitives/dropdown-menu.tsx +128 -0
  258. package/src/components/primitives/form-field.tsx +136 -0
  259. package/src/components/primitives/inline-confirm.tsx +118 -0
  260. package/src/components/primitives/input.tsx +21 -0
  261. package/src/components/primitives/label.tsx +43 -0
  262. package/src/components/primitives/pagination.tsx +110 -0
  263. package/src/components/primitives/popover.tsx +41 -0
  264. package/src/components/primitives/progress.tsx +66 -0
  265. package/src/components/primitives/radio-group.tsx +90 -0
  266. package/src/components/primitives/select.tsx +254 -0
  267. package/src/components/primitives/separator.tsx +49 -0
  268. package/src/components/primitives/side-nav.tsx +253 -0
  269. package/src/components/primitives/skeleton.tsx +25 -0
  270. package/src/components/primitives/spinner.tsx +39 -0
  271. package/src/components/primitives/switch.tsx +74 -0
  272. package/src/components/primitives/table.tsx +157 -0
  273. package/src/components/primitives/tabs.tsx +163 -0
  274. package/src/components/primitives/textarea.tsx +21 -0
  275. package/src/components/primitives/toast.tsx +117 -0
  276. package/src/components/primitives/tooltip.tsx +38 -0
  277. package/src/components/status/state-chip.tsx +70 -0
  278. package/src/components/status/state-mark.tsx +154 -0
  279. package/src/components/status/status-pill.tsx +153 -0
  280. package/src/components/status/status-tokens.ts +173 -0
  281. package/src/index.ts +96 -0
  282. package/src/lib/cn.ts +168 -0
  283. package/src/lib/money.ts +164 -0
  284. package/src/lib/omit-undefined.ts +25 -0
  285. package/src/styles/theme.css +381 -0
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { cn } from "../../lib/cn";
5
+ import { TableRow, type TableRowProps } from "../primitives/table";
6
+ import type { StatusHue } from "../status/status-tokens";
7
+
8
+ export interface LiveRowProps extends TableRowProps {
9
+ /** Any value that changes to trigger a wash — pass the row's `@version`, an exact change key immune to clock skew. */
10
+ washTrigger: string | number;
11
+ /** The destination state's hue, so the wash tints toward where the row landed. */
12
+ washHue?: StatusHue;
13
+ }
14
+
15
+ const WASH_BG_CLASS: Record<StatusHue, string> = {
16
+ neutral: "bg-state-neutral-fg/10",
17
+ success: "bg-state-success-fg/10",
18
+ warning: "bg-state-warning-fg/10",
19
+ danger: "bg-state-danger-fg/10",
20
+ uncertain: "bg-state-uncertain-fg/10",
21
+ expired: "bg-state-expired-fg/10",
22
+ parked: "bg-state-parked-fg/10",
23
+ };
24
+
25
+ /**
26
+ * The row-level half of the design doc's `LiveTable`/`LiveRow` contract
27
+ * (§5.3, §6.5 rule 4): "in-place status change never moves a row" — this
28
+ * wraps `TableRow` with the 240ms wash-then-decay on a status change,
29
+ * nothing else moves or resizes. The full `LiveTable` (scroll-position-
30
+ * dependent buffered insertion, the sticky "N new" pill, sort-mode
31
+ * switching) is a later, screen-level task — this is the reusable unit it
32
+ * will be built on, shipped now so the wash behaviour isn't retrofitted
33
+ * per-screen later.
34
+ *
35
+ * Reduced motion (§3.8 rule 3): the wash becomes a static hold instead of
36
+ * a timed decay — the signal must survive, only the animation may not.
37
+ */
38
+ export function LiveRow({
39
+ washTrigger,
40
+ washHue = "neutral",
41
+ className,
42
+ children,
43
+ ...props
44
+ }: LiveRowProps) {
45
+ const [washing, setWashing] = useState(false);
46
+ const previousTrigger = useRef(washTrigger);
47
+ const reducedMotion =
48
+ typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
49
+
50
+ useEffect(() => {
51
+ if (previousTrigger.current === washTrigger) return;
52
+ previousTrigger.current = washTrigger;
53
+ setWashing(true);
54
+ const holdMs = reducedMotion ? 1200 : 400 + 240 + 600;
55
+ const timeout = setTimeout(() => setWashing(false), holdMs);
56
+ return () => clearTimeout(timeout);
57
+ }, [washTrigger, reducedMotion]);
58
+
59
+ return (
60
+ <TableRow
61
+ className={cn(
62
+ washing && [
63
+ WASH_BG_CLASS[washHue],
64
+ reducedMotion
65
+ ? "transition-none"
66
+ : "transition-colors duration-[var(--dur-state)] ease-out",
67
+ ],
68
+ className,
69
+ )}
70
+ {...props}
71
+ >
72
+ {children}
73
+ </TableRow>
74
+ );
75
+ }
@@ -0,0 +1,157 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { cn } from "../../lib/cn";
5
+ // D18: `Tabs` was rebuilt as `ValueTabs` (Headless UI `TabGroup` behind a
6
+ // value-based adapter) — aliased on import so nothing below this line
7
+ // changes; see tabs.tsx's own module doc for the full mechanism.
8
+ import {
9
+ ValueTabs as Tabs,
10
+ ValueTabsContent as TabsContent,
11
+ ValueTabsList as TabsList,
12
+ ValueTabsTrigger as TabsTrigger,
13
+ } from "../primitives/tabs";
14
+
15
+ export interface PayloadExchange {
16
+ direction: "request" | "response" | "callback";
17
+ method?: string;
18
+ url?: string;
19
+ status?: number;
20
+ durationMs?: number;
21
+ headers?: Record<string, string>;
22
+ body?: string;
23
+ error?: string;
24
+ }
25
+
26
+ export interface PayloadInspectorProps {
27
+ exchanges: PayloadExchange[];
28
+ defaultOpen?: number;
29
+ /** Bodies over this size collapse behind an explicit "load full payload" action. */
30
+ maxInlineBytes?: number;
31
+ }
32
+
33
+ function StatusChip({ status }: { status?: number | undefined }) {
34
+ if (status == null) return null;
35
+ const ok = status < 400;
36
+ return (
37
+ <span
38
+ className={cn(
39
+ "rounded-sm border px-1.5 py-0.5 font-mono text-caption",
40
+ ok
41
+ ? "border-edge text-muted-foreground"
42
+ : "border-state-danger-border text-state-danger-fg",
43
+ )}
44
+ >
45
+ {status}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ function ExchangeBody({
51
+ exchange,
52
+ maxInlineBytes,
53
+ }: {
54
+ exchange: PayloadExchange;
55
+ maxInlineBytes: number;
56
+ }) {
57
+ const [forceLoad, setForceLoad] = useState(false);
58
+ const body = exchange.body ?? "";
59
+ const oversized = !forceLoad && body.length > maxInlineBytes;
60
+
61
+ return (
62
+ <Tabs defaultValue="body">
63
+ <TabsList>
64
+ <TabsTrigger value="body">Body</TabsTrigger>
65
+ <TabsTrigger value="headers">Headers</TabsTrigger>
66
+ {exchange.error != null && <TabsTrigger value="error">Error</TabsTrigger>}
67
+ </TabsList>
68
+ <TabsContent value="body">
69
+ {body.length === 0 ? (
70
+ <p className="py-3 text-caption text-subtle-foreground">No response body was recorded.</p>
71
+ ) : oversized ? (
72
+ <button
73
+ type="button"
74
+ onClick={() => setForceLoad(true)}
75
+ className="py-3 text-caption text-foreground underline"
76
+ >
77
+ Load full payload ({(body.length / 1024).toFixed(1)} KB)
78
+ </button>
79
+ ) : (
80
+ <pre className="max-h-96 overflow-auto rounded-sm bg-base-100 p-3 font-mono text-[12px] text-foreground">
81
+ {body}
82
+ </pre>
83
+ )}
84
+ </TabsContent>
85
+ <TabsContent value="headers">
86
+ {exchange.headers == null || Object.keys(exchange.headers).length === 0 ? (
87
+ <p className="py-3 text-caption text-subtle-foreground">No headers recorded.</p>
88
+ ) : (
89
+ <dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 py-2 font-mono text-[12px]">
90
+ {Object.entries(exchange.headers).map(([key, value]) => (
91
+ <div key={key} className="contents">
92
+ <dt className="text-subtle-foreground">{key}</dt>
93
+ <dd className="break-all text-foreground">{value}</dd>
94
+ </div>
95
+ ))}
96
+ </dl>
97
+ )}
98
+ </TabsContent>
99
+ {exchange.error != null && (
100
+ <TabsContent value="error">
101
+ <pre className="max-h-96 overflow-auto rounded-sm border border-state-danger-border bg-state-danger-bg p-3 font-mono text-[12px] text-state-danger-fg">
102
+ {exchange.error}
103
+ </pre>
104
+ </TabsContent>
105
+ )}
106
+ </Tabs>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Raw provider request/response and DLR bodies, verbatim (design doc §5.3).
112
+ * No syntax-highlighting palette on purpose (§1.3): a rainbow JSON block
113
+ * would be the loudest thing on a diagnostic screen. Uses native
114
+ * `<details>` for the accordion behaviour — free keyboard/ARIA semantics,
115
+ * no extra Radix dependency needed for a plain expand/collapse.
116
+ */
117
+ export function PayloadInspector({
118
+ exchanges,
119
+ defaultOpen = 0,
120
+ maxInlineBytes = 262144,
121
+ }: PayloadInspectorProps) {
122
+ return (
123
+ <div className="flex flex-col gap-2">
124
+ {exchanges.map((exchange, i) => (
125
+ <details
126
+ // biome-ignore lint/suspicious/noArrayIndexKey: exchanges are a fixed, ordered, append-only log
127
+ key={i}
128
+ open={i === defaultOpen}
129
+ className="rounded-sm border border-edge bg-surface-2"
130
+ >
131
+ <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-body">
132
+ <span className="font-mono text-micro text-muted-foreground">{exchange.direction}</span>
133
+ {exchange.method != null && (
134
+ <span className="font-mono text-caption text-foreground">{exchange.method}</span>
135
+ )}
136
+ {exchange.url != null && (
137
+ <span className="truncate font-mono text-caption text-subtle-foreground">
138
+ {exchange.url}
139
+ </span>
140
+ )}
141
+ <span className="ml-auto flex items-center gap-2">
142
+ <StatusChip status={exchange.status} />
143
+ {exchange.durationMs != null && (
144
+ <span className="font-mono text-caption text-subtle-foreground">
145
+ {exchange.durationMs}ms
146
+ </span>
147
+ )}
148
+ </span>
149
+ </summary>
150
+ <div className="border-edge border-t px-3 pb-3">
151
+ <ExchangeBody exchange={exchange} maxInlineBytes={maxInlineBytes} />
152
+ </div>
153
+ </details>
154
+ ))}
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,77 @@
1
+ import { Skeleton } from "../primitives/skeleton";
2
+ import { ScreenStack } from "./screen-layout";
3
+
4
+ // #308's own mitigation half — a real, visible fallback for the
5
+ // `<Suspense>` boundary every screen that reads `useSearchParams()`/
6
+ // `useQueryStates()` (`nuqs`) must wrap itself in (Next.js requires this;
7
+ // see AGENTS.md's own R6-adjacent note on `page.tsx` shells). The eight
8
+ // screens that still need that boundary — `messages`, `webhooks`, `jobs`,
9
+ // `workers`, `opt-outs`, `apps`, `users`, `audit-log` — used to pass
10
+ // `fallback={null}`, which is exactly the shape #308 diagnosed: a
11
+ // `<Suspense>` boundary is a real, load-bearing part of Next's streaming
12
+ // SSR pipeline, and its reveal (the swap from the fallback to the real
13
+ // tree) is `requestAnimationFrame`-gated by both React's own inline
14
+ // completion script (`$RC` schedules `$RV` via rAF) and the hydration
15
+ // retry that follows it. A backgrounded/non-composited tab — proven live
16
+ // against this exact build, see the PR/issue thread — never runs that rAF
17
+ // callback, so `fallback={null}` rendered as a permanently empty `<main>`
18
+ // with zero signal that anything was ever wrong. This is NOT a fix for
19
+ // that mechanism (nothing in application code can make a browser paint a
20
+ // hidden tab), it only makes the wait visible instead of indistinguishable
21
+ // from broken. Ordinary, foregrounded browser tabs reveal the real
22
+ // boundary within one frame regardless, mitigation or not.
23
+ //
24
+ // One reusable component rather than nine bespoke skeletons — every
25
+ // affected screen needs the same shape (a title bar, an optional filter
26
+ // row, several table-row-shaped blocks), and `Skeleton` itself already
27
+ // forbids shimmer (design doc §3.8/§5.2, see that primitive's own doc)
28
+ // so there is no per-screen animation timing to keep in sync anyway. Per
29
+ // R6, this lives in `@vaam-apps/ui`, not duplicated per route.
30
+
31
+ export interface RouteSkeletonProps {
32
+ /** How many table-row-shaped blocks to render. Doesn't need to match the
33
+ * real screen's row height exactly (`Skeleton`'s own doc asks for that
34
+ * only where a skeleton stands in for an already-mounted, known-shape
35
+ * table) — this fallback exists for a `<main>` that hasn't rendered
36
+ * *anything* app-specific yet, not a loading row inside a mounted
37
+ * table. */
38
+ rows?: number;
39
+ /** Whether to render a filter-bar-shaped skeleton row above the table
40
+ * rows — most of the eight screens this serves have one (a search box,
41
+ * a couple of selects); a couple don't. */
42
+ withFilterBar?: boolean;
43
+ }
44
+
45
+ export function RouteSkeleton({ rows = 6, withFilterBar = true }: RouteSkeletonProps) {
46
+ return (
47
+ <ScreenStack>
48
+ {/* Not `ScreenHeader` here: its `title`/`description` slots render
49
+ * inside `<h1>`/`<p>`, and `<p>` cannot contain a block-level
50
+ * element like `Skeleton`'s own `<div>` — the browser's HTML parser
51
+ * gives `<p>` an implied end tag the instant it meets one (no such
52
+ * rule applies to `<h1>`), silently splitting `<p><div/></p>` into
53
+ * two siblings and reparenting the div outside the paragraph. That
54
+ * is a real, reproduced-live hydration-mismatch shape, not a
55
+ * hypothetical — found by rendering this exact component before
56
+ * this comment was written. A plain header block, skeleton bars as
57
+ * direct children, sidesteps it entirely. */}
58
+ <div className="flex flex-col gap-1">
59
+ <Skeleton className="h-7 w-48" />
60
+ <Skeleton className="h-4 w-80" />
61
+ </div>
62
+ {withFilterBar && (
63
+ <div className="flex flex-wrap items-center gap-2">
64
+ <Skeleton className="h-9 w-64" />
65
+ <Skeleton className="h-9 w-32" />
66
+ <Skeleton className="h-9 w-32" />
67
+ </div>
68
+ )}
69
+ <div className="flex flex-col gap-2">
70
+ {Array.from({ length: rows }, (_, i) => (
71
+ // biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows, a fixed count, never reordered/removed individually.
72
+ <Skeleton key={i} className="h-11 w-full" />
73
+ ))}
74
+ </div>
75
+ </ScreenStack>
76
+ );
77
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ // R6 (AGENTS.md): every route screen was hand-rolling the identical
5
+ // `<div className="flex flex-col gap-6">` wrapper plus a
6
+ // `<h1 className="font-medium text-foreground text-title">`/description
7
+ // `<header>` pair — confirmed by grep, ten of eleven screens under
8
+ // `frontends/apps/admin/app/*/*.tsx` had this exact pair before this file
9
+ // existed. R6's own test ("if a second route would plausibly use it, it
10
+ // belongs in `frontends/packages/ui`") is not a close call here. Extracted
11
+ // verbatim from `jobs-screen.tsx`/`workers-screen.tsx`/`opt-outs-screen.tsx`
12
+ // — same classes, not a redesign.
13
+ //
14
+ // New file, added while several route groups were being moved onto R6 in
15
+ // parallel — expect another agent's PR to add an equivalent. If both land,
16
+ // reconcile onto one of the two rather than keeping both: same shape,
17
+ // different name is worse than either alone.
18
+
19
+ export interface ScreenStackProps {
20
+ children: ReactNode;
21
+ className?: string;
22
+ }
23
+
24
+ /** The page-level vertical rhythm every screen composes its sections into. */
25
+ export function ScreenStack({ children, className }: ScreenStackProps) {
26
+ return <div className={cn("flex flex-col gap-6", className)}>{children}</div>;
27
+ }
28
+
29
+ export interface ScreenHeaderProps {
30
+ title: ReactNode;
31
+ description?: ReactNode;
32
+ }
33
+
34
+ /** A screen's own `<h1>` + one-line description, styled once. */
35
+ export function ScreenHeader({ title, description }: ScreenHeaderProps) {
36
+ return (
37
+ <header className="flex flex-col gap-1">
38
+ <h1 className="font-medium text-foreground text-title">{title}</h1>
39
+ {description != null && (
40
+ <p className="max-w-xl text-body text-muted-foreground">{description}</p>
41
+ )}
42
+ </header>
43
+ );
44
+ }
@@ -0,0 +1,44 @@
1
+ import { Button } from "../primitives/button";
2
+ import { InlineBanner } from "./inline-banner";
3
+
4
+ /**
5
+ * "Someone else changed this row since it loaded" — the banner for a
6
+ * `412 Precondition Failed` save.
7
+ *
8
+ * Shared rather than route-local because the thing it reports is not
9
+ * route-local: #59 gave `@version` to ten operator-editable models and
10
+ * threaded ETag/`If-Match` through the generated REST layer, so *every*
11
+ * edit screen in the console can lose this race. `apps/` and `users/` each
12
+ * hand-rolled it; the rest will need it as their edit screens land.
13
+ *
14
+ * The two hand-rolled copies differed in exactly one way — one hardcoded
15
+ * its sentence, the other took a `message` prop. The prop version is
16
+ * strictly more general, so it wins, with the hardcoded sentence as the
17
+ * default so the common case stays a one-line call.
18
+ *
19
+ * `warning`, not `danger`: nothing was lost and nothing is broken. The
20
+ * write was *refused* precisely so the other operator's edit survives —
21
+ * which is the mechanism working, not failing. Reloading resolves it.
22
+ * `packages/gateway/src/errors.ts`'s `isStaleWriteError` exists to
23
+ * distinguish this from a genuine conflict for the same reason.
24
+ */
25
+ export function StaleWriteBanner({
26
+ message = "Someone else changed this row since it loaded. Reload to see their edit.",
27
+ onReload,
28
+ }: {
29
+ message?: string | undefined;
30
+ onReload: () => void;
31
+ }) {
32
+ return (
33
+ <InlineBanner variant="warning" className="flex items-center justify-between gap-3">
34
+ <span>{message}</span>
35
+ {/* `type="button"` is load-bearing, not boilerplate: both call sites
36
+ render this *inside* a `<form>`, where a button's default type is
37
+ `submit`. Dropping it would make "Reload" submit the very edit the
38
+ banner is warning has gone stale. */}
39
+ <Button type="button" variant="secondary" size="sm" onClick={onReload}>
40
+ Reload
41
+ </Button>
42
+ </InlineBanner>
43
+ );
44
+ }
@@ -0,0 +1,229 @@
1
+ import { Info } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Skeleton } from "../primitives/skeleton";
5
+ import { StateMark } from "../status/state-mark";
6
+ import type { StatusSystem } from "../status/status-tokens";
7
+ import { type PayloadExchange, PayloadInspector } from "./payload-inspector";
8
+
9
+ export interface StateTransition<S extends string = string> {
10
+ toState: S;
11
+ /** ISO 8601 timestamp. */
12
+ at: string;
13
+ actor?: string;
14
+ providerKey?: string;
15
+ workerNode?: string;
16
+ attempt?: number;
17
+ maxAttempts?: number;
18
+ payload?: PayloadExchange[];
19
+ }
20
+
21
+ export interface StateTimelineProps<S extends string = string> {
22
+ transitions: StateTransition<S>[];
23
+ /** The state machine's presentation table — the same one its
24
+ * `createStatusPill` is bound to, so a timeline and a pill can never
25
+ * disagree about what a state looks like. */
26
+ system: StatusSystem<S>;
27
+ currentState: S;
28
+ /** Whether the timeline has stopped. Passed in rather than derived from
29
+ * `system[currentState].family`: terminality is the server's fact, and
30
+ * a presentational table is the wrong place to learn it from — see
31
+ * `status-tokens.ts`'s own warning. */
32
+ isTerminal: boolean;
33
+ /**
34
+ * Any IANA zone name, e.g. `"UTC"`, `"Africa/Douala"`, `"America/New_York"`.
35
+ * The UTC-offset suffix is derived from the zone and the instant, so it
36
+ * stays correct across DST and for zones this library has never heard
37
+ * of. (It used to be a two-value union with the suffix hard-coded as
38
+ * `"Z"` or `"+01"` — correct only for the two zones that union named.)
39
+ */
40
+ timezone?: string;
41
+ /**
42
+ * Per-state explanatory notes, rendered beneath the node that entered
43
+ * that state.
44
+ *
45
+ * The point is states that look like bugs to anyone who does not
46
+ * already know the product decision behind them — "we never learned the
47
+ * outcome, and deliberately will not retry". Without the note the
48
+ * operator's next move is to open a SQL client, which is the outcome a
49
+ * timeline exists to prevent. Supplied by the caller because the
50
+ * explanation is domain knowledge, not presentation.
51
+ */
52
+ annotations?: Partial<Record<S, string>> | undefined;
53
+ }
54
+
55
+ /**
56
+ * `2026-09-11 14:03:07 +01` — sortable, unambiguous, and carrying its own
57
+ * offset so a screenshot pasted into a ticket is still interpretable.
58
+ *
59
+ * The offset is read out of `Intl`'s own `shortOffset` part rather than
60
+ * assumed from the zone name: it is the only way to be right across DST
61
+ * and across zones this component has never been told about. `UTC` keeps
62
+ * its conventional `Z` rather than the `GMT` that `shortOffset` yields.
63
+ */
64
+ function formatAbsolute(iso: string, timezone: string): string {
65
+ const date = new Date(iso);
66
+ const formatter = new Intl.DateTimeFormat("en-CA", {
67
+ timeZone: timezone,
68
+ year: "numeric",
69
+ month: "2-digit",
70
+ day: "2-digit",
71
+ hour: "2-digit",
72
+ minute: "2-digit",
73
+ second: "2-digit",
74
+ hourCycle: "h23",
75
+ timeZoneName: "shortOffset",
76
+ });
77
+ const parts = formatter.formatToParts(date);
78
+ const stamp = parts
79
+ .filter((part) => part.type !== "timeZoneName" && part.type !== "literal")
80
+ .reduce<string[]>((acc, part) => {
81
+ acc.push(part.value);
82
+ return acc;
83
+ }, []);
84
+ const [year, month, day, hour, minute, second] = stamp;
85
+ // "GMT+1" / "GMT-05:00" / "GMT" → "+01" / "-05:00" / "Z".
86
+ const raw = parts.find((part) => part.type === "timeZoneName")?.value ?? "";
87
+ const offset = raw === "GMT" ? "Z" : raw.replace(/^GMT/, "").replace(/^([+-])(\d)$/, "$10$2");
88
+ return `${year}-${month}-${day} ${hour}:${minute}:${second} ${offset}`;
89
+ }
90
+
91
+ function formatElapsed(ms: number): string {
92
+ if (ms < 1000) return `+${ms}ms`;
93
+ if (ms < 60_000) return `+${(ms / 1000).toFixed(3)}s`;
94
+ const totalSeconds = Math.round(ms / 1000);
95
+ const minutes = Math.floor(totalSeconds / 60);
96
+ const seconds = totalSeconds % 60;
97
+ if (minutes < 60) return `+${minutes}m ${String(seconds).padStart(2, "0")}s`;
98
+ const hours = Math.floor(minutes / 60);
99
+ return `+${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
100
+ }
101
+
102
+ function AnnotationNode({ text }: { text: string }) {
103
+ return (
104
+ <li className="relative flex gap-3 pb-4 pl-0">
105
+ <div className="flex w-4 shrink-0 justify-center">
106
+ <Info size={14} strokeWidth={1.5} className="mt-0.5 text-muted-foreground" />
107
+ </div>
108
+ <div className="min-w-0 flex-1 rounded-sm border border-edge bg-surface-2 px-3 py-2 text-caption text-muted-foreground">
109
+ {text}
110
+ </div>
111
+ </li>
112
+ );
113
+ }
114
+
115
+ /**
116
+ * A record's transition history: one node per state entered, with elapsed
117
+ * time between them, optional per-transition metadata, and an optional
118
+ * payload inspector.
119
+ *
120
+ * Generic over the state machine — pass the same [`StatusSystem`] its
121
+ * pill is bound to. It used to be hard-wired to one application's message
122
+ * states, including that application's own explanatory notes and a
123
+ * two-value timezone union; all three are now the caller's.
124
+ */
125
+ export function StateTimeline<S extends string>({
126
+ transitions,
127
+ system,
128
+ currentState,
129
+ isTerminal,
130
+ timezone = "UTC",
131
+ annotations,
132
+ }: StateTimelineProps<S>) {
133
+ if (transitions.length === 0) {
134
+ return (
135
+ <ol className="flex flex-col gap-0">
136
+ {[0, 1, 2].map((i) => (
137
+ <li key={i} className="flex items-center gap-3 py-2">
138
+ <Skeleton className="h-4 w-4 rounded-full" />
139
+ <Skeleton className="h-4 w-40" />
140
+ </li>
141
+ ))}
142
+ </ol>
143
+ );
144
+ }
145
+
146
+ const rows: ReactNode[] = [];
147
+
148
+ transitions.forEach((transition, i) => {
149
+ const previous = transitions[i - 1];
150
+ const elapsedMs = previous
151
+ ? new Date(transition.at).getTime() - new Date(previous.at).getTime()
152
+ : null;
153
+ const meta = system[transition.toState];
154
+ const isLast = i === transitions.length - 1;
155
+
156
+ rows.push(
157
+ <li
158
+ key={`${transition.toState}-${transition.at}`}
159
+ className="relative flex gap-3 pb-6 last:pb-0"
160
+ >
161
+ <div className="flex w-4 shrink-0 flex-col items-center">
162
+ <StateMark meta={meta} size={16} className="text-foreground" />
163
+ {!isLast && <span className="mt-1 w-px flex-1 bg-[var(--state-mark-rail,var(--edge))]" />}
164
+ </div>
165
+ <div className="min-w-0 flex-1">
166
+ <div className="flex items-baseline justify-between gap-2">
167
+ <p className="font-medium text-body text-foreground">
168
+ {meta.label}{" "}
169
+ <span className="font-mono text-subtle-foreground">{transition.toState}</span>
170
+ </p>
171
+ <div className="shrink-0 text-right">
172
+ <p className="font-mono text-caption text-foreground">
173
+ {formatAbsolute(transition.at, timezone)}
174
+ </p>
175
+ {elapsedMs != null && (
176
+ <p className="font-mono text-caption text-subtle-foreground">
177
+ {formatElapsed(elapsedMs)}
178
+ </p>
179
+ )}
180
+ </div>
181
+ </div>
182
+ {(transition.providerKey || transition.workerNode || transition.attempt) != null && (
183
+ <p className="mt-1 font-mono text-caption text-subtle-foreground">
184
+ {[
185
+ transition.providerKey,
186
+ transition.workerNode,
187
+ transition.attempt != null
188
+ ? `attempt ${transition.attempt}${transition.maxAttempts ? `/${transition.maxAttempts}` : ""}`
189
+ : null,
190
+ ]
191
+ .filter(Boolean)
192
+ .join(" · ")}
193
+ </p>
194
+ )}
195
+ {transition.payload != null && transition.payload.length > 0 && (
196
+ <div className="mt-2">
197
+ <PayloadInspector exchanges={transition.payload} defaultOpen={-1} />
198
+ </div>
199
+ )}
200
+ </div>
201
+ </li>,
202
+ );
203
+
204
+ const annotation = annotations?.[transition.toState];
205
+ if (annotation != null) {
206
+ rows.push(<AnnotationNode key={`${transition.toState}-annotation`} text={annotation} />);
207
+ }
208
+ });
209
+
210
+ // In-flight cap (design doc §5.3): the rail continues past the last node
211
+ // as a dashed segment ending in the current-state glyph, so "still
212
+ // moving" is readable without parsing. A terminal timeline instead ends
213
+ // the last node's own rail segment (no trailing cap at all).
214
+ if (!isTerminal) {
215
+ rows.push(
216
+ <li key="in-flight-cap" className="relative flex gap-3">
217
+ <div className="flex w-4 shrink-0 justify-center">
218
+ <span className="h-6 w-px border-edge-strong border-l border-dashed" aria-hidden="true" />
219
+ </div>
220
+ <div className="flex items-center gap-2 text-caption text-subtle-foreground">
221
+ <StateMark meta={system[currentState]} size={12} className="text-muted-foreground" />
222
+ <span>still moving</span>
223
+ </div>
224
+ </li>,
225
+ );
226
+ }
227
+
228
+ return <ol className={cn("flex flex-col")}>{rows}</ol>;
229
+ }
@@ -0,0 +1,40 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import type { HTMLAttributes } from "react";
3
+ import { cn } from "../../lib/cn";
4
+
5
+ /**
6
+ * Non-status tags only: app name, provider key, role, environment, sender
7
+ * ID. Never use `Badge` for a message or job state — that's `StatusPill`'s
8
+ * job (design doc §5.2); mixing them is how the status language erodes.
9
+ *
10
+ * D11: `cva()` replaces the previous `variant === "x" && "..."` conditional
11
+ * chain, same classes. One deliberate D8 diff, not a byte-identical carry-
12
+ * over: the previous base string hard-coded `rounded-sm`, which fought
13
+ * daisyUI's own `.badge` rule (`border-radius: var(--radius-selector)`,
14
+ * confirmed by reading `daisyui/components/badge.css` directly) at equal
15
+ * specificity — a redundant override, not a deliberate choice, and for the
16
+ * wrong tier besides: `--radius-selector` (8px) is this register's
17
+ * small-control tier, `--radius-sm` now aliases `--radius-field` (12px).
18
+ * Dropping the override lets daisyUI's own class govern radius, which is
19
+ * exactly constraint 7 ("DaisyUI does the work") and the correct tier for
20
+ * a tag-sized control.
21
+ */
22
+ export const badgeVariants = cva("badge font-mono text-caption", {
23
+ variants: {
24
+ variant: {
25
+ neutral: "badge-neutral",
26
+ outline: "badge-outline",
27
+ },
28
+ },
29
+ defaultVariants: { variant: "neutral" },
30
+ });
31
+
32
+ export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>["variant"]>;
33
+
34
+ export interface BadgeProps
35
+ extends HTMLAttributes<HTMLSpanElement>,
36
+ VariantProps<typeof badgeVariants> {}
37
+
38
+ export function Badge({ className, variant, ...props }: BadgeProps) {
39
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
40
+ }