@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,113 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ /**
5
+ * A labelled value in a detail drawer or summary panel — one `dt`/`dd`
6
+ * pair, wrapped in a `<div>` so the pair can be laid out as a unit.
7
+ *
8
+ * # Why this is shared, when four route-local copies said it should not be
9
+ *
10
+ * `providers/`, `routes/`, `sender-ids/` and `webhooks/` each carry a
11
+ * `components/detail-row.tsx` whose **code is byte-identical** — only the
12
+ * doc comments differ. Each of those comments argues, in good faith, that
13
+ * the component is route-local because "the identical shape repeats
14
+ * independently in the other three, each factored the same way in its own
15
+ * file". Four files justifying their own duplication by pointing at each
16
+ * other is the clearest possible signal the thing belongs here instead.
17
+ *
18
+ * They were written by four agents working in parallel on disjoint route
19
+ * groups, none of which could edit this package — a deliberate constraint,
20
+ * because the round before it produced two `ScreenHeader`s and two
21
+ * `MESSAGE_CLASSES` from agents that *could*. The trade was that
22
+ * cross-route duplicates get reconciled centrally afterwards. This is that.
23
+ *
24
+ * # The variants are the console's real, pre-existing treatments
25
+ *
26
+ * Nothing here is a new design. Eight sites were read first and their
27
+ * looks catalogued; the three variants below are exactly what was already
28
+ * on screen, so every converted site keeps its current appearance:
29
+ *
30
+ * - `inline` — label left, value right, on one line. The default, and what
31
+ * the four route-local copies rendered.
32
+ * - `stacked` — label above value. What those same copies rendered under
33
+ * their own `stacked` prop, for a value too long to sit beside its label.
34
+ * - `divided` — label above value, hairline rule beneath, last row
35
+ * unruled. What `jobs`, `opt-outs` and `workers` each hand-rolled as a
36
+ * private `*DetailField` helper.
37
+ *
38
+ * **One deliberate normalisation, called out rather than slipped in:**
39
+ * `audit-log` and the gallery used `flex justify-between gap-4` — the
40
+ * `inline` treatment with a slightly wider gap and no vertical centring.
41
+ * They map onto `inline` here, so those two drawers shift by one spacing
42
+ * step and gain baseline centring. That is a real, if small, visual change,
43
+ * and it is the point: the console should not render the same concept two
44
+ * ways because two people typed a different gap.
45
+ */
46
+ export type DetailRowVariant = "inline" | "stacked" | "divided";
47
+
48
+ export interface DetailRowProps {
49
+ label: ReactNode;
50
+ children: ReactNode;
51
+ variant?: DetailRowVariant | undefined;
52
+ className?: string | undefined;
53
+ }
54
+
55
+ export function DetailRow({ label, children, variant = "inline", className }: DetailRowProps) {
56
+ if (variant === "divided") {
57
+ return (
58
+ <div
59
+ className={cn(
60
+ "flex flex-col gap-0.5 border-edge-subtle border-b py-2 last:border-b-0",
61
+ className,
62
+ )}
63
+ >
64
+ <dt className="text-caption text-subtle-foreground">{label}</dt>
65
+ <dd className="text-body text-foreground">{children}</dd>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ if (variant === "stacked") {
71
+ return (
72
+ <div className={cn("flex flex-col gap-1", className)}>
73
+ <dt className="text-muted-foreground">{label}</dt>
74
+ <dd className="text-caption">{children}</dd>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className={cn("flex items-center justify-between gap-3", className)}>
81
+ <dt className="text-muted-foreground">{label}</dt>
82
+ <dd>{children}</dd>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ /**
88
+ * The `<dl>` a group of [`DetailRow`]s belongs in.
89
+ *
90
+ * Exists so a caller cannot pair the rows with the wrong container — every
91
+ * one of the eight original sites wrapped its rows in a `<dl>` with its own
92
+ * spacing (`gap-2`, `gap-3`, or none), which is the same one-decision-in-
93
+ * many-places problem the rows themselves had, one level up.
94
+ *
95
+ * `divided` rows carry their own vertical rhythm through padding and the
96
+ * hairline rule, so the list adds no gap for them — passing `gap` there
97
+ * would double-space rows that are already separated by a border.
98
+ */
99
+ export function DetailList({
100
+ children,
101
+ variant = "inline",
102
+ className,
103
+ }: {
104
+ children: ReactNode;
105
+ variant?: DetailRowVariant | undefined;
106
+ className?: string | undefined;
107
+ }) {
108
+ return (
109
+ <dl className={cn("flex flex-col text-body", variant !== "divided" && "gap-3", className)}>
110
+ {children}
111
+ </dl>
112
+ );
113
+ }
@@ -0,0 +1,49 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { CopyButton } from "./copy-button";
3
+
4
+ export interface IdDisplayProps {
5
+ value: string;
6
+ /** `table` (default): a truncated prefix plus a hover-reveal copy
7
+ * button — enough to disambiguate visually within one screen of rows.
8
+ * `full`: the whole value, selectable, with an always-visible copy —
9
+ * detail views only. Copy always copies the FULL value in both. */
10
+ variant?: "table" | "full";
11
+ /** How many leading characters the `table` variant shows. Seven is the
12
+ * default because it is what makes two 23-character CUIDs distinguishable
13
+ * in a list without dominating the column; an id family with a shared
14
+ * prefix (`ord_`, `pi_`) needs more. */
15
+ truncateTo?: number;
16
+ className?: string | undefined;
17
+ }
18
+
19
+ /**
20
+ * An opaque machine identifier — a CUID, a ULID, a provider's reference.
21
+ *
22
+ * Two rules, both learned the hard way and both enforced here rather than
23
+ * left to each call site:
24
+ *
25
+ * - **Never truncate in the middle.** `abc…xyz` makes two ids that differ
26
+ * only in the middle look identical, which is precisely the comparison
27
+ * a human scanning a column is making.
28
+ * - **Never decorate.** A displayed `msg_abc…` gets pasted into a filter
29
+ * that expects the bare value and returns nothing, or worse, a 400. If
30
+ * the prefix is part of the id, it is part of `value`.
31
+ *
32
+ * For a value that must be read in full — a config key, a role name, an
33
+ * enum literal — use `Code` instead. This component's truncation is wrong
34
+ * for anything a human is meant to read rather than match.
35
+ */
36
+ export function IdDisplay({ value, variant = "table", truncateTo = 7, className }: IdDisplayProps) {
37
+ const shown = variant === "table" ? value.slice(0, truncateTo) : value;
38
+
39
+ return (
40
+ <span
41
+ className={cn("group inline-flex items-center gap-1.5 font-mono tabular-nums", className)}
42
+ >
43
+ <span className={variant === "full" ? "select-all" : undefined} title={value}>
44
+ {shown}
45
+ </span>
46
+ <CopyButton value={value} revealOnGroupHover={variant === "table"} />
47
+ </span>
48
+ );
49
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { Eye, EyeOff } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { cn } from "../../lib/cn";
6
+ import { CopyButton } from "./copy-button";
7
+
8
+ export interface MaskedValueProps {
9
+ /** The full value. Present in the DOM only while revealed. */
10
+ value: string;
11
+ /** Trailing characters left visible while masked, so the value stays
12
+ * recognisable without being readable. `0` masks everything. */
13
+ reveal?: number;
14
+ /** Leading characters left visible — for a prefixed credential like
15
+ * `whsec_…` or `sk_live_…`, where the prefix says what kind of thing it
16
+ * is and is not itself secret. */
17
+ prefix?: number;
18
+ /** Allow revealing at all. `false` gives a permanently masked value with
19
+ * no toggle — for something displayed for recognition only. */
20
+ revealable?: boolean;
21
+ /** Offer a copy button. Copies the full value whether or not it is
22
+ * currently revealed — the point of masking is the screen, not the
23
+ * clipboard. */
24
+ copyable?: boolean;
25
+ /** Names the value in the toggle's and copy button's accessible names,
26
+ * e.g. `"signing secret"`. */
27
+ label?: string;
28
+ className?: string | undefined;
29
+ }
30
+
31
+ const DOT = "•";
32
+
33
+ function mask(value: string, prefix: number, reveal: number): string {
34
+ const head = value.slice(0, prefix);
35
+ const tail = reveal > 0 ? value.slice(-reveal) : "";
36
+ const hiddenCount = Math.max(value.length - head.length - tail.length, 0);
37
+ // A fixed run of dots, not one per character: the exact length of a
38
+ // secret is information, and padding it out to the real length leaks it
39
+ // to anyone looking over a shoulder.
40
+ const dots = DOT.repeat(hiddenCount === 0 ? 0 : Math.min(Math.max(hiddenCount, 6), 12));
41
+ return `${head}${dots}${tail}`;
42
+ }
43
+
44
+ /**
45
+ * A secret or sensitive value, masked by default with an explicit reveal.
46
+ *
47
+ * # What this is and is not
48
+ *
49
+ * It is a **shoulder-surfing and screen-share control**, not a security
50
+ * boundary. Whoever renders this already received the value over the
51
+ * wire; anyone with the session and dev tools can read it regardless.
52
+ * Withholding data that has already crossed the wire to an authorised,
53
+ * authenticated session would be theatre. What masking buys is real but
54
+ * narrow: the value does not end up in a screenshot, a recording, or the
55
+ * eyeline of the person sitting behind the operator.
56
+ *
57
+ * If a value must genuinely never reach the client, that is an API
58
+ * decision — do not send it — and no component can substitute for it.
59
+ *
60
+ * # Why it is shared
61
+ *
62
+ * Both applications this package serves had hand-rolled one: a console
63
+ * masking a webhook signing secret as `whsec_••••••••••<last 4>`, and a
64
+ * payments dashboard rendering a masked payer reference and a TOTP
65
+ * enrolment secret as plain text because no such component existed.
66
+ */
67
+ export function MaskedValue({
68
+ value,
69
+ reveal = 4,
70
+ prefix = 0,
71
+ revealable = true,
72
+ copyable = true,
73
+ label = "value",
74
+ className,
75
+ }: MaskedValueProps) {
76
+ const [revealed, setRevealed] = useState(false);
77
+ const shown = revealed ? value : mask(value, prefix, reveal);
78
+
79
+ return (
80
+ <span className={cn("group inline-flex items-center gap-1.5", className)}>
81
+ <span className={cn("font-mono", revealed && "select-all break-all")}>{shown}</span>
82
+ {revealable && (
83
+ <button
84
+ type="button"
85
+ onClick={() => setRevealed((previous) => !previous)}
86
+ aria-label={revealed ? `Hide ${label}` : `Reveal ${label}`}
87
+ aria-pressed={revealed}
88
+ className="shrink-0 text-subtle-foreground transition-colors hover:text-foreground"
89
+ >
90
+ {revealed ? <EyeOff size={12} strokeWidth={1.5} /> : <Eye size={12} strokeWidth={1.5} />}
91
+ </button>
92
+ )}
93
+ {copyable && <CopyButton value={value} label={`Copy ${label}`} />}
94
+ </span>
95
+ );
96
+ }
@@ -0,0 +1,46 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { type FormatMoneyOptions, formatMoney, type MinorUnits } from "../../lib/money";
3
+
4
+ export interface MoneyProps extends FormatMoneyOptions {
5
+ /** An integer count of the currency's smallest unit. */
6
+ amount: MinorUnits;
7
+ /** ISO 4217 code, e.g. `"XAF"`. */
8
+ currency: string;
9
+ /** Tint by sign: credits green, debits red. Off by default — in a
10
+ * ledger where most rows are one direction, colouring every row is
11
+ * noise, and colour alone is not an accessible way to convey sign. The
12
+ * minus sign is always rendered regardless. */
13
+ tone?: "none" | "signed";
14
+ className?: string | undefined;
15
+ }
16
+
17
+ /**
18
+ * An amount of money, right-alignable and vertically comparable.
19
+ *
20
+ * `tabular-nums` is the whole reason this is a component and not a call to
21
+ * [`formatMoney`] in a `<span>`: without fixed-width digits, a column of
22
+ * amounts does not line up at the decimal point, and scanning it for an
23
+ * outlier — the actual job — stops working.
24
+ *
25
+ * `<data value>` rather than `<span>`: the machine-readable amount stays
26
+ * in the DOM in minor units, so a copy-paste or a scraper gets the exact
27
+ * integer rather than a locale-formatted string it would have to parse
28
+ * back.
29
+ */
30
+ export function Money({ amount, currency, tone = "none", className, ...options }: MoneyProps) {
31
+ const formatted = formatMoney(amount, currency, options);
32
+ const negative = String(amount).trim().startsWith("-");
33
+
34
+ return (
35
+ <data
36
+ value={`${String(amount)} ${currency}`}
37
+ className={cn(
38
+ "font-mono tabular-nums whitespace-nowrap",
39
+ tone === "signed" && (negative ? "text-state-danger-fg" : "text-state-success-fg"),
40
+ className,
41
+ )}
42
+ >
43
+ {formatted}
44
+ </data>
45
+ );
46
+ }
@@ -0,0 +1,60 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { CopyButton } from "./copy-button";
3
+
4
+ export interface PhoneDisplayProps {
5
+ /** E.164, e.g. `+237677123456`. Stored form, and what gets copied. */
6
+ value: string;
7
+ /**
8
+ * Regroups the E.164 digits for reading. Return `null` — or omit the
9
+ * prop — to render the value verbatim.
10
+ *
11
+ * Grouping is a per-country convention (`+237 6 77 12 34 56` in
12
+ * Cameroon, `+1 (415) 555-0132` in the NANP) and getting it wrong is
13
+ * worse than not grouping at all, because a mis-grouped number reads as
14
+ * a different number. This component therefore ships no default
15
+ * grouping and no built-in country table: a caller that knows its
16
+ * market passes a formatter, and everyone else gets the raw E.164,
17
+ * which is always correct if less pretty.
18
+ */
19
+ format?: ((e164: string) => string | null) | undefined;
20
+ /**
21
+ * A short mono tag after the number — a carrier, a line type, a region.
22
+ * Renders an em dash when absent, so a column of numbers stays aligned
23
+ * whether or not each row could be classified.
24
+ */
25
+ tag?: string | undefined;
26
+ /** Hover text for the tag. Say where it came from and how much to trust
27
+ * it: a carrier inferred from a number prefix is a guess, and number
28
+ * portability means it is wrong for a real fraction of subscribers. */
29
+ tagTitle?: string | undefined;
30
+ className?: string | undefined;
31
+ }
32
+
33
+ /**
34
+ * A phone number, never truncated.
35
+ *
36
+ * Truncation is banned rather than discouraged: a half-shown phone number
37
+ * is worse than useless in an investigation, and unlike an opaque id
38
+ * (where a prefix is enough to match rows by eye) there is no prefix of a
39
+ * phone number that identifies it.
40
+ *
41
+ * Copy always copies the raw E.164 — the form that pastes into a query —
42
+ * not the grouped form on screen.
43
+ *
44
+ * Previously this was `MsisdnDisplay`, with Cameroon's `+237` grouping and
45
+ * a four-entry Cameroonian carrier table compiled in. Both are now the
46
+ * caller's: an application knows its market, a component library does not.
47
+ */
48
+ export function PhoneDisplay({ value, format, tag, tagTitle, className }: PhoneDisplayProps) {
49
+ const formatted = format?.(value) ?? value;
50
+
51
+ return (
52
+ <span className={cn("group inline-flex items-center gap-1.5 whitespace-nowrap", className)}>
53
+ <span className="font-mono tabular-nums">{formatted}</span>
54
+ <span className="font-mono text-micro text-subtle-foreground" title={tagTitle}>
55
+ {tag ?? "—"}
56
+ </span>
57
+ <CopyButton value={value} revealOnGroupHover />
58
+ </span>
59
+ );
60
+ }
@@ -0,0 +1,68 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import type { StatusHue } from "../status/status-tokens";
4
+ import { HUE_CLASSES } from "../status/status-tokens";
5
+
6
+ export interface StatTileProps {
7
+ label: ReactNode;
8
+ /** The number itself. Pass a `Money` or a formatted string — this
9
+ * component does no formatting, because a tile has no way to know
10
+ * whether it is showing a count, a currency or a rate. */
11
+ value: ReactNode;
12
+ /** One line under the value: the comparison, the window, the caveat.
13
+ * A bare number with no denominator is the most common way a dashboard
14
+ * misleads, so this slot exists to make the denominator easy. */
15
+ caption?: ReactNode;
16
+ /** Tints the value. Leave unset unless the number's own colour carries
17
+ * meaning — a wall of coloured tiles makes the one that matters harder
18
+ * to find, which is the opposite of the point. */
19
+ tone?: StatusHue | undefined;
20
+ /** Top-right slot — a sparkline, a `StateChip`, a refresh button. */
21
+ action?: ReactNode;
22
+ className?: string | undefined;
23
+ }
24
+
25
+ /**
26
+ * One headline number with its label and context.
27
+ *
28
+ * Borders, not shadows, and no card chrome of its own beyond a hairline:
29
+ * tiles appear in rows of three to six, and six shadowed boxes is visual
30
+ * noise where six outlined ones read as one instrument panel.
31
+ *
32
+ * `<dl>`/`<dt>`/`<dd>` rather than divs — a tile *is* a term and its
33
+ * definition, and the semantics make a row of them navigable rather than
34
+ * an undifferentiated wall of text to a screen reader.
35
+ */
36
+ export function StatTile({ label, value, caption, tone, action, className }: StatTileProps) {
37
+ return (
38
+ <div
39
+ className={cn(
40
+ "relative flex flex-col rounded-box border border-edge bg-base-300 p-4",
41
+ className,
42
+ )}
43
+ >
44
+ {/* Outside the `<dl>`, and absolutely positioned rather than a flex
45
+ sibling of the `<dt>`. A `<dl>` may contain only `<dt>`, `<dd>`
46
+ and `<div>`s that wrap dt/dd groups — an action button among
47
+ them is invalid markup, which axe reports as a serious
48
+ `definition-list` violation. Found by the a11y addon, after an
49
+ earlier version nested the action in a header row inside the
50
+ list. */}
51
+ {action != null && <div className="absolute top-4 right-4">{action}</div>}
52
+ <dl className="flex flex-col gap-1">
53
+ <dt className={cn("text-caption text-muted-foreground", action != null && "pr-20")}>
54
+ {label}
55
+ </dt>
56
+ <dd
57
+ className={cn(
58
+ "font-mono text-metric tabular-nums",
59
+ tone === undefined ? "text-foreground" : HUE_CLASSES[tone].fg,
60
+ )}
61
+ >
62
+ {value}
63
+ </dd>
64
+ {caption != null && <dd className="text-caption text-subtle-foreground">{caption}</dd>}
65
+ </dl>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useSyncExternalStore } from "react";
4
+ import { cn } from "../../lib/cn";
5
+
6
+ // A single shared 30s interval drives every `TimestampDisplay` instance's
7
+ // relative-time re-render (design doc §7.2: "a table of 200 rows must not
8
+ // run 200 timers"). Lazily started on the first mounted instance, torn
9
+ // down when the last one unmounts.
10
+
11
+ let tick = 0;
12
+ const listeners = new Set<() => void>();
13
+ let timer: ReturnType<typeof setInterval> | null = null;
14
+
15
+ function subscribe(listener: () => void): () => void {
16
+ listeners.add(listener);
17
+ timer ??= setInterval(() => {
18
+ tick += 1;
19
+ for (const l of listeners) l();
20
+ }, 30_000);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ if (listeners.size === 0 && timer !== null) {
24
+ clearInterval(timer);
25
+ timer = null;
26
+ }
27
+ };
28
+ }
29
+
30
+ function useSharedTick(): number {
31
+ return useSyncExternalStore(
32
+ subscribe,
33
+ () => tick,
34
+ () => 0,
35
+ );
36
+ }
37
+
38
+ function pad(n: number): string {
39
+ return String(n).padStart(2, "0");
40
+ }
41
+
42
+ /** `2026-08-08 14:03:07` + a zone suffix — ISO-ordered so it sorts and
43
+ * compares visually (design doc §7.2). Only UTC is implemented (`Z`
44
+ * suffix, always): the Africa/Douala toggle lives on the top bar, which
45
+ * this screen doesn't build. "A bare local time with no zone label never
46
+ * appears anywhere in this product" still holds — the suffix is always
47
+ * present, just not yet switchable. */
48
+ function formatAbsolute(date: Date): string {
49
+ const y = date.getUTCFullYear();
50
+ const mo = pad(date.getUTCMonth() + 1);
51
+ const d = pad(date.getUTCDate());
52
+ const h = pad(date.getUTCHours());
53
+ const mi = pad(date.getUTCMinutes());
54
+ const s = pad(date.getUTCSeconds());
55
+ return `${y}-${mo}-${d} ${h}:${mi}:${s}Z`;
56
+ }
57
+
58
+ function formatRelative(diffMs: number): string {
59
+ const minutes = Math.floor(diffMs / 60_000);
60
+ if (minutes < 1) return "just now";
61
+ if (minutes < 60) return `${minutes}m`;
62
+ const hours = Math.floor(minutes / 60);
63
+ return `${hours}h`;
64
+ }
65
+
66
+ export interface TimestampDisplayProps {
67
+ /** ISO-8601. */
68
+ value: string;
69
+ className?: string;
70
+ }
71
+
72
+ /**
73
+ * Relative for anything under 24h (`2m`, `47m`, `6h`), absolute otherwise
74
+ * — design doc §7.2. Renders the absolute value on the server and on
75
+ * first client render (so server/client markup matches and Next doesn't
76
+ * warn/flash), then upgrades to relative once mounted, driven by the
77
+ * shared interval above rather than its own timer.
78
+ */
79
+ export function TimestampDisplay({ value, className }: TimestampDisplayProps) {
80
+ useSharedTick();
81
+ const [hydrated, setHydrated] = useState(false);
82
+ useEffect(() => setHydrated(true), []);
83
+
84
+ const date = new Date(value);
85
+ const absolute = formatAbsolute(date);
86
+
87
+ if (!hydrated) {
88
+ return (
89
+ <span className={cn("font-mono text-subtle-foreground tabular-nums", className)}>
90
+ {absolute}
91
+ </span>
92
+ );
93
+ }
94
+
95
+ const diffMs = Date.now() - date.getTime();
96
+ const display = diffMs >= 0 && diffMs < 24 * 3600 * 1000 ? formatRelative(diffMs) : absolute;
97
+
98
+ return (
99
+ <span
100
+ title={absolute}
101
+ className={cn("font-mono text-subtle-foreground tabular-nums", className)}
102
+ >
103
+ {display}
104
+ </span>
105
+ );
106
+ }
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ // R6 (AGENTS.md): the same three banner treatments — a neutral scope/notice
5
+ // box, a danger error box, and a plain caption-only note — were hand-rolled
6
+ // inline in every screen that needed one (`rounded-sm border border-edge
7
+ // bg-surface-2 px-3 py-2 text-caption text-muted-foreground` for "neutral",
8
+ // `border-state-danger-border bg-state-danger-bg ... text-state-danger-fg`
9
+ // for "danger"). Classes moved here verbatim, parameterised by variant, not
10
+ // redesigned. `InlineEmptyState` already set the precedent for this shape
11
+ // of extraction (a status treatment repeated per-screen, given one home) —
12
+ // this is the same move applied to a different repeated block.
13
+ //
14
+ // Not to be confused with `InlineEmptyState`: that one is for "there is
15
+ // nothing to show here" inside a list; this one is for a standing notice or
16
+ // an error above/around content that *is* showing.
17
+
18
+ export interface InlineBannerProps {
19
+ /** `'neutral'` — a standing notice (scope, caveat). `'danger'` — an
20
+ * error. `'warning'` — recoverable, needs attention (a stale write).
21
+ * `'success'` — a positive confirmation (a verified audit chain).
22
+ * `'uncertain'` — degraded or unknown, neither failure nor success (a
23
+ * stalled live feed, a UCS-2 volume spike). `'plain'` — a caption-only
24
+ * note with no border or fill, for something that doesn't need the
25
+ * weight of a bordered box.
26
+ *
27
+ * The last three were added after the R6 factorization, not with it:
28
+ * four route groups had hand-rolled boxes in exactly these tones because
29
+ * the variant they needed did not exist, and the agents doing the
30
+ * factorization were deliberately barred from editing this package. Two
31
+ * of them flagged the gap rather than inventing a competing component,
32
+ * which is the behaviour the constraint was for. */
33
+ variant?: "neutral" | "danger" | "warning" | "success" | "uncertain" | "plain";
34
+ children: ReactNode;
35
+ className?: string;
36
+ }
37
+
38
+ export function InlineBanner({ variant = "neutral", children, className }: InlineBannerProps) {
39
+ return (
40
+ <div
41
+ className={cn(
42
+ "text-caption",
43
+ variant === "neutral" &&
44
+ "rounded-sm border border-edge bg-surface-2 px-3 py-2 text-muted-foreground",
45
+ variant === "danger" &&
46
+ "rounded-sm border border-state-danger-border bg-state-danger-bg px-3 py-2 text-state-danger-fg",
47
+ variant === "warning" &&
48
+ "rounded-sm border border-state-warning-border bg-state-warning-bg px-3 py-2 text-state-warning-fg",
49
+ variant === "success" &&
50
+ "rounded-sm border border-state-success-border bg-state-success-bg px-3 py-2 text-state-success-fg",
51
+ variant === "uncertain" &&
52
+ "rounded-sm border border-state-uncertain-border bg-state-uncertain-bg px-3 py-2 text-state-uncertain-fg",
53
+ variant === "plain" && "text-subtle-foreground",
54
+ className,
55
+ )}
56
+ >
57
+ {children}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export interface InlineEmptyStateProps {
5
+ message: ReactNode;
6
+ action?: { label: string; onClick: () => void };
7
+ /** `'standalone'` is the one exception (§5.3): a screen with nothing else
8
+ * to do may centre a single line plus one primary action. Still no
9
+ * illustration, still no card. */
10
+ variant?: "inline" | "standalone";
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Enforces the binding rule: empty states are inline status lines, not
16
+ * centred placards. Use inside a table body (spanning all columns) or in
17
+ * the panel where the missing list would be.
18
+ */
19
+ export function InlineEmptyState({
20
+ message,
21
+ action,
22
+ variant = "inline",
23
+ className,
24
+ }: InlineEmptyStateProps) {
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "flex items-center gap-3 text-body text-muted-foreground",
29
+ variant === "standalone" ? "justify-center py-16 text-center" : "py-3 text-left",
30
+ className,
31
+ )}
32
+ >
33
+ <span>{message}</span>
34
+ {action != null && (
35
+ <button
36
+ type="button"
37
+ onClick={action.onClick}
38
+ className="text-foreground underline decoration-edge-strong underline-offset-2 hover:decoration-foreground"
39
+ >
40
+ {action.label}
41
+ </button>
42
+ )}
43
+ </div>
44
+ );
45
+ }