@dowel-ui/react 0.3.0 → 0.5.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 (206) hide show
  1. package/README.md +13 -8
  2. package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
  3. package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
  4. package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
  5. package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
  6. package/dist/components/ai-disclosure/index.d.ts +2 -0
  7. package/dist/components/ai-disclosure/index.js +2 -0
  8. package/dist/components/ai-disclosure/meta.js +17 -0
  9. package/dist/components/ai-disclosure/meta.js.map +1 -0
  10. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
  11. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
  12. package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
  13. package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
  14. package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
  15. package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
  16. package/dist/components/ai-extraction-review/extraction-model.js +91 -0
  17. package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
  18. package/dist/components/ai-extraction-review/index.d.ts +3 -0
  19. package/dist/components/ai-extraction-review/index.js +3 -0
  20. package/dist/components/ai-extraction-review/meta.js +17 -0
  21. package/dist/components/ai-extraction-review/meta.js.map +1 -0
  22. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
  23. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
  24. package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
  25. package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
  26. package/dist/components/ai-suggested-value/index.d.ts +2 -0
  27. package/dist/components/ai-suggested-value/index.js +2 -0
  28. package/dist/components/ai-suggested-value/meta.js +17 -0
  29. package/dist/components/ai-suggested-value/meta.js.map +1 -0
  30. package/dist/components/alert/alert.d.ts +1 -1
  31. package/dist/components/avatar/avatar.d.ts +1 -1
  32. package/dist/components/badge/badge.d.ts +1 -1
  33. package/dist/components/button/button.d.ts +1 -1
  34. package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
  35. package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
  36. package/dist/components/confirm-typed/confirm-typed.js +106 -0
  37. package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
  38. package/dist/components/confirm-typed/index.d.ts +2 -0
  39. package/dist/components/confirm-typed/index.js +2 -0
  40. package/dist/components/confirm-typed/meta.js +17 -0
  41. package/dist/components/confirm-typed/meta.js.map +1 -0
  42. package/dist/components/cron-editor/cron-editor.d.ts +36 -0
  43. package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
  44. package/dist/components/cron-editor/cron-editor.js +493 -0
  45. package/dist/components/cron-editor/cron-editor.js.map +1 -0
  46. package/dist/components/cron-editor/cron-expression.d.ts +82 -0
  47. package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
  48. package/dist/components/cron-editor/cron-expression.js +352 -0
  49. package/dist/components/cron-editor/cron-expression.js.map +1 -0
  50. package/dist/components/cron-editor/index.d.ts +3 -0
  51. package/dist/components/cron-editor/index.js +3 -0
  52. package/dist/components/cron-editor/meta.js +17 -0
  53. package/dist/components/cron-editor/meta.js.map +1 -0
  54. package/dist/components/date-picker/date-picker.js +1 -1
  55. package/dist/components/diff-viewer/diff-model.d.ts +69 -0
  56. package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
  57. package/dist/components/diff-viewer/diff-model.js +180 -0
  58. package/dist/components/diff-viewer/diff-model.js.map +1 -0
  59. package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
  60. package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
  61. package/dist/components/diff-viewer/diff-viewer.js +212 -0
  62. package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
  63. package/dist/components/diff-viewer/index.d.ts +3 -0
  64. package/dist/components/diff-viewer/index.js +3 -0
  65. package/dist/components/diff-viewer/meta.js +17 -0
  66. package/dist/components/diff-viewer/meta.js.map +1 -0
  67. package/dist/components/dns-record/dns-record.d.ts +61 -0
  68. package/dist/components/dns-record/dns-record.d.ts.map +1 -0
  69. package/dist/components/dns-record/dns-record.js +227 -0
  70. package/dist/components/dns-record/dns-record.js.map +1 -0
  71. package/dist/components/dns-record/index.d.ts +2 -0
  72. package/dist/components/dns-record/index.js +2 -0
  73. package/dist/components/dns-record/meta.js +17 -0
  74. package/dist/components/dns-record/meta.js.map +1 -0
  75. package/dist/components/log-viewer/index.d.ts +3 -0
  76. package/dist/components/log-viewer/index.js +3 -0
  77. package/dist/components/log-viewer/log-stream.d.ts +68 -0
  78. package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
  79. package/dist/components/log-viewer/log-stream.js +120 -0
  80. package/dist/components/log-viewer/log-stream.js.map +1 -0
  81. package/dist/components/log-viewer/log-viewer.d.ts +51 -0
  82. package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
  83. package/dist/components/log-viewer/log-viewer.js +284 -0
  84. package/dist/components/log-viewer/log-viewer.js.map +1 -0
  85. package/dist/components/log-viewer/meta.js +17 -0
  86. package/dist/components/log-viewer/meta.js.map +1 -0
  87. package/dist/components/permission-matrix/index.d.ts +3 -0
  88. package/dist/components/permission-matrix/index.js +3 -0
  89. package/dist/components/permission-matrix/meta.js +17 -0
  90. package/dist/components/permission-matrix/meta.js.map +1 -0
  91. package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
  92. package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
  93. package/dist/components/permission-matrix/permission-matrix.js +199 -0
  94. package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
  95. package/dist/components/permission-matrix/permission-model.d.ts +53 -0
  96. package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
  97. package/dist/components/permission-matrix/permission-model.js +64 -0
  98. package/dist/components/permission-matrix/permission-model.js.map +1 -0
  99. package/dist/components/progress/progress.d.ts +1 -1
  100. package/dist/components/secret-field/index.d.ts +2 -0
  101. package/dist/components/secret-field/index.js +2 -0
  102. package/dist/components/secret-field/meta.js +17 -0
  103. package/dist/components/secret-field/meta.js.map +1 -0
  104. package/dist/components/secret-field/secret-field.d.ts +58 -0
  105. package/dist/components/secret-field/secret-field.d.ts.map +1 -0
  106. package/dist/components/secret-field/secret-field.js +168 -0
  107. package/dist/components/secret-field/secret-field.js.map +1 -0
  108. package/dist/components/session-expiry/index.d.ts +2 -0
  109. package/dist/components/session-expiry/index.js +2 -0
  110. package/dist/components/session-expiry/meta.js +17 -0
  111. package/dist/components/session-expiry/meta.js.map +1 -0
  112. package/dist/components/session-expiry/session-expiry.d.ts +64 -0
  113. package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
  114. package/dist/components/session-expiry/session-expiry.js +185 -0
  115. package/dist/components/session-expiry/session-expiry.js.map +1 -0
  116. package/dist/components/shortcut-recorder/index.d.ts +3 -0
  117. package/dist/components/shortcut-recorder/index.js +3 -0
  118. package/dist/components/shortcut-recorder/meta.js +17 -0
  119. package/dist/components/shortcut-recorder/meta.js.map +1 -0
  120. package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
  121. package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
  122. package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
  123. package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
  124. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
  125. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
  126. package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
  127. package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
  128. package/dist/components/spinner/spinner.d.ts +1 -1
  129. package/dist/components/sync-status/index.d.ts +2 -0
  130. package/dist/components/sync-status/index.js +2 -0
  131. package/dist/components/sync-status/meta.js +17 -0
  132. package/dist/components/sync-status/meta.js.map +1 -0
  133. package/dist/components/sync-status/sync-status.d.ts +65 -0
  134. package/dist/components/sync-status/sync-status.d.ts.map +1 -0
  135. package/dist/components/sync-status/sync-status.js +103 -0
  136. package/dist/components/sync-status/sync-status.js.map +1 -0
  137. package/dist/components/time-range-picker/index.d.ts +3 -0
  138. package/dist/components/time-range-picker/index.js +3 -0
  139. package/dist/components/time-range-picker/meta.js +22 -0
  140. package/dist/components/time-range-picker/meta.js.map +1 -0
  141. package/dist/components/time-range-picker/time-expression.d.ts +88 -0
  142. package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
  143. package/dist/components/time-range-picker/time-expression.js +335 -0
  144. package/dist/components/time-range-picker/time-expression.js.map +1 -0
  145. package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
  146. package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
  147. package/dist/components/time-range-picker/time-range-picker.js +377 -0
  148. package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
  149. package/dist/components/toast/toast.d.ts +1 -1
  150. package/dist/index.d.ts +36 -1
  151. package/dist/index.js +24 -3
  152. package/dist/registry/components.d.ts.map +1 -1
  153. package/dist/registry/components.js +78 -50
  154. package/dist/registry/components.js.map +1 -1
  155. package/package.json +6 -4
  156. package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
  157. package/src/components/ai-disclosure/index.ts +9 -0
  158. package/src/components/ai-disclosure/meta.ts +23 -0
  159. package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
  160. package/src/components/ai-extraction-review/extraction-model.ts +159 -0
  161. package/src/components/ai-extraction-review/index.ts +13 -0
  162. package/src/components/ai-extraction-review/meta.ts +24 -0
  163. package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
  164. package/src/components/ai-suggested-value/index.ts +6 -0
  165. package/src/components/ai-suggested-value/meta.ts +24 -0
  166. package/src/components/confirm-typed/confirm-typed.tsx +191 -0
  167. package/src/components/confirm-typed/index.ts +1 -0
  168. package/src/components/confirm-typed/meta.ts +22 -0
  169. package/src/components/cron-editor/cron-editor.tsx +676 -0
  170. package/src/components/cron-editor/cron-expression.ts +552 -0
  171. package/src/components/cron-editor/index.ts +23 -0
  172. package/src/components/cron-editor/meta.ts +23 -0
  173. package/src/components/diff-viewer/diff-model.ts +256 -0
  174. package/src/components/diff-viewer/diff-viewer.tsx +339 -0
  175. package/src/components/diff-viewer/index.ts +17 -0
  176. package/src/components/diff-viewer/meta.ts +20 -0
  177. package/src/components/dns-record/dns-record.tsx +315 -0
  178. package/src/components/dns-record/index.ts +7 -0
  179. package/src/components/dns-record/meta.ts +21 -0
  180. package/src/components/log-viewer/index.ts +21 -0
  181. package/src/components/log-viewer/log-stream.ts +176 -0
  182. package/src/components/log-viewer/log-viewer.tsx +424 -0
  183. package/src/components/log-viewer/meta.ts +21 -0
  184. package/src/components/permission-matrix/index.ts +14 -0
  185. package/src/components/permission-matrix/meta.ts +23 -0
  186. package/src/components/permission-matrix/permission-matrix.tsx +368 -0
  187. package/src/components/permission-matrix/permission-model.ts +120 -0
  188. package/src/components/secret-field/index.ts +6 -0
  189. package/src/components/secret-field/meta.ts +21 -0
  190. package/src/components/secret-field/secret-field.tsx +284 -0
  191. package/src/components/session-expiry/index.ts +9 -0
  192. package/src/components/session-expiry/meta.ts +23 -0
  193. package/src/components/session-expiry/session-expiry.tsx +321 -0
  194. package/src/components/shortcut-recorder/index.ts +19 -0
  195. package/src/components/shortcut-recorder/meta.ts +22 -0
  196. package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
  197. package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
  198. package/src/components/sync-status/index.ts +9 -0
  199. package/src/components/sync-status/meta.ts +21 -0
  200. package/src/components/sync-status/sync-status.tsx +217 -0
  201. package/src/components/time-range-picker/index.ts +25 -0
  202. package/src/components/time-range-picker/meta.ts +22 -0
  203. package/src/components/time-range-picker/time-expression.ts +491 -0
  204. package/src/components/time-range-picker/time-range-picker.tsx +522 -0
  205. package/src/index.ts +14 -0
  206. package/src/registry/components.ts +28 -0
@@ -0,0 +1,315 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type ComponentPropsWithRef,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { Button } from "@/components/button";
13
+ import { Spinner } from "@/components/spinner";
14
+ import { focusRing } from "@/lib/styles";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ /**
18
+ * "Add this record at your DNS provider."
19
+ *
20
+ * Vercel, Resend, Cloudflare, Postmark, Google Workspace and every product
21
+ * that verifies a domain or routes its mail draws this card by hand, and
22
+ * they all learn the same three things the hard way.
23
+ *
24
+ * The parts are copied separately. A provider's form has a Name field, a
25
+ * Type field and a Value field, so one button that copies the whole line
26
+ * copies something nobody can paste anywhere.
27
+ *
28
+ * The Name field is a trap. Some providers want the host relative to the
29
+ * zone, `_dmarc`; some want the full name, `_dmarc.acme.com`; and some take
30
+ * the relative form and append the zone themselves, so a full name pasted
31
+ * in becomes `_dmarc.acme.com.acme.com` and the check fails for a reason
32
+ * nobody can see. So the host is shown both ways, with the sentence that
33
+ * explains which to use.
34
+ *
35
+ * A failed check says what was found. "Not verified" sends people back to
36
+ * their provider to stare at a record that is correct and has not
37
+ * propagated yet; "found v=spf1 -all, expected v=spf1 include:…" sends them
38
+ * to the typo. And nothing found at all is said as nothing found, with how
39
+ * long that can take, because it is not the same as wrong.
40
+ */
41
+
42
+ export type DnsRecordStatus = "pending" | "checking" | "verified" | "failed";
43
+
44
+ export interface DnsHostForms {
45
+ /** Relative to the zone: `_dmarc`, or `@` for the zone itself. */
46
+ relative: string;
47
+ /** Fully qualified: `_dmarc.acme.com`. */
48
+ full: string;
49
+ }
50
+
51
+ /**
52
+ * The host both ways a provider might want it. Accepts either form and a
53
+ * trailing dot, so the record can be declared however the backend has it.
54
+ */
55
+ export function dnsHostForms(name: string, zone: string): DnsHostForms {
56
+ const cleanZone = zone.replace(/\.$/, "");
57
+ const cleanName = name.replace(/\.$/, "");
58
+ if (cleanName === "@" || cleanName === "" || cleanName === cleanZone) {
59
+ return { relative: "@", full: cleanZone };
60
+ }
61
+ if (cleanName.endsWith(`.${cleanZone}`)) {
62
+ return { relative: cleanName.slice(0, -cleanZone.length - 1), full: cleanName };
63
+ }
64
+ return { relative: cleanName, full: `${cleanName}.${cleanZone}` };
65
+ }
66
+
67
+ export interface DnsRecordProps extends Omit<ComponentPropsWithRef<"section">, "children"> {
68
+ type: string;
69
+ /** The host, relative or fully qualified. */
70
+ name: string;
71
+ value: string;
72
+ /** The zone the record belongs to. Enables the host to be shown both ways. */
73
+ zone?: string;
74
+ ttl?: number | string;
75
+ priority?: number;
76
+ /** What the record is for, in the reader's language: "Proves you own acme.com". */
77
+ purpose?: string;
78
+ status?: DnsRecordStatus;
79
+ /** What the check found instead, when it failed. The useful part. */
80
+ found?: string[];
81
+ checkedAt?: Date;
82
+ locale?: string;
83
+ onCheck?: () => void;
84
+ children?: ReactNode;
85
+ }
86
+
87
+ type CopyState = "idle" | "copied" | "error";
88
+
89
+ export function DnsRecord({
90
+ className,
91
+ type,
92
+ name,
93
+ value,
94
+ zone,
95
+ ttl,
96
+ priority,
97
+ purpose,
98
+ status = "pending",
99
+ found,
100
+ checkedAt,
101
+ locale,
102
+ onCheck,
103
+ children,
104
+ ...props
105
+ }: DnsRecordProps) {
106
+ const id = useId();
107
+ const forms = zone ? dnsHostForms(name, zone) : null;
108
+ const host = forms ? forms.relative : name;
109
+
110
+ // Which part was copied, and whether it worked, so the button that was
111
+ // pressed is the one that says so.
112
+ const [copy, setCopy] = useState<{ part: string; state: CopyState } | null>(null);
113
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
114
+ useEffect(
115
+ () => () => {
116
+ if (timer.current) clearTimeout(timer.current);
117
+ },
118
+ [],
119
+ );
120
+
121
+ const copyPart = (part: string, text: string) => {
122
+ const settle = (state: CopyState) => {
123
+ setCopy({ part, state });
124
+ if (timer.current) clearTimeout(timer.current);
125
+ timer.current = setTimeout(() => {
126
+ setCopy(null);
127
+ }, 2000);
128
+ };
129
+ Promise.resolve()
130
+ .then(() => navigator.clipboard.writeText(text))
131
+ .then(
132
+ () => {
133
+ settle("copied");
134
+ },
135
+ () => {
136
+ settle("error");
137
+ },
138
+ );
139
+ };
140
+
141
+ const statusText =
142
+ status === "verified"
143
+ ? "Verified"
144
+ : status === "checking"
145
+ ? "Checking…"
146
+ : status === "failed"
147
+ ? found && found.length > 0
148
+ ? "Not verified. Something else was found."
149
+ : "Not verified. Nothing found yet — DNS changes can take up to 48 hours to appear."
150
+ : "Not checked yet";
151
+
152
+ const checked =
153
+ checkedAt !== undefined
154
+ ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(
155
+ checkedAt,
156
+ )
157
+ : null;
158
+
159
+ const parts: { key: string; label: string; text: string; note?: ReactNode }[] = [
160
+ { key: "type", label: "Type", text: type },
161
+ {
162
+ key: "name",
163
+ label: "Name",
164
+ text: host,
165
+ note: forms ? (
166
+ forms.relative === "@" ? (
167
+ <>
168
+ Some providers want <code className="font-mono">{forms.full}</code> or leave it
169
+ blank instead of <code className="font-mono">@</code>.
170
+ </>
171
+ ) : (
172
+ <>
173
+ Some providers want the full name, <code className="font-mono">{forms.full}</code>.
174
+ If the field already ends in <code className="font-mono">{zone}</code>, enter just
175
+ the part shown.
176
+ </>
177
+ )
178
+ ) : undefined,
179
+ },
180
+ {
181
+ key: "value",
182
+ label: "Value",
183
+ text: value,
184
+ note:
185
+ type.toUpperCase() === "TXT"
186
+ ? "Enter without quotes; your provider adds them if it needs to."
187
+ : undefined,
188
+ },
189
+ ...(priority !== undefined
190
+ ? [{ key: "priority", label: "Priority", text: String(priority) }]
191
+ : []),
192
+ ...(ttl !== undefined ? [{ key: "ttl", label: "TTL", text: String(ttl) }] : []),
193
+ ];
194
+
195
+ return (
196
+ <section
197
+ data-slot="dns-record"
198
+ data-status={status}
199
+ aria-labelledby={`${id}-heading`}
200
+ aria-busy={status === "checking" || undefined}
201
+ className={cn(
202
+ "flex flex-col gap-3 rounded-lg border border-border bg-card p-4",
203
+ className,
204
+ )}
205
+ {...props}
206
+ >
207
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
208
+ <h3 id={`${id}-heading`} className="text-sm font-medium">
209
+ {type} record
210
+ {purpose ? (
211
+ <span className="font-normal text-muted-foreground"> · {purpose}</span>
212
+ ) : null}
213
+ </h3>
214
+ {/* Status as a word, and announced when it changes: a check was asked
215
+ for, and the answer should arrive where the asker is. */}
216
+ <p
217
+ data-slot="dns-record-status"
218
+ role="status"
219
+ aria-live="polite"
220
+ className={cn(
221
+ "flex items-center gap-1.5 text-xs",
222
+ status === "verified" && "text-success",
223
+ status === "failed" && "text-destructive",
224
+ (status === "pending" || status === "checking") && "text-muted-foreground",
225
+ )}
226
+ >
227
+ {status === "checking" ? <Spinner size="sm" /> : null}
228
+ {statusText}
229
+ {checked && status !== "checking" ? (
230
+ <span className="text-muted-foreground"> · checked {checked}</span>
231
+ ) : null}
232
+ </p>
233
+ </div>
234
+
235
+ {children}
236
+
237
+ <dl className="m-0 grid gap-x-4 gap-y-2 sm:grid-cols-[auto_1fr]">
238
+ {parts.map((part) => (
239
+ <div key={part.key} className="contents">
240
+ <dt className="text-xs font-medium text-muted-foreground sm:pt-1.5">
241
+ {part.label}
242
+ </dt>
243
+ <dd className="m-0 flex flex-col gap-1">
244
+ <div className="flex items-start gap-2">
245
+ <code
246
+ data-slot="dns-record-part"
247
+ data-part={part.key}
248
+ className="min-w-0 flex-1 rounded-md bg-muted px-2 py-1 font-mono text-xs break-all"
249
+ >
250
+ {part.text}
251
+ </code>
252
+ <button
253
+ type="button"
254
+ aria-label={`Copy ${part.label.toLowerCase()}`}
255
+ data-state={copy?.part === part.key ? copy.state : "idle"}
256
+ className={cn(
257
+ "shrink-0 rounded-md border border-input bg-background px-2 py-1 text-xs font-medium",
258
+ "hover:bg-accent hover:text-accent-foreground",
259
+ focusRing,
260
+ )}
261
+ onClick={() => {
262
+ copyPart(part.key, part.text);
263
+ }}
264
+ >
265
+ {copy?.part === part.key && copy.state === "copied" ? "Copied" : "Copy"}
266
+ </button>
267
+ </div>
268
+ {copy?.part === part.key && copy.state === "error" ? (
269
+ <p data-slot="dns-record-copy-error" className="text-xs text-destructive">
270
+ Could not copy. Select it and copy by hand.
271
+ </p>
272
+ ) : null}
273
+ {part.note ? <p className="text-xs text-muted-foreground">{part.note}</p> : null}
274
+ </dd>
275
+ </div>
276
+ ))}
277
+ </dl>
278
+
279
+ {status === "failed" && found && found.length > 0 ? (
280
+ <div
281
+ data-slot="dns-record-found"
282
+ className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs"
283
+ >
284
+ <p className="font-medium text-destructive">Found instead:</p>
285
+ <ul className="m-0 mt-1 list-none p-0">
286
+ {found.map((entry) => (
287
+ <li key={entry}>
288
+ <code className="font-mono break-all">{entry}</code>
289
+ </li>
290
+ ))}
291
+ </ul>
292
+ </div>
293
+ ) : null}
294
+
295
+ {/* A separate region from the verification status, so "Copied name"
296
+ never replaces or interrupts a check result that is being read.
297
+ Present from the start, so the first copy is heard. */}
298
+ <span role="status" aria-live="polite" className="sr-only">
299
+ {copy?.state === "copied"
300
+ ? `Copied ${copy.part}`
301
+ : copy?.state === "error"
302
+ ? `Could not copy ${copy.part}. Select it and copy by hand.`
303
+ : ""}
304
+ </span>
305
+
306
+ {onCheck ? (
307
+ <div className="flex items-center gap-2">
308
+ <Button variant="outline" size="sm" loading={status === "checking"} onClick={onCheck}>
309
+ {status === "verified" ? "Check again" : "Check now"}
310
+ </Button>
311
+ </div>
312
+ ) : null}
313
+ </section>
314
+ );
315
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ DnsRecord,
3
+ dnsHostForms,
4
+ type DnsHostForms,
5
+ type DnsRecordProps,
6
+ type DnsRecordStatus,
7
+ } from "./dns-record";
@@ -0,0 +1,21 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "dns-record",
5
+ title: "DNS Record",
6
+ description:
7
+ "Add this record at your provider: parts copied separately, checked with what was found.",
8
+ category: "data",
9
+ status: "stable",
10
+ dependencies: [],
11
+ registryDependencies: ["button", "spinner"],
12
+ files: ["dns-record.tsx"],
13
+ a11y:
14
+ "The card is a region named by the record's type and purpose. Each part is a definition " +
15
+ "with its own copy button, named by the part it copies, so five Copy buttons are five " +
16
+ "different buttons. Verification status is a word in a polite live region, so the answer to " +
17
+ "a check arrives where it was asked for, and the card is aria-busy while checking. Copy " +
18
+ "results are announced in a separate region, present from the start, so a copy never " +
19
+ "replaces a check result mid-sentence; failure is announced with what to do instead. What " +
20
+ "the check found is a list, not a colour.",
21
+ });
@@ -0,0 +1,21 @@
1
+ export {
2
+ LogViewer,
3
+ LogViewerRow,
4
+ LogViewerToolbar,
5
+ type LogViewerProps,
6
+ type LogViewerRowProps,
7
+ type LogViewerToolbarProps,
8
+ } from "./log-viewer";
9
+ export {
10
+ LOG_LEVELS,
11
+ compileQuery,
12
+ findMatches,
13
+ segment,
14
+ useLogStream,
15
+ type FilterState,
16
+ type LogLevel,
17
+ type LogLine,
18
+ type MatchRange,
19
+ type UseLogStreamOptions,
20
+ type VisibleLine,
21
+ } from "./log-stream";
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+
5
+ /**
6
+ * Filtering and match-finding for a log stream.
7
+ *
8
+ * Separate from the view because it is pure and worth testing without a DOM,
9
+ * and because the awkward parts are here rather than in the rendering: a bad
10
+ * regex must not throw while someone is halfway through typing it, and a match
11
+ * has to be located precisely enough to highlight rather than merely detected.
12
+ */
13
+
14
+ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
15
+
16
+ export const LOG_LEVELS: readonly LogLevel[] = [
17
+ "trace",
18
+ "debug",
19
+ "info",
20
+ "warn",
21
+ "error",
22
+ "fatal",
23
+ ];
24
+
25
+ export interface LogLine {
26
+ id: string;
27
+ message: string;
28
+ level?: LogLevel;
29
+ /** ISO timestamp, or anything the consumer wants shown in the gutter. */
30
+ timestamp?: string;
31
+ /** Structured fields, revealed when the row is expanded. */
32
+ fields?: Record<string, unknown>;
33
+ }
34
+
35
+ /** A [start, end) slice of a message that matched the filter. */
36
+ export type MatchRange = readonly [number, number];
37
+
38
+ export interface FilterState {
39
+ /** Substring, or a pattern when `regex` is on. */
40
+ query: string;
41
+ regex: boolean;
42
+ /** Levels to show. Empty means all of them. */
43
+ levels: Set<LogLevel>;
44
+ }
45
+
46
+ /**
47
+ * Compiles the query.
48
+ *
49
+ * Returns null for an invalid pattern rather than throwing: the query is being
50
+ * typed, so it spends most of its life syntactically incomplete, and a viewer
51
+ * that crashes on "(" is unusable.
52
+ */
53
+ export function compileQuery(query: string, regex: boolean): RegExp | null {
54
+ if (query.length === 0) return null;
55
+ try {
56
+ return regex
57
+ ? new RegExp(query, "gi")
58
+ : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Every match in a line, so each can be highlighted rather than just the first. */
65
+ export function findMatches(message: string, pattern: RegExp | null): MatchRange[] {
66
+ if (!pattern) return [];
67
+
68
+ const ranges: MatchRange[] = [];
69
+ // Fresh lastIndex: the pattern is reused across lines and a global regex
70
+ // carries position between calls, which would skip matches in later lines.
71
+ pattern.lastIndex = 0;
72
+
73
+ let match = pattern.exec(message);
74
+ while (match !== null) {
75
+ // A pattern that can match empty — "a*" — would loop forever otherwise.
76
+ if (match[0].length === 0) {
77
+ pattern.lastIndex += 1;
78
+ } else {
79
+ ranges.push([match.index, match.index + match[0].length]);
80
+ }
81
+ match = pattern.exec(message);
82
+ }
83
+
84
+ return ranges;
85
+ }
86
+
87
+ /** Splits a message into alternating plain and matched segments. */
88
+ export function segment(
89
+ message: string,
90
+ ranges: MatchRange[],
91
+ ): { text: string; match: boolean }[] {
92
+ if (ranges.length === 0) return [{ text: message, match: false }];
93
+
94
+ const parts: { text: string; match: boolean }[] = [];
95
+ let cursor = 0;
96
+
97
+ for (const [start, end] of ranges) {
98
+ if (start > cursor) parts.push({ text: message.slice(cursor, start), match: false });
99
+ parts.push({ text: message.slice(start, end), match: true });
100
+ cursor = end;
101
+ }
102
+ if (cursor < message.length) parts.push({ text: message.slice(cursor), match: false });
103
+
104
+ return parts;
105
+ }
106
+
107
+ export interface VisibleLine extends LogLine {
108
+ matches: MatchRange[];
109
+ }
110
+
111
+ export interface UseLogStreamOptions {
112
+ lines: LogLine[];
113
+ /** Levels present but unchecked are hidden. Empty shows everything. */
114
+ initialLevels?: Set<LogLevel>;
115
+ }
116
+
117
+ export function useLogStream({ lines, initialLevels }: UseLogStreamOptions) {
118
+ const [query, setQuery] = useState("");
119
+ const [regex, setRegex] = useState(false);
120
+ const [levels, setLevels] = useState<Set<LogLevel>>(initialLevels ?? new Set());
121
+
122
+ const pattern = useMemo(() => compileQuery(query, regex), [query, regex]);
123
+
124
+ // An invalid pattern is reported, not swallowed: with no feedback the reader
125
+ // sees an empty log and concludes nothing matched.
126
+ const invalidPattern = regex && query.length > 0 && pattern === null;
127
+
128
+ const visible = useMemo<VisibleLine[]>(() => {
129
+ const result: VisibleLine[] = [];
130
+
131
+ for (const line of lines) {
132
+ if (levels.size > 0 && line.level && !levels.has(line.level)) continue;
133
+
134
+ const matches = findMatches(line.message, pattern);
135
+ // A query with no match hides the line; an invalid pattern hides nothing,
136
+ // because the reader has not finished saying what they want yet.
137
+ if (pattern && matches.length === 0) continue;
138
+
139
+ result.push({ ...line, matches });
140
+ }
141
+
142
+ return result;
143
+ }, [lines, levels, pattern]);
144
+
145
+ const counts = useMemo(() => {
146
+ const byLevel = new Map<LogLevel, number>();
147
+ for (const line of lines) {
148
+ if (!line.level) continue;
149
+ byLevel.set(line.level, (byLevel.get(line.level) ?? 0) + 1);
150
+ }
151
+ return byLevel;
152
+ }, [lines]);
153
+
154
+ const toggleLevel = useCallback((level: LogLevel) => {
155
+ setLevels((current) => {
156
+ const next = new Set(current);
157
+ if (next.has(level)) next.delete(level);
158
+ else next.add(level);
159
+ return next;
160
+ });
161
+ }, []);
162
+
163
+ return {
164
+ query,
165
+ setQuery,
166
+ regex,
167
+ setRegex,
168
+ levels,
169
+ toggleLevel,
170
+ setLevels,
171
+ visible,
172
+ counts,
173
+ invalidPattern,
174
+ total: lines.length,
175
+ };
176
+ }