@riceawa/dsh-lan-gateway 0.3.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,603 @@
1
+ /**
2
+ * The lan-gateway settings card shown in the official DSH Settings → Plugins
3
+ * page (the `settings.plugin.item` slot).
4
+ *
5
+ * ModLens-style: the card carries NO injected services. It reads and writes
6
+ * the loopback-only `/lan-gateway/config` host route (the browser never sees
7
+ * the settings seam or any secret), so the client bundle's only dependency is
8
+ * the `slots` service that every plugin already has.
9
+ *
10
+ * @module @riceawa/dsh-lan-gateway/client/card
11
+ */
12
+
13
+ import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
14
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
15
+
16
+ /**
17
+ * The official Settings → Plugins page declares the `settings.plugin.item`
18
+ * list slot (kind list, root scope, empty owner share) in its own package.
19
+ * The published package ships no `src/`, so the entry is re-declared here —
20
+ * the runtime slot is real; this only restores the compile-time table.
21
+ */
22
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
23
+ interface SlotMap {
24
+ /** One plugin's card inside the plugin configuration section. */
25
+ 'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
26
+ }
27
+ }
28
+
29
+ /** Props the renderer binds for this card (unused — the card is self-loading). */
30
+ export type LanGatewayCardProps = PropsRuntime<'settings.plugin.item'>
31
+
32
+ /** The wire shape of the `lan-gateway` config section. */
33
+ export interface LanGatewaySettings {
34
+ enabled?: boolean
35
+ gatewayPort?: number
36
+ dshTargetPort?: number
37
+ lanCidrs?: string[]
38
+ authRequired?: boolean
39
+ cookieMaxAgeDays?: number
40
+ tlsEnabled?: boolean
41
+ tlsMode?: 'self-signed' | 'custom'
42
+ tlsCertPath?: string
43
+ tlsKeyPath?: string
44
+ tlsSelfSignedHosts?: string
45
+ tlsCertMaxAgeDays?: number
46
+ }
47
+
48
+ /** GET /lan-gateway/config response. */
49
+ interface RouteState {
50
+ config: LanGatewaySettings
51
+ running: boolean
52
+ port: number
53
+ tls: string
54
+ lastError: string | null
55
+ }
56
+
57
+ /* ------------------------------------------------------------------ */
58
+ /* Bilingual copy (ModLens-style: two small sets, picked by browser) */
59
+ /* ------------------------------------------------------------------ */
60
+
61
+ interface Labels {
62
+ title: string
63
+ description: string
64
+ unsaved: string
65
+ save: string
66
+ saving: string
67
+ discard: string
68
+ reset: string
69
+ overridden: string
70
+ readOnly: string
71
+ saveFailed: string
72
+ loadFailed: string
73
+ emptyMeansClear: string
74
+ running: string
75
+ stopped: string
76
+ tls: string
77
+ lastError: string
78
+ [key: `field.${string}`]: string
79
+ [key: `hint.${string}`]: string
80
+ }
81
+
82
+ const LABELS: Record<'zh' | 'en', Labels> = {
83
+ zh: {
84
+ title: 'LAN 网关',
85
+ description: '远程访问开关、端口、TLS 证书、受信网段等网关设置',
86
+ unsaved: '未保存',
87
+ save: '保存',
88
+ saving: '保存中…',
89
+ discard: '放弃',
90
+ reset: '重置',
91
+ overridden: '已覆盖',
92
+ readOnly: '网关设置当前不可用(读不到配置路由)。',
93
+ saveFailed: '保存未生效,请检查输入后重试。',
94
+ loadFailed: '加载网关配置失败。',
95
+ emptyMeansClear: '留空 = 使用默认',
96
+ running: '运行中',
97
+ stopped: '已停止',
98
+ tls: 'TLS',
99
+ lastError: '上次错误',
100
+ 'field.enabled': '启用网关',
101
+ 'hint.enabled': '启动时监听 0.0.0.0 网关端口',
102
+ 'field.gatewayPort': '网关端口',
103
+ 'hint.gatewayPort': '绑定到 0.0.0.0 的监听端口(默认 3081)',
104
+ 'field.dshTargetPort': 'dsh 目标端口',
105
+ 'hint.dshTargetPort': '留空则自动跟随 dsh web 端口(默认 3080)',
106
+ 'field.lanCidrs': '免密 LAN 网段',
107
+ 'hint.lanCidrs': '逗号分隔的 CIDR,如 10.0.0.0/8, 192.168.0.0/16',
108
+ 'field.authRequired': '非 LAN 访问需要密码',
109
+ 'hint.authRequired': '公网来源必须登录后才能访问',
110
+ 'field.cookieMaxAgeDays': '会话有效期(天)',
111
+ 'hint.cookieMaxAgeDays': '登录 cookie 的存活天数(默认 7)',
112
+ 'field.tlsEnabled': '启用 TLS(HTTPS)',
113
+ 'hint.tlsEnabled': '以 HTTPS 提供网关服务',
114
+ 'field.tlsMode': '证书来源',
115
+ 'hint.tlsMode': 'self-signed = 自动生成自签名证书;custom = 使用自己的证书',
116
+ 'field.tlsSelfSignedHosts': '自签名证书域名/IP',
117
+ 'hint.tlsSelfSignedHosts': '逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5',
118
+ 'field.tlsCertPath': '证书文件路径(custom)',
119
+ 'hint.tlsCertPath': 'PEM 格式证书(或证书链)的绝对路径',
120
+ 'field.tlsKeyPath': '私钥文件路径(custom)',
121
+ 'hint.tlsKeyPath': '与证书配套的 PEM 私钥绝对路径',
122
+ 'field.tlsCertMaxAgeDays': '自签名证书有效期(天)',
123
+ 'hint.tlsCertMaxAgeDays': '默认 825(约 27 个月)',
124
+ },
125
+ en: {
126
+ title: 'LAN Gateway',
127
+ description: 'Remote-access switch, port, TLS certificate, trusted CIDRs and more',
128
+ unsaved: 'Unsaved',
129
+ save: 'Save',
130
+ saving: 'Saving…',
131
+ discard: 'Discard',
132
+ reset: 'Reset',
133
+ overridden: 'overridden',
134
+ readOnly: 'Gateway settings unavailable (config route unreachable).',
135
+ saveFailed: 'The save did not land — check the inputs and retry.',
136
+ loadFailed: 'Failed to load gateway configuration.',
137
+ emptyMeansClear: 'Empty = default',
138
+ running: 'Running',
139
+ stopped: 'Stopped',
140
+ tls: 'TLS',
141
+ lastError: 'Last error',
142
+ 'field.enabled': 'Enable gateway',
143
+ 'hint.enabled': 'Listen on the gateway port at boot',
144
+ 'field.gatewayPort': 'Gateway port',
145
+ 'hint.gatewayPort': 'Port bound on 0.0.0.0 (default 3081)',
146
+ 'field.dshTargetPort': 'dsh target port',
147
+ 'hint.dshTargetPort': 'Leave empty to follow the dsh web port (default 3080)',
148
+ 'field.lanCidrs': 'Password-free LAN CIDRs',
149
+ 'hint.lanCidrs': 'Comma separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16',
150
+ 'field.authRequired': 'Password required for non-LAN',
151
+ 'hint.authRequired': 'Internet sources must sign in before reaching the GUI',
152
+ 'field.cookieMaxAgeDays': 'Session lifetime (days)',
153
+ 'hint.cookieMaxAgeDays': 'Login cookie lifetime (default 7)',
154
+ 'field.tlsEnabled': 'Enable TLS (HTTPS)',
155
+ 'hint.tlsEnabled': 'Serve the gateway over HTTPS',
156
+ 'field.tlsMode': 'Certificate source',
157
+ 'hint.tlsMode': 'self-signed = auto-generated certificate; custom = your own files',
158
+ 'field.tlsSelfSignedHosts': 'Self-signed hosts (SANs)',
159
+ 'hint.tlsSelfSignedHosts': 'Comma separated DNS/IP names, e.g. localhost, 192.168.1.5',
160
+ 'field.tlsCertPath': 'Certificate path (custom)',
161
+ 'hint.tlsCertPath': 'Absolute path to a PEM certificate (or chain)',
162
+ 'field.tlsKeyPath': 'Private key path (custom)',
163
+ 'hint.tlsKeyPath': 'Absolute path to the matching PEM private key',
164
+ 'field.tlsCertMaxAgeDays': 'Self-signed validity (days)',
165
+ 'hint.tlsCertMaxAgeDays': 'Default 825 (about 27 months)',
166
+ },
167
+ }
168
+
169
+ function labels(): Labels {
170
+ const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en').toLowerCase()
171
+ return lang.startsWith('zh') ? LABELS.zh : LABELS.en
172
+ }
173
+
174
+ /* ------------------------------------------------------------------ */
175
+ /* Field model */
176
+ /* ------------------------------------------------------------------ */
177
+
178
+ type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select'
179
+
180
+ interface FieldDef {
181
+ field: keyof LanGatewaySettings
182
+ kind: FieldKind
183
+ optional?: boolean
184
+ options?: readonly string[]
185
+ }
186
+
187
+ const FIELDS: readonly FieldDef[] = [
188
+ { field: 'enabled', kind: 'boolean' },
189
+ { field: 'gatewayPort', kind: 'number' },
190
+ { field: 'dshTargetPort', kind: 'number', optional: true },
191
+ { field: 'lanCidrs', kind: 'cidrs' },
192
+ { field: 'authRequired', kind: 'boolean' },
193
+ { field: 'cookieMaxAgeDays', kind: 'number' },
194
+ { field: 'tlsEnabled', kind: 'boolean' },
195
+ { field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
196
+ { field: 'tlsSelfSignedHosts', kind: 'text' },
197
+ { field: 'tlsCertPath', kind: 'text', optional: true },
198
+ { field: 'tlsKeyPath', kind: 'text', optional: true },
199
+ { field: 'tlsCertMaxAgeDays', kind: 'number' },
200
+ ]
201
+
202
+ function formatValue(def: FieldDef, value: unknown): string {
203
+ switch (def.kind) {
204
+ case 'boolean': return value === true ? 'true' : 'false'
205
+ case 'number': return typeof value === 'number' ? String(value) : ''
206
+ case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
207
+ case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
208
+ case 'text': return typeof value === 'string' ? value : ''
209
+ }
210
+ }
211
+
212
+ type Write = { kind: 'set'; value: unknown } | { kind: 'clear' }
213
+
214
+ /** Parse draft text into a value for the POST body; undefined blocks saving. */
215
+ function parseValue(def: FieldDef, text: string): Write | undefined {
216
+ const trimmed = text.trim()
217
+ switch (def.kind) {
218
+ case 'boolean':
219
+ if (trimmed === 'true') return { kind: 'set', value: true }
220
+ if (trimmed === 'false') return { kind: 'set', value: false }
221
+ return undefined
222
+ case 'number':
223
+ if (trimmed === '') return def.optional ? { kind: 'clear' } : undefined
224
+ if (!/^\d+$/.test(trimmed)) return undefined
225
+ return { kind: 'set', value: Number(trimmed) }
226
+ case 'cidrs': {
227
+ const cidrs = trimmed.split(',').map(s => s.trim()).filter(s => s !== '')
228
+ return cidrs.length === 0 ? { kind: 'clear' } : { kind: 'set', value: cidrs }
229
+ }
230
+ case 'select':
231
+ return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
232
+ case 'text':
233
+ return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
234
+ }
235
+ }
236
+
237
+ /* ------------------------------------------------------------------ */
238
+ /* Card */
239
+ /* ------------------------------------------------------------------ */
240
+
241
+ /**
242
+ * Render the LAN gateway card. Self-loading: fetches the config route on
243
+ * mount, posts the edited config on save.
244
+ * @param _props - unused; the card needs no injected face.
245
+ * @returns the card, or nothing while the route is unreachable.
246
+ */
247
+ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
248
+ const t = labels()
249
+ const [open, setOpen] = useState(false)
250
+ const [route, setRoute] = useState<RouteState | null>(null)
251
+ const [loadFailed, setLoadFailed] = useState(false)
252
+ const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
253
+ const [saving, setSaving] = useState(false)
254
+ const [failed, setFailed] = useState<string | null>(null)
255
+
256
+ useEffect(() => {
257
+ let cancelled = false
258
+ fetch('/lan-gateway/config')
259
+ .then(async (response) => {
260
+ if (cancelled) return
261
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
262
+ setRoute(await response.json() as RouteState)
263
+ })
264
+ .catch(() => {
265
+ if (!cancelled) setLoadFailed(true)
266
+ })
267
+ return () => { cancelled = true }
268
+ }, [])
269
+
270
+ if (loadFailed) return null
271
+ if (route === null) return null
272
+
273
+ const { config } = route
274
+ const draftOf = (field: keyof LanGatewaySettings): string =>
275
+ drafts[field] ?? formatValue(FIELDS.find(f => f.field === field)!, config[field])
276
+
277
+ const stage = (field: string, text: string): void => {
278
+ setDrafts(prev => ({ ...prev, [field]: text }))
279
+ setFailed(null)
280
+ }
281
+
282
+ const resetField = (def: FieldDef): void => {
283
+ setDrafts(prev => {
284
+ const next = { ...prev }
285
+ delete next[def.field]
286
+ return next
287
+ })
288
+ }
289
+
290
+ const discard = (): void => {
291
+ setDrafts({})
292
+ setFailed(null)
293
+ }
294
+
295
+ const invalid = (): boolean =>
296
+ Object.entries(drafts).some(([field, text]) => {
297
+ const def = FIELDS.find(f => f.field === field)
298
+ return def === undefined || parseValue(def, text ?? '') === undefined
299
+ })
300
+
301
+ const dirty = Object.keys(drafts).length > 0
302
+
303
+ const save = async (): Promise<void> => {
304
+ if (!dirty || saving || invalid()) return
305
+ setSaving(true)
306
+ setFailed(null)
307
+ try {
308
+ // Build the next full config: the loaded one with drafts applied.
309
+ const next: Record<string, unknown> = {}
310
+ for (const def of FIELDS) {
311
+ const text = drafts[def.field] ?? formatValue(def, config[def.field])
312
+ const write = parseValue(def, text)
313
+ if (write === undefined) continue
314
+ next[def.field] = write.kind === 'clear' ? null : write.value
315
+ }
316
+ const response = await fetch('/lan-gateway/config', {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify(next),
320
+ })
321
+ const body = await response.json().catch(() => ({})) as Partial<RouteState> & { error?: string }
322
+ if (!response.ok) {
323
+ setFailed(body.error ?? `HTTP ${response.status}`)
324
+ return
325
+ }
326
+ if (body.config !== undefined) {
327
+ setRoute({
328
+ config: body.config,
329
+ running: body.running ?? false,
330
+ port: body.port ?? 0,
331
+ tls: body.tls ?? '',
332
+ lastError: body.lastError ?? null,
333
+ })
334
+ }
335
+ setDrafts({})
336
+ } catch {
337
+ setFailed(t.saveFailed)
338
+ } finally {
339
+ setSaving(false)
340
+ }
341
+ }
342
+
343
+ const renderControl = (def: FieldDef): ReactNode => {
344
+ const field = def.field
345
+ const label = t[`field.${field}`]
346
+ const hint = t[`hint.${field}`]
347
+ const text = draftOf(field)
348
+ switch (def.kind) {
349
+ case 'boolean':
350
+ return (
351
+ <div style={styles.field}>
352
+ <label style={styles.checkRow}>
353
+ <input
354
+ type="checkbox"
355
+ checked={text === 'true'}
356
+ disabled={saving}
357
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
358
+ stage(field, e.target.checked ? 'true' : 'false')}
359
+ />
360
+ <span style={styles.label}>{label}</span>
361
+ <button
362
+ type="button"
363
+ style={styles.reset}
364
+ disabled={saving || !drafts[field]}
365
+ onClick={() => resetField(def)}
366
+ >
367
+ {t.reset}
368
+ </button>
369
+ </label>
370
+ <span style={styles.hint}>{hint}</span>
371
+ </div>
372
+ )
373
+ case 'select':
374
+ return (
375
+ <div style={styles.field}>
376
+ <label style={styles.label} htmlFor={`lan-gw-${field}`}>{label}</label>
377
+ <select
378
+ id={`lan-gw-${field}`}
379
+ style={styles.input}
380
+ value={text}
381
+ disabled={saving}
382
+ onChange={(e: ChangeEvent<HTMLSelectElement>) => stage(field, e.target.value)}
383
+ >
384
+ {def.options?.map(option => <option key={option} value={option}>{option}</option>)}
385
+ </select>
386
+ <span style={styles.hint}>{hint}</span>
387
+ <button
388
+ type="button"
389
+ style={styles.reset}
390
+ disabled={saving || !drafts[field]}
391
+ onClick={() => resetField(def)}
392
+ >
393
+ {t.reset}
394
+ </button>
395
+ </div>
396
+ )
397
+ default:
398
+ return (
399
+ <div style={styles.field}>
400
+ <label style={styles.label} htmlFor={`lan-gw-${field}`}>{label}</label>
401
+ <input
402
+ id={`lan-gw-${field}`}
403
+ style={styles.input}
404
+ type={def.kind === 'number' ? 'number' : 'text'}
405
+ value={text}
406
+ disabled={saving}
407
+ placeholder={def.optional ? t.emptyMeansClear : undefined}
408
+ onChange={(e: ChangeEvent<HTMLInputElement>) => stage(field, e.target.value)}
409
+ />
410
+ <span style={styles.hint}>{hint}</span>
411
+ </div>
412
+ )
413
+ }
414
+ }
415
+
416
+ const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
417
+
418
+ return (
419
+ <li style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
420
+ <button
421
+ type="button"
422
+ style={styles.header}
423
+ aria-expanded={open}
424
+ onClick={() => { setOpen(!open) }}
425
+ >
426
+ <span style={styles.headText}>
427
+ <span style={styles.name}>{t.title}</span>
428
+ <span style={styles.description}>{t.description}</span>
429
+ </span>
430
+ <span style={styles.status}>{statusLine}</span>
431
+ {dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
432
+ <span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
433
+ </button>
434
+ {open
435
+ ? (
436
+ <div style={styles.body}>
437
+ {route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
438
+ {FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
439
+ <div style={styles.footer}>
440
+ {failed ? <p style={styles.error} role="status">{failed}</p> : null}
441
+ <button
442
+ type="button"
443
+ style={styles.discard}
444
+ disabled={!dirty || saving}
445
+ onClick={discard}
446
+ >
447
+ {t.discard}
448
+ </button>
449
+ <button
450
+ type="button"
451
+ style={styles.save}
452
+ disabled={!dirty || invalid() || saving}
453
+ onClick={() => { void save() }}
454
+ >
455
+ {saving ? t.saving : t.save}
456
+ </button>
457
+ </div>
458
+ </div>
459
+ )
460
+ : null}
461
+ </li>
462
+ )
463
+ }
464
+
465
+ /* ------------------------------------------------------------------ */
466
+ /* Styling — the official DSH theme tokens (light/dark aware), with */
467
+ /* neutral fallbacks so the card never renders black-on-black or */
468
+ /* white-on-white even if a token is missing. */
469
+ /* ------------------------------------------------------------------ */
470
+
471
+ /** Theme token with a fallback for token-less environments. */
472
+ function tk(token: string, fallback: string): string {
473
+ return `var(${token}, ${fallback})`
474
+ }
475
+
476
+ const L = {
477
+ border: tk('--dsw-alias-border-l2', 'rgba(127,127,127,0.35)'),
478
+ bg: tk('--dsw-alias-bg-layer-3', 'transparent'),
479
+ bgOpen: tk('--dsw-alias-bg-layer-2', 'transparent'),
480
+ labelPrimary: tk('--dsw-alias-label-primary', 'inherit'),
481
+ labelSecondary: tk('--dsw-alias-label-secondary', 'inherit'),
482
+ labelTertiary: tk('--dsw-alias-label-tertiary', 'rgba(127,127,127,0.8)'),
483
+ labelDimmed: tk('--dsw-alias-label-dimmed', 'rgba(127,127,127,0.6)'),
484
+ error: tk('--dsw-alias-label-error', '#d1242f'),
485
+ brand: tk('--dsw-alias-brand-primary', '#4f6ef7'),
486
+ badgeBg: tk('--dsw-alias-bg-module-platform', 'rgba(127,127,127,0.14)'),
487
+ }
488
+
489
+ const styles: Record<string, React.CSSProperties> = {
490
+ card: {
491
+ listStyle: 'none',
492
+ border: `1px solid ${L.border}`,
493
+ borderRadius: '12px',
494
+ background: L.bg,
495
+ transition: 'border-color .16s, background .16s',
496
+ overflow: 'hidden',
497
+ },
498
+ cardOpen: {
499
+ background: L.bgOpen,
500
+ borderColor: L.labelDimmed,
501
+ },
502
+ header: {
503
+ display: 'flex',
504
+ alignItems: 'center',
505
+ gap: '12px',
506
+ width: '100%',
507
+ padding: '14px 16px',
508
+ border: 0,
509
+ background: 'none',
510
+ font: 'inherit',
511
+ color: 'inherit',
512
+ textAlign: 'left',
513
+ cursor: 'pointer',
514
+ },
515
+ headText: { display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 },
516
+ name: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
517
+ description: { fontSize: '13px', lineHeight: 1.5, color: L.labelTertiary },
518
+ status: { fontSize: '11px', color: L.labelTertiary, whiteSpace: 'nowrap' },
519
+ pending: {
520
+ flex: 'none',
521
+ borderRadius: '999px',
522
+ padding: '1px 8px',
523
+ fontSize: '11px',
524
+ lineHeight: '17px',
525
+ fontWeight: 500,
526
+ whiteSpace: 'nowrap',
527
+ background: L.badgeBg,
528
+ color: L.labelSecondary,
529
+ },
530
+ chevron: { flex: 'none', color: L.labelTertiary, fontSize: '12px', transition: 'transform .16s' },
531
+ chevronOpen: { transform: 'rotate(180deg)' },
532
+ body: {
533
+ borderTop: `1px solid ${L.border}`,
534
+ margin: '0 16px',
535
+ paddingBottom: '8px',
536
+ display: 'flex',
537
+ flexDirection: 'column',
538
+ },
539
+ field: {
540
+ display: 'flex',
541
+ flexDirection: 'column',
542
+ gap: '6px',
543
+ padding: '12px 0',
544
+ },
545
+ label: { fontSize: '13px', fontWeight: 500, lineHeight: 1.5, color: L.labelPrimary },
546
+ hint: { margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.labelTertiary },
547
+ checkRow: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' },
548
+ reset: {
549
+ border: 'none',
550
+ background: 'none',
551
+ padding: 0,
552
+ font: 'inherit',
553
+ fontSize: '12px',
554
+ lineHeight: 1.5,
555
+ color: L.labelSecondary,
556
+ cursor: 'pointer',
557
+ alignSelf: 'flex-start',
558
+ },
559
+ input: {
560
+ height: '34px',
561
+ padding: '0 12px',
562
+ border: `1px solid ${L.border}`,
563
+ borderRadius: '8px',
564
+ background: L.bg,
565
+ font: 'inherit',
566
+ fontSize: '13px',
567
+ lineHeight: 1.5,
568
+ color: L.labelPrimary,
569
+ },
570
+ footer: {
571
+ display: 'flex',
572
+ alignItems: 'center',
573
+ justifyContent: 'flex-end',
574
+ gap: '8px',
575
+ padding: '12px 0 4px',
576
+ borderTop: `1px solid ${L.border}`,
577
+ },
578
+ error: { flex: 1, minWidth: 0, margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.error },
579
+ discard: {
580
+ appearance: 'none',
581
+ border: `1px solid ${L.border}`,
582
+ borderRadius: '8px',
583
+ padding: '5px 14px',
584
+ font: 'inherit',
585
+ fontSize: '13px',
586
+ lineHeight: 1.5,
587
+ background: 'none',
588
+ color: L.labelSecondary,
589
+ cursor: 'pointer',
590
+ },
591
+ save: {
592
+ appearance: 'none',
593
+ border: '1px solid transparent',
594
+ borderRadius: '8px',
595
+ padding: '5px 14px',
596
+ font: 'inherit',
597
+ fontSize: '13px',
598
+ lineHeight: 1.5,
599
+ background: L.labelPrimary,
600
+ color: L.bg,
601
+ cursor: 'pointer',
602
+ },
603
+ }