@marlinjai/clearify 1.11.2 → 1.13.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.
@@ -0,0 +1,414 @@
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import { FormField } from './components/FormField.js';
3
+ import { useToast, ToastContainer } from './components/Toast.js';
4
+ import { Modal } from './components/Modal.js';
5
+ import { ConfigPreview } from './components/ConfigPreview.js';
6
+ import type { ClearifyDataConfig } from '../../types/index.js';
7
+
8
+ type ThemeMode = 'light' | 'dark' | 'auto';
9
+
10
+ interface LinkRow {
11
+ key: string;
12
+ value: string;
13
+ }
14
+
15
+ interface FormState {
16
+ name: string;
17
+ siteUrl: string;
18
+ primaryColor: string;
19
+ mode: ThemeMode;
20
+ logoLight: string;
21
+ logoDark: string;
22
+ links: LinkRow[];
23
+ }
24
+
25
+ function configToForm(data: ClearifyDataConfig): FormState {
26
+ const links: LinkRow[] = [];
27
+ if (data.links) {
28
+ for (const [key, value] of Object.entries(data.links)) {
29
+ if (value !== undefined) links.push({ key, value });
30
+ }
31
+ }
32
+ return {
33
+ name: data.name ?? '',
34
+ siteUrl: data.siteUrl ?? '',
35
+ primaryColor: data.theme?.primaryColor ?? '#6366f1',
36
+ mode: data.theme?.mode ?? 'auto',
37
+ logoLight: data.logo?.light ?? '',
38
+ logoDark: data.logo?.dark ?? '',
39
+ links,
40
+ };
41
+ }
42
+
43
+ function formToConfig(form: FormState): ClearifyDataConfig {
44
+ const links: Record<string, string> = {};
45
+ for (const row of form.links) {
46
+ const k = row.key.trim();
47
+ if (k) links[k] = row.value;
48
+ }
49
+ return {
50
+ name: form.name || undefined,
51
+ siteUrl: form.siteUrl || undefined,
52
+ theme: { primaryColor: form.primaryColor, mode: form.mode },
53
+ logo: form.logoLight || form.logoDark
54
+ ? { light: form.logoLight || undefined, dark: form.logoDark || undefined }
55
+ : undefined,
56
+ links: Object.keys(links).length > 0 ? links : undefined,
57
+ };
58
+ }
59
+
60
+ const inputStyle: React.CSSProperties = {
61
+ width: '100%',
62
+ padding: '0.5rem 0.75rem',
63
+ fontSize: '0.875rem',
64
+ borderRadius: 'var(--clearify-radius-sm)',
65
+ border: '1px solid var(--clearify-border-strong)',
66
+ backgroundColor: 'var(--clearify-bg)',
67
+ color: 'var(--clearify-text)',
68
+ outline: 'none',
69
+ transition: 'border-color 0.15s, box-shadow 0.15s',
70
+ };
71
+
72
+ const sectionHeadingStyle: React.CSSProperties = {
73
+ fontWeight: 600,
74
+ fontSize: '1rem',
75
+ color: 'var(--clearify-text)',
76
+ marginBottom: '0.5rem',
77
+ };
78
+
79
+ const sectionStyle: React.CSSProperties = {
80
+ paddingBottom: '1.5rem',
81
+ marginBottom: '1.5rem',
82
+ borderBottom: '1px solid var(--clearify-border)',
83
+ };
84
+
85
+ const modes: ThemeMode[] = ['light', 'dark', 'auto'];
86
+
87
+ export function SiteSettings() {
88
+ const [form, setForm] = useState<FormState | null>(null);
89
+ const [initial, setInitial] = useState<string>('');
90
+ const [loading, setLoading] = useState(true);
91
+ const [saving, setSaving] = useState(false);
92
+ const { toasts, show } = useToast();
93
+ const [preview, setPreview] = useState<{ before: object; after: object } | null>(null);
94
+
95
+ const fetchConfig = useCallback(async () => {
96
+ try {
97
+ const res = await fetch('/__clearify/api/config/data');
98
+ if (!res.ok) throw new Error('Failed to fetch config');
99
+ const data: ClearifyDataConfig = await res.json();
100
+ const state = configToForm(data);
101
+ setForm(state);
102
+ setInitial(JSON.stringify(state));
103
+ } catch {
104
+ show('Failed to load settings', 'error');
105
+ } finally {
106
+ setLoading(false);
107
+ }
108
+ }, [show]);
109
+
110
+ useEffect(() => {
111
+ fetchConfig();
112
+ }, [fetchConfig]);
113
+
114
+ const hasChanges = form !== null && JSON.stringify(form) !== initial;
115
+
116
+ const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
117
+ setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
118
+ };
119
+
120
+ const updateLink = (index: number, field: 'key' | 'value', val: string) => {
121
+ setForm((prev) => {
122
+ if (!prev) return prev;
123
+ const links = [...prev.links];
124
+ links[index] = { ...links[index], [field]: val };
125
+ return { ...prev, links };
126
+ });
127
+ };
128
+
129
+ const addLink = () => {
130
+ setForm((prev) => (prev ? { ...prev, links: [...prev.links, { key: '', value: '' }] } : prev));
131
+ };
132
+
133
+ const removeLink = (index: number) => {
134
+ setForm((prev) => {
135
+ if (!prev) return prev;
136
+ const links = prev.links.filter((_, i) => i !== index);
137
+ return { ...prev, links };
138
+ });
139
+ };
140
+
141
+ const doSave = useCallback(async () => {
142
+ if (!form) return;
143
+ setSaving(true);
144
+ try {
145
+ const res = await fetch('/__clearify/api/config/data', {
146
+ method: 'PATCH',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify(formToConfig(form)),
149
+ });
150
+ if (!res.ok) throw new Error('Save failed');
151
+ setInitial(JSON.stringify(form));
152
+ show('Settings saved successfully', 'success');
153
+ setPreview(null);
154
+ } catch {
155
+ show('Failed to save settings', 'error');
156
+ } finally {
157
+ setSaving(false);
158
+ }
159
+ }, [form, show]);
160
+
161
+ const handleSave = async () => {
162
+ if (!form) return;
163
+ try {
164
+ const res = await fetch('/__clearify/api/config/data');
165
+ const currentData = await res.json();
166
+ const before = { name: currentData.name, siteUrl: currentData.siteUrl, theme: currentData.theme, logo: currentData.logo, links: currentData.links };
167
+ const after = formToConfig(form);
168
+ setPreview({ before, after });
169
+ } catch {
170
+ doSave();
171
+ }
172
+ };
173
+
174
+ if (loading || !form) {
175
+ return (
176
+ <div style={{ padding: '2rem' }}>
177
+ <h2 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--clearify-text)' }}>
178
+ Site Settings
179
+ </h2>
180
+ <p style={{ color: 'var(--clearify-text-secondary)', marginTop: '0.5rem' }}>Loading...</p>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ return (
186
+ <div style={{ padding: '2rem', maxWidth: '640px' }}>
187
+ <h2
188
+ style={{
189
+ fontSize: '1.5rem',
190
+ fontWeight: 700,
191
+ color: 'var(--clearify-text)',
192
+ marginBottom: '0.25rem',
193
+ }}
194
+ >
195
+ Site Settings
196
+ </h2>
197
+ <p style={{ color: 'var(--clearify-text-secondary)', fontSize: '0.875rem', marginBottom: '2rem' }}>
198
+ Configure your documentation site.
199
+ </p>
200
+
201
+ {/* General */}
202
+ <div style={sectionStyle}>
203
+ <h3 style={sectionHeadingStyle}>General</h3>
204
+ <FormField label="Site name" htmlFor="cfg-name">
205
+ <input
206
+ id="cfg-name"
207
+ type="text"
208
+ style={inputStyle}
209
+ value={form.name}
210
+ onChange={(e) => update('name', e.target.value)}
211
+ placeholder="My Docs"
212
+ />
213
+ </FormField>
214
+ <FormField label="Site URL" htmlFor="cfg-url">
215
+ <input
216
+ id="cfg-url"
217
+ type="text"
218
+ style={inputStyle}
219
+ value={form.siteUrl}
220
+ onChange={(e) => update('siteUrl', e.target.value)}
221
+ placeholder="https://docs.example.com"
222
+ />
223
+ </FormField>
224
+ </div>
225
+
226
+ {/* Theme */}
227
+ <div style={sectionStyle}>
228
+ <h3 style={sectionHeadingStyle}>Theme</h3>
229
+ <FormField label="Primary color">
230
+ <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
231
+ <input
232
+ type="color"
233
+ value={form.primaryColor}
234
+ onChange={(e) => update('primaryColor', e.target.value)}
235
+ style={{
236
+ width: 40,
237
+ height: 36,
238
+ padding: 0,
239
+ border: '1px solid var(--clearify-border-strong)',
240
+ borderRadius: 'var(--clearify-radius-sm)',
241
+ cursor: 'pointer',
242
+ backgroundColor: 'transparent',
243
+ }}
244
+ />
245
+ <input
246
+ type="text"
247
+ value={form.primaryColor}
248
+ onChange={(e) => {
249
+ const v = e.target.value;
250
+ if (/^#[0-9a-fA-F]{0,6}$/.test(v)) update('primaryColor', v);
251
+ }}
252
+ style={{ ...inputStyle, width: '120px', fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}
253
+ />
254
+ </div>
255
+ </FormField>
256
+ <FormField label="Color mode">
257
+ <div style={{ display: 'flex', gap: '0.25rem' }}>
258
+ {modes.map((m) => {
259
+ const active = form.mode === m;
260
+ return (
261
+ <button
262
+ key={m}
263
+ onClick={() => update('mode', m)}
264
+ style={{
265
+ padding: '0.375rem 0.875rem',
266
+ borderRadius: 'var(--clearify-radius-sm)',
267
+ fontSize: '0.8125rem',
268
+ fontWeight: active ? 600 : 450,
269
+ cursor: 'pointer',
270
+ border: active ? 'none' : '1px solid var(--clearify-border)',
271
+ backgroundColor: active ? 'var(--clearify-primary-soft)' : 'transparent',
272
+ color: active ? 'var(--clearify-primary)' : 'var(--clearify-text-secondary)',
273
+ transition: 'background-color 0.15s, color 0.15s',
274
+ }}
275
+ >
276
+ {m.charAt(0).toUpperCase() + m.slice(1)}
277
+ </button>
278
+ );
279
+ })}
280
+ </div>
281
+ </FormField>
282
+ </div>
283
+
284
+ {/* Logo */}
285
+ <div style={sectionStyle}>
286
+ <h3 style={sectionHeadingStyle}>Logo</h3>
287
+ <FormField label="Light logo path" htmlFor="cfg-logo-light">
288
+ <input
289
+ id="cfg-logo-light"
290
+ type="text"
291
+ style={inputStyle}
292
+ value={form.logoLight}
293
+ onChange={(e) => update('logoLight', e.target.value)}
294
+ placeholder="/images/logo-light.svg"
295
+ />
296
+ </FormField>
297
+ <FormField label="Dark logo path" htmlFor="cfg-logo-dark">
298
+ <input
299
+ id="cfg-logo-dark"
300
+ type="text"
301
+ style={inputStyle}
302
+ value={form.logoDark}
303
+ onChange={(e) => update('logoDark', e.target.value)}
304
+ placeholder="/images/logo-dark.svg"
305
+ />
306
+ </FormField>
307
+ </div>
308
+
309
+ {/* Links */}
310
+ <div style={{ marginBottom: '2rem' }}>
311
+ <h3 style={sectionHeadingStyle}>Links</h3>
312
+ {form.links.map((link, i) => (
313
+ <div key={i} style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem', alignItems: 'center' }}>
314
+ <input
315
+ type="text"
316
+ placeholder="Key"
317
+ value={link.key}
318
+ onChange={(e) => updateLink(i, 'key', e.target.value)}
319
+ style={{ ...inputStyle, flex: 1 }}
320
+ />
321
+ <input
322
+ type="text"
323
+ placeholder="URL"
324
+ value={link.value}
325
+ onChange={(e) => updateLink(i, 'value', e.target.value)}
326
+ style={{ ...inputStyle, flex: 2 }}
327
+ />
328
+ <button
329
+ onClick={() => removeLink(i)}
330
+ style={{
331
+ background: 'none',
332
+ border: 'none',
333
+ cursor: 'pointer',
334
+ color: 'var(--clearify-text-tertiary)',
335
+ padding: '0.375rem',
336
+ borderRadius: 'var(--clearify-radius-sm)',
337
+ display: 'flex',
338
+ alignItems: 'center',
339
+ justifyContent: 'center',
340
+ flexShrink: 0,
341
+ transition: 'color 0.15s',
342
+ }}
343
+ title="Remove link"
344
+ >
345
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
346
+ <line x1="18" y1="6" x2="6" y2="18" />
347
+ <line x1="6" y1="6" x2="18" y2="18" />
348
+ </svg>
349
+ </button>
350
+ </div>
351
+ ))}
352
+ <button
353
+ onClick={addLink}
354
+ style={{
355
+ display: 'inline-flex',
356
+ alignItems: 'center',
357
+ gap: '0.375rem',
358
+ padding: '0.375rem 0.75rem',
359
+ borderRadius: 'var(--clearify-radius-sm)',
360
+ fontSize: '0.8125rem',
361
+ fontWeight: 500,
362
+ color: 'var(--clearify-primary)',
363
+ backgroundColor: 'var(--clearify-primary-soft)',
364
+ border: 'none',
365
+ cursor: 'pointer',
366
+ transition: 'background-color 0.15s',
367
+ }}
368
+ >
369
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
370
+ <line x1="12" y1="5" x2="12" y2="19" />
371
+ <line x1="5" y1="12" x2="19" y2="12" />
372
+ </svg>
373
+ Add link
374
+ </button>
375
+ </div>
376
+
377
+ {/* Save */}
378
+ <button
379
+ onClick={handleSave}
380
+ disabled={!hasChanges || saving}
381
+ style={{
382
+ padding: '0.5rem 1.5rem',
383
+ borderRadius: 'var(--clearify-radius-sm)',
384
+ fontSize: '0.875rem',
385
+ fontWeight: 600,
386
+ color: '#fff',
387
+ backgroundColor: hasChanges ? 'var(--clearify-primary)' : 'var(--clearify-text-tertiary)',
388
+ border: 'none',
389
+ cursor: hasChanges ? 'pointer' : 'not-allowed',
390
+ opacity: saving ? 0.7 : 1,
391
+ transition: 'background-color 0.15s, opacity 0.15s',
392
+ }}
393
+ >
394
+ {saving ? 'Saving...' : 'Save'}
395
+ </button>
396
+
397
+ {/* Config preview */}
398
+ <Modal open={preview !== null} onClose={() => setPreview(null)} title="Review Changes">
399
+ {preview && (
400
+ <ConfigPreview
401
+ before={preview.before}
402
+ after={preview.after}
403
+ onConfirm={doSave}
404
+ onCancel={() => setPreview(null)}
405
+ />
406
+ )}
407
+ </Modal>
408
+
409
+ <ToastContainer toasts={toasts} />
410
+ </div>
411
+ );
412
+ }
413
+
414
+ export default SiteSettings;
@@ -0,0 +1,166 @@
1
+ import React from 'react';
2
+
3
+ interface ConfigPreviewProps {
4
+ before: object;
5
+ after: object;
6
+ onConfirm: () => void;
7
+ onCancel: () => void;
8
+ }
9
+
10
+ type DiffLineType = 'unchanged' | 'added' | 'removed';
11
+
12
+ interface DiffLine {
13
+ text: string;
14
+ type: DiffLineType;
15
+ }
16
+
17
+ /**
18
+ * Basic line-by-line diff of two JSON objects.
19
+ * Uses longest-common-subsequence to produce a minimal diff.
20
+ */
21
+ function computeDiff(before: object, after: object): DiffLine[] {
22
+ const beforeLines = JSON.stringify(before, null, 2).split('\n');
23
+ const afterLines = JSON.stringify(after, null, 2).split('\n');
24
+
25
+ // Build LCS table
26
+ const m = beforeLines.length;
27
+ const n = afterLines.length;
28
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
29
+
30
+ for (let i = 1; i <= m; i++) {
31
+ for (let j = 1; j <= n; j++) {
32
+ if (beforeLines[i - 1] === afterLines[j - 1]) {
33
+ dp[i][j] = dp[i - 1][j - 1] + 1;
34
+ } else {
35
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
36
+ }
37
+ }
38
+ }
39
+
40
+ // Backtrack to produce diff
41
+ const result: DiffLine[] = [];
42
+ let i = m;
43
+ let j = n;
44
+
45
+ while (i > 0 || j > 0) {
46
+ if (i > 0 && j > 0 && beforeLines[i - 1] === afterLines[j - 1]) {
47
+ result.push({ text: beforeLines[i - 1], type: 'unchanged' });
48
+ i--;
49
+ j--;
50
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
51
+ result.push({ text: afterLines[j - 1], type: 'added' });
52
+ j--;
53
+ } else {
54
+ result.push({ text: beforeLines[i - 1], type: 'removed' });
55
+ i--;
56
+ }
57
+ }
58
+
59
+ return result.reverse();
60
+ }
61
+
62
+ const lineColors: Record<DiffLineType, { bg: string; color: string; prefix: string }> = {
63
+ unchanged: { bg: 'transparent', color: 'var(--clearify-text-secondary)', prefix: ' ' },
64
+ added: { bg: 'rgba(34, 197, 94, 0.12)', color: 'var(--clearify-text)', prefix: '+ ' },
65
+ removed: { bg: 'rgba(239, 68, 68, 0.12)', color: 'var(--clearify-text)', prefix: '- ' },
66
+ };
67
+
68
+ const buttonBase: React.CSSProperties = {
69
+ padding: '0.5rem 1rem',
70
+ borderRadius: 'var(--clearify-radius-sm)',
71
+ fontSize: '0.8125rem',
72
+ fontWeight: 600,
73
+ cursor: 'pointer',
74
+ border: '1px solid var(--clearify-border)',
75
+ transition: 'background-color 0.15s, color 0.15s',
76
+ };
77
+
78
+ export function ConfigPreview({ before, after, onConfirm, onCancel }: ConfigPreviewProps) {
79
+ const lines = computeDiff(before, after);
80
+ const hasChanges = lines.some((l) => l.type !== 'unchanged');
81
+
82
+ return (
83
+ <div>
84
+ <p
85
+ style={{
86
+ fontSize: '0.8125rem',
87
+ color: 'var(--clearify-text-secondary)',
88
+ marginBottom: '0.75rem',
89
+ }}
90
+ >
91
+ {hasChanges
92
+ ? 'Review the configuration changes below before saving.'
93
+ : 'No changes detected.'}
94
+ </p>
95
+
96
+ <div
97
+ style={{
98
+ maxHeight: '50vh',
99
+ overflow: 'auto',
100
+ borderRadius: 'var(--clearify-radius-sm)',
101
+ border: '1px solid var(--clearify-border)',
102
+ backgroundColor: 'var(--clearify-bg)',
103
+ marginBottom: '1rem',
104
+ }}
105
+ >
106
+ <pre
107
+ style={{
108
+ margin: 0,
109
+ padding: '0.75rem',
110
+ fontFamily: 'var(--font-mono, monospace)',
111
+ fontSize: '0.8125rem',
112
+ lineHeight: 1.6,
113
+ whiteSpace: 'pre-wrap',
114
+ wordBreak: 'break-word',
115
+ }}
116
+ >
117
+ {lines.map((line, i) => {
118
+ const { bg, color, prefix } = lineColors[line.type];
119
+ return (
120
+ <div
121
+ key={i}
122
+ style={{
123
+ backgroundColor: bg,
124
+ color,
125
+ padding: '0 0.25rem',
126
+ marginLeft: '-0.25rem',
127
+ marginRight: '-0.25rem',
128
+ borderRadius: 2,
129
+ }}
130
+ >
131
+ <span style={{ opacity: 0.5, userSelect: 'none' }}>{prefix}</span>
132
+ {line.text}
133
+ </div>
134
+ );
135
+ })}
136
+ </pre>
137
+ </div>
138
+
139
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
140
+ <button
141
+ onClick={onCancel}
142
+ style={{
143
+ ...buttonBase,
144
+ backgroundColor: 'var(--clearify-bg-secondary)',
145
+ color: 'var(--clearify-text)',
146
+ }}
147
+ >
148
+ Cancel
149
+ </button>
150
+ <button
151
+ onClick={onConfirm}
152
+ disabled={!hasChanges}
153
+ style={{
154
+ ...buttonBase,
155
+ backgroundColor: hasChanges ? 'var(--clearify-primary)' : 'var(--clearify-text-tertiary)',
156
+ color: '#fff',
157
+ border: 'none',
158
+ cursor: hasChanges ? 'pointer' : 'not-allowed',
159
+ }}
160
+ >
161
+ Confirm &amp; Save
162
+ </button>
163
+ </div>
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,73 @@
1
+ import React from 'react';
2
+ import { Modal } from './Modal.js';
3
+
4
+ interface ConfirmDialogProps {
5
+ open: boolean;
6
+ onClose: () => void;
7
+ onConfirm: () => void;
8
+ title: string;
9
+ message: string;
10
+ confirmLabel?: string;
11
+ danger?: boolean;
12
+ }
13
+
14
+ const buttonBase: React.CSSProperties = {
15
+ padding: '0.5rem 1rem',
16
+ borderRadius: 'var(--clearify-radius-sm)',
17
+ fontSize: '0.8125rem',
18
+ fontWeight: 600,
19
+ cursor: 'pointer',
20
+ border: '1px solid var(--clearify-border)',
21
+ transition: 'background-color 0.15s, color 0.15s',
22
+ };
23
+
24
+ export function ConfirmDialog({
25
+ open,
26
+ onClose,
27
+ onConfirm,
28
+ title,
29
+ message,
30
+ confirmLabel = 'Confirm',
31
+ danger = false,
32
+ }: ConfirmDialogProps) {
33
+ return (
34
+ <Modal open={open} onClose={onClose} title={title}>
35
+ <p
36
+ style={{
37
+ fontSize: '0.875rem',
38
+ color: 'var(--clearify-text-secondary)',
39
+ marginBottom: '1.25rem',
40
+ lineHeight: 1.5,
41
+ }}
42
+ >
43
+ {message}
44
+ </p>
45
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
46
+ <button
47
+ onClick={onClose}
48
+ style={{
49
+ ...buttonBase,
50
+ backgroundColor: 'var(--clearify-bg-secondary)',
51
+ color: 'var(--clearify-text)',
52
+ }}
53
+ >
54
+ Cancel
55
+ </button>
56
+ <button
57
+ onClick={() => {
58
+ onConfirm();
59
+ onClose();
60
+ }}
61
+ style={{
62
+ ...buttonBase,
63
+ backgroundColor: danger ? '#dc2626' : 'var(--clearify-primary)',
64
+ color: '#fff',
65
+ border: 'none',
66
+ }}
67
+ >
68
+ {confirmLabel}
69
+ </button>
70
+ </div>
71
+ </Modal>
72
+ );
73
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+
3
+ interface FormFieldProps {
4
+ label: string;
5
+ error?: string;
6
+ children: React.ReactNode;
7
+ htmlFor?: string;
8
+ }
9
+
10
+ export function FormField({ label, error, children, htmlFor }: FormFieldProps) {
11
+ return (
12
+ <div style={{ marginBottom: '1rem' }}>
13
+ <label
14
+ htmlFor={htmlFor}
15
+ style={{
16
+ display: 'block',
17
+ fontSize: '0.8125rem',
18
+ fontWeight: 500,
19
+ color: 'var(--clearify-text-secondary)',
20
+ marginBottom: '0.375rem',
21
+ }}
22
+ >
23
+ {label}
24
+ </label>
25
+ {children}
26
+ {error && (
27
+ <p
28
+ style={{
29
+ fontSize: '0.75rem',
30
+ color: '#dc2626',
31
+ marginTop: '0.25rem',
32
+ }}
33
+ >
34
+ {error}
35
+ </p>
36
+ )}
37
+ </div>
38
+ );
39
+ }