@reddb-io/ui 0.1.1 → 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.
Files changed (60) hide show
  1. package/build/_app/immutable/assets/0.DQDLkSdR.css +1 -0
  2. package/build/_app/immutable/assets/Workspace.CvP5De7i.css +1 -0
  3. package/build/_app/immutable/chunks/65XjEwaF.js +367 -0
  4. package/build/_app/immutable/chunks/BjFADNkA.js +992 -0
  5. package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.js} +1 -1
  6. package/build/_app/immutable/chunks/{Ov8N-vxt.js → C9rXzYLu.js} +1 -1
  7. package/build/_app/immutable/chunks/CDKIdLnm.js +187 -0
  8. package/build/_app/immutable/chunks/{DXY_Rxa7.js → CXNPJgv1.js} +7 -7
  9. package/build/_app/immutable/chunks/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
  10. package/build/_app/immutable/chunks/pDJnDxXM.js +1 -0
  11. package/build/_app/immutable/entry/app.DdaMXHAv.js +2 -0
  12. package/build/_app/immutable/entry/start.-MLBtQXB.js +1 -0
  13. package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.Ysoz3Y3h.js} +1 -1
  14. package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.C2jbGTOJ.js} +1 -1
  15. package/build/_app/immutable/nodes/11.CAG5_cGP.js +1 -0
  16. package/build/_app/immutable/nodes/{10.B7DMKSNc.js → 12.DQG5zpEx.js} +5 -5
  17. package/build/_app/immutable/nodes/7.CAG5_cGP.js +1 -0
  18. package/build/_app/immutable/nodes/{7.BZX9Iird.js → 8.CrHI4F9p.js} +1 -1
  19. package/build/_app/version.json +1 -1
  20. package/build/index.html +12 -12
  21. package/dist/embed/{RedUiEmbedRoot-BGoLxJf1.js → RedUiEmbedRoot-BwFUA5X-.js} +12715 -10653
  22. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +1 -0
  23. package/dist/embed/index-DGhgtnaK.js +577 -0
  24. package/dist/embed/{index-B6ZQhCsu.js.map → index-DGhgtnaK.js.map} +1 -1
  25. package/dist/embed/{index-fad77a13.esm-BqPj0saH.js → index-fad77a13.esm-BMULHdcE.js} +22 -20
  26. package/dist/embed/{index-fad77a13.esm-BqPj0saH.js.map → index-fad77a13.esm-BMULHdcE.js.map} +1 -1
  27. package/dist/embed/index.js +1 -1
  28. package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js +536 -0
  29. package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js.map +1 -0
  30. package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js → sigma-node-border.esm-Bnk4BRbw.js} +32 -32
  31. package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js.map → sigma-node-border.esm-Bnk4BRbw.js.map} +1 -1
  32. package/dist/embed/{sigma.esm-y0qgY-OC.js → sigma.esm-D5yoExsH.js} +3 -3
  33. package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-D5yoExsH.js.map} +1 -1
  34. package/package.json +5 -3
  35. package/src/lib/Appearance.svelte +90 -0
  36. package/src/lib/CommandPalette.svelte +2 -0
  37. package/src/lib/SettingsView.svelte +376 -0
  38. package/src/lib/Topbar.svelte +15 -1
  39. package/src/lib/Workspace.svelte +14 -1
  40. package/src/lib/index.ts +2 -0
  41. package/src/lib/renderers/GraphRenderer.svelte +448 -44
  42. package/src/lib/renderers/graph-bundle.ts +401 -0
  43. package/src/lib/renderers/graph-communities.ts +193 -0
  44. package/src/lib/renderers/graph-render.ts +48 -5
  45. package/src/lib/renderers/graph-sigma.ts +274 -8
  46. package/src/lib/router.svelte.ts +84 -57
  47. package/src/lib/settings-sections.ts +267 -0
  48. package/src/lib/skins/skins.svelte.ts +64 -0
  49. package/src/lib/skins/skins.ts +178 -0
  50. package/build/_app/immutable/assets/0.C63jY5jX.css +0 -1
  51. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +0 -1
  52. package/build/_app/immutable/chunks/BMHKbvs8.js +0 -1
  53. package/build/_app/immutable/chunks/BQHbwf54.js +0 -916
  54. package/build/_app/immutable/chunks/Bmgx1mfv.js +0 -310
  55. package/build/_app/immutable/entry/app.D_IVtpdv.js +0 -2
  56. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +0 -1
  57. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +0 -1
  58. package/dist/embed/index-B6ZQhCsu.js +0 -577
  59. /package/build/_app/immutable/assets/{10.Dblyk0Kr.css → 12.Dblyk0Kr.css} +0 -0
  60. /package/build/_app/immutable/nodes/{8.CAG5_cGP.js → 10.CAG5_cGP.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/ui",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "RedDB UI components and standalone web application.",
6
6
  "type": "module",
@@ -45,8 +45,10 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
+ "@aflsolutions/graphology-communities-leiden": "^1.1.1",
48
49
  "@fontsource-variable/inter": "^5.2.8",
49
50
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
51
+ "@sigma/edge-curve": "^3.1.0",
50
52
  "@sigma/node-border": "^3.0.0",
51
53
  "@tauri-apps/api": "^2.5.0",
52
54
  "@xyflow/svelte": "^1.2.0",
@@ -59,8 +61,8 @@
59
61
  "sigma": "^3.0.3",
60
62
  "tailwind-merge": "^3.6.0",
61
63
  "tailwind-variants": "^2.1.0",
62
- "@reddb-io/ui-mcp": "0.1.1",
63
- "@reddb-io/ui-kit": "0.1.1"
64
+ "@reddb-io/ui-kit": "0.3.0",
65
+ "@reddb-io/ui-mcp": "0.3.0"
64
66
  },
65
67
  "devDependencies": {
66
68
  "@sveltejs/adapter-static": "^3.0.8",
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ // Appearance section (#75). Renders each registered skin as a live preview
3
+ // card — a mini-mockup painted with that skin's *own* derived tokens — and
4
+ // applies the skin on click. Selection persists across reloads via the skin
5
+ // store. Dark-only: there is no light/system switch here.
6
+ import { Check } from 'lucide-svelte'
7
+ import PageHeader from '$lib/PageHeader.svelte'
8
+ import { SKINS, skinToCssVars, type Skin } from '$lib/skins/skins'
9
+ import { skins } from '$lib/skins/skins.svelte'
10
+
11
+ // Each preview is painted with the skin's own custom properties so the card
12
+ // is a faithful, self-contained swatch of the palette — `color-mix` ramps
13
+ // resolve in the browser exactly as they would once applied.
14
+ function previewStyle(skin: Skin): string {
15
+ return Object.entries(skinToCssVars(skin))
16
+ .map(([k, v]) => `${k}: ${v}`)
17
+ .join('; ')
18
+ }
19
+
20
+ const selected = $derived(skins.current.name)
21
+ </script>
22
+
23
+ <div class="h-full overflow-auto bg-bg-0 p-6 text-fg-1">
24
+ <PageHeader
25
+ eyebrow="Settings"
26
+ title="Appearance"
27
+ subtitle="Pick a skin. Surfaces are derived from a single seed; the accent stays surgical. Dark only."
28
+ />
29
+
30
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4 max-w-4xl">
31
+ {#each SKINS as skin (skin.name)}
32
+ {@const active = skin.name === selected}
33
+ <button
34
+ type="button"
35
+ onclick={() => skins.select(skin.name)}
36
+ aria-pressed={active}
37
+ class="group text-left rounded-lg border bg-bg-1 p-3 transition-colors {active
38
+ ? 'border-accent'
39
+ : 'border-line-2 hover:border-line-3'}"
40
+ >
41
+ <!-- Mini-mockup, painted in the skin's own tokens -->
42
+ <div
43
+ class="rounded-md overflow-hidden border border-line-1"
44
+ style={previewStyle(skin)}
45
+ >
46
+ <div class="bg-bg-0 p-3">
47
+ <!-- faux topbar -->
48
+ <div class="flex items-center gap-1.5 mb-2.5">
49
+ <span class="size-2 rounded-full" style="background: var(--color-accent)"></span>
50
+ <span class="h-1.5 w-12 rounded-full bg-bg-3"></span>
51
+ <span class="ml-auto h-1.5 w-6 rounded-full bg-bg-2"></span>
52
+ </div>
53
+ <!-- faux card with strips -->
54
+ <div class="rounded-md bg-bg-1 border border-line-2 p-2.5">
55
+ <div class="h-1.5 w-2/3 rounded-full bg-fg-2 mb-2"></div>
56
+ <div class="h-1.5 w-full rounded-full bg-bg-3 mb-1.5"></div>
57
+ <div class="h-1.5 w-4/5 rounded-full bg-bg-3 mb-2.5"></div>
58
+ <div class="flex items-center gap-1.5">
59
+ <span
60
+ class="h-4 w-12 rounded"
61
+ style="background: var(--color-accent)"
62
+ ></span>
63
+ <span class="h-4 w-8 rounded bg-bg-2 border border-line-2"></span>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ <!-- surface ramp strip: bg-0..bg-3 -->
68
+ <div class="flex h-2.5">
69
+ <span class="flex-1 bg-bg-0"></span>
70
+ <span class="flex-1 bg-bg-1"></span>
71
+ <span class="flex-1 bg-bg-2"></span>
72
+ <span class="flex-1 bg-bg-3"></span>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="flex items-center gap-2 mt-3">
77
+ <span class="type-h2 text-fg-0">{skin.label}</span>
78
+ {#if active}
79
+ <span
80
+ class="inline-flex items-center gap-1 rounded-full bg-accent-soft px-2 py-0.5 text-[10px] font-mono uppercase tracking-wider text-accent"
81
+ >
82
+ <Check class="size-3" /> Active
83
+ </span>
84
+ {/if}
85
+ </div>
86
+ <p class="type-caption m-0 mt-1">{skin.description}</p>
87
+ </button>
88
+ {/each}
89
+ </div>
90
+ </div>
@@ -31,6 +31,8 @@
31
31
  { id: 'collections', group: 'navigate', label: 'Open collections', hint: '/collections', run: () => navigate({ view: 'collections' }) },
32
32
  { id: 'topology', group: 'navigate', label: 'Open cluster', hint: '/cluster', run: () => navigate({ view: 'cluster' }) },
33
33
  { id: 'security', group: 'navigate', label: 'Open security', hint: '/security', run: () => navigate({ view: 'security' }) },
34
+ { id: 'settings', group: 'navigate', label: 'Open settings', hint: '/settings', run: () => navigate({ view: 'settings' }) },
35
+ { id: 'appearance', group: 'navigate', label: 'Open appearance', hint: '/appearance', run: () => navigate({ view: 'appearance' }) },
34
36
 
35
37
  { id: 'new-query', group: 'data', label: 'New query', shortcut: '⌘T', run: () => { fire('red:new-query'); close() } },
36
38
 
@@ -0,0 +1,376 @@
1
+ <script lang="ts">
2
+ // Settings view — a data-driven, permission-aware config inspector.
3
+ //
4
+ // The sections, their curated keys, and the label/description/enum copy all
5
+ // live in `settings-sections` as DATA. This view does three things: reads the
6
+ // *real* config from `connection.client` (no mocks/fixtures), flattens it to
7
+ // dotted keys, and renders each curated key through `resolveControl` as a
8
+ // `ListRow`. When the connection is unreachable it falls back to EmptyState.
9
+ //
10
+ // Permission-aware by default: a key whose source endpoint is denied or
11
+ // unsupported is not greyed out — its control is absent, replaced by a small
12
+ // chip naming the missing grant (with the server's reason in the tooltip).
13
+ import {
14
+ SplitView,
15
+ NavItem,
16
+ SectionHeading,
17
+ Pill,
18
+ ListRow,
19
+ } from '@reddb-io/ui-kit'
20
+ import {
21
+ Settings2,
22
+ Database,
23
+ Network,
24
+ Plug,
25
+ Shield,
26
+ Search,
27
+ RefreshCw,
28
+ Download,
29
+ Lock,
30
+ Unplug,
31
+ type Icon,
32
+ } from 'lucide-svelte'
33
+ import PageHeader from '$lib/PageHeader.svelte'
34
+ import EmptyState from '$lib/EmptyState.svelte'
35
+ import { connection } from '$lib/connections.svelte'
36
+ import { activity } from '$lib/activity.svelte'
37
+ import {
38
+ SECTIONS,
39
+ resolveSection,
40
+ resolveControl,
41
+ sourceForKey,
42
+ flattenConfig,
43
+ filterKeys,
44
+ type ControlDescriptor,
45
+ type SettingsSource,
46
+ } from '$lib/settings-sections'
47
+
48
+ const icons: Record<string, typeof Icon> = {
49
+ settings: Settings2,
50
+ database: Database,
51
+ network: Network,
52
+ plug: Plug,
53
+ shield: Shield,
54
+ }
55
+
56
+ // The grant a denied source is gated on — shown on the "absent" chip.
57
+ const GRANTS: Record<SettingsSource, string> = {
58
+ stats: 'stats:read',
59
+ cluster: 'cluster:status',
60
+ capabilities: 'capabilities',
61
+ session: 'auth:whoami',
62
+ }
63
+
64
+ let activeId = $state(SECTIONS[0].id)
65
+ const active = $derived(resolveSection(activeId))
66
+
67
+ // Per-section search query, keyed by section id, so switching tabs preserves
68
+ // each section's typed query. The input is focusable via the ⌘K idiom.
69
+ let queries = $state<Record<string, string>>({})
70
+ let searchEl = $state<HTMLInputElement | null>(null)
71
+ const query = $derived(queries[activeId] ?? '')
72
+
73
+ function setQuery(v: string) {
74
+ queries = { ...queries, [activeId]: v }
75
+ }
76
+
77
+ function onWindowKey(e: KeyboardEvent) {
78
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
79
+ e.preventDefault()
80
+ searchEl?.focus()
81
+ searchEl?.select()
82
+ }
83
+ }
84
+
85
+ // Flattened live config and, per source, the reason its grant is absent
86
+ // (null ⇒ available). Both are rebuilt atomically on every refresh.
87
+ let values = $state<Record<string, unknown>>({})
88
+ let denied = $state<Record<SettingsSource, string | null>>({
89
+ stats: null,
90
+ cluster: null,
91
+ capabilities: null,
92
+ session: null,
93
+ })
94
+ let loading = $state(false)
95
+
96
+ const connected = $derived(connection.connected)
97
+ const activeUrl = $derived(connection.active.url)
98
+
99
+ async function refresh() {
100
+ const client = connection.client
101
+ if (!client || !connected) {
102
+ values = {}
103
+ return
104
+ }
105
+ loading = true
106
+ const next: Record<string, unknown> = {}
107
+ const nextDenied: Record<SettingsSource, string | null> = {
108
+ stats: null,
109
+ cluster: null,
110
+ capabilities: null,
111
+ session: null,
112
+ }
113
+
114
+ // /capabilities is the negotiation itself — always available from the store.
115
+ Object.assign(next, flattenConfig({ capabilities: connection.capabilities }))
116
+
117
+ try {
118
+ const stats = await activity.track('settings · stats', () => client.stats())
119
+ Object.assign(next, flattenConfig(stats as unknown as Record<string, unknown>))
120
+ } catch (e) {
121
+ nextDenied.stats = (e as Error).message
122
+ }
123
+
124
+ // Cluster topology is capability-gated: if the server can't honour
125
+ // /cluster/status, those keys are absent + explained, never faked.
126
+ if (!connection.capabilities.clusterStatus) {
127
+ nextDenied.cluster = 'Server does not expose /cluster/status.'
128
+ } else {
129
+ try {
130
+ const cluster = await activity.track('settings · cluster', () =>
131
+ client.clusterStatus(),
132
+ )
133
+ Object.assign(next, flattenConfig(cluster as unknown as Record<string, unknown>))
134
+ } catch (e) {
135
+ nextDenied.cluster = (e as Error).message
136
+ }
137
+ }
138
+
139
+ try {
140
+ const session = await activity.track('settings · whoami', () => client.whoami())
141
+ Object.assign(
142
+ next,
143
+ flattenConfig({ session: session as unknown as Record<string, unknown> }),
144
+ )
145
+ } catch (e) {
146
+ nextDenied.session = (e as Error).message
147
+ }
148
+
149
+ values = next
150
+ denied = nextDenied
151
+ loading = false
152
+ }
153
+
154
+ $effect(() => {
155
+ void activeUrl
156
+ void connected
157
+ refresh()
158
+ })
159
+
160
+ type RenderRow =
161
+ | { type: 'value'; key: string; label: string; control: ControlDescriptor; value: unknown }
162
+ | { type: 'denied'; key: string; label: string; reason: string; grant: string }
163
+
164
+ // Resolve the active section's curated keys to render rows. A key whose
165
+ // source is denied becomes a "denied" row (absent control + explanation); a
166
+ // key with a live value becomes a "value" row; a key the server simply does
167
+ // not report is dropped (graceful degradation — no empty placeholder).
168
+ const rows = $derived.by<RenderRow[]>(() => {
169
+ const out: RenderRow[] = []
170
+ for (const key of filterKeys(active.keys, query)) {
171
+ const src = sourceForKey(key)
172
+ const reason = denied[src]
173
+ const label = resolveControl(key).label
174
+ if (reason) {
175
+ out.push({ type: 'denied', key, label, reason, grant: GRANTS[src] })
176
+ } else if (key in values) {
177
+ out.push({
178
+ type: 'value',
179
+ key,
180
+ label,
181
+ control: resolveControl(key, values[key]),
182
+ value: values[key],
183
+ })
184
+ }
185
+ }
186
+ return out
187
+ })
188
+
189
+ function formatBytes(n: number): string {
190
+ if (!Number.isFinite(n)) return String(n)
191
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
192
+ let v = n
193
+ let i = 0
194
+ while (v >= 1024 && i < units.length - 1) {
195
+ v /= 1024
196
+ i++
197
+ }
198
+ return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`
199
+ }
200
+
201
+ function formatValue(control: ControlDescriptor, value: unknown): string {
202
+ if (control.kind === 'number' && control.key.endsWith('_bytes')) {
203
+ return formatBytes(Number(value))
204
+ }
205
+ return String(value)
206
+ }
207
+
208
+ // Export the live config snapshot (what's currently inspected) as JSON. This
209
+ // is the honest, real action: the snapshot is read from connection.client, so
210
+ // it can be downloaded but NOT written back (the client exposes no config
211
+ // write/reset). Import/reset would have no real target here, so they are
212
+ // intentionally not offered rather than faked.
213
+ function exportConfig() {
214
+ const payload = {
215
+ url: activeUrl,
216
+ exportedAt: new Date().toISOString(),
217
+ config: values,
218
+ }
219
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
220
+ const url = URL.createObjectURL(blob)
221
+ const a = document.createElement('a')
222
+ a.href = url
223
+ a.download = `reddb-config-${new Date().toISOString().slice(0, 10)}.json`
224
+ a.click()
225
+ URL.revokeObjectURL(url)
226
+ }
227
+ </script>
228
+
229
+ <svelte:window onkeydown={onWindowKey} />
230
+
231
+ {#if !connected}
232
+ <div class="h-full overflow-auto bg-bg-0 p-6">
233
+ <PageHeader
234
+ eyebrow="Settings"
235
+ title="Settings"
236
+ subtitle="Live configuration for the active reddb connection."
237
+ />
238
+ <EmptyState
239
+ icon={Unplug}
240
+ title="No active connection"
241
+ message="Connect to a reddb instance to inspect its live configuration."
242
+ hint="red serve"
243
+ />
244
+ </div>
245
+ {:else}
246
+ <SplitView searchShortcut={null}>
247
+ {#snippet search()}
248
+ <div class="relative">
249
+ <Search
250
+ class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-3"
251
+ />
252
+ <input
253
+ bind:this={searchEl}
254
+ type="text"
255
+ placeholder="Search this section ⌘K"
256
+ value={query}
257
+ oninput={(e) => setQuery(e.currentTarget.value)}
258
+ onkeydown={(e) => {
259
+ if (e.key === 'Escape') {
260
+ setQuery('')
261
+ e.currentTarget.blur()
262
+ }
263
+ }}
264
+ class="h-7 w-full rounded-md border border-line-2 bg-bg-2 pl-8 pr-2.5 text-xs text-fg-1 placeholder:text-fg-3 outline-none transition-colors focus:border-line-3"
265
+ />
266
+ </div>
267
+ {/snippet}
268
+
269
+ {#snippet nav()}
270
+ <nav class="grid gap-0.5 p-2">
271
+ {#each SECTIONS as section (section.id)}
272
+ {@const SectionIcon = icons[section.icon] ?? Settings2}
273
+ <NavItem
274
+ label={section.label}
275
+ active={section.id === active.id}
276
+ onclick={() => (activeId = section.id)}
277
+ >
278
+ {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
279
+ </NavItem>
280
+ {/each}
281
+ </nav>
282
+ {/snippet}
283
+
284
+ {#snippet footer()}
285
+ <div class="grid gap-2 px-3 py-2.5">
286
+ <button
287
+ type="button"
288
+ onclick={exportConfig}
289
+ disabled={loading}
290
+ class="inline-flex h-7 items-center justify-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 transition-colors hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
291
+ >
292
+ <Download class="size-3.5" />
293
+ export config
294
+ </button>
295
+ <div class="text-[11px] text-fg-3">
296
+ Live from <span class="font-mono text-fg-2">{activeUrl}</span>.
297
+ </div>
298
+ </div>
299
+ {/snippet}
300
+
301
+ {#snippet content()}
302
+ {@const SectionIcon = icons[active.icon] ?? Settings2}
303
+ <div class="p-6">
304
+ <PageHeader eyebrow="Settings" title={active.label} subtitle={active.blurb}>
305
+ {#snippet actions()}
306
+ <button
307
+ type="button"
308
+ onclick={refresh}
309
+ disabled={loading}
310
+ class="inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
311
+ >
312
+ <RefreshCw class={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
313
+ refresh
314
+ </button>
315
+ {/snippet}
316
+ </PageHeader>
317
+
318
+ <section class="mb-6">
319
+ <SectionHeading title={active.label} class="mb-2">
320
+ {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
321
+ {#snippet meta()}<Pill>{rows.length}</Pill>{/snippet}
322
+ </SectionHeading>
323
+
324
+ {#if rows.length === 0}
325
+ <div
326
+ class="rounded-lg border border-dashed border-line-1 bg-bg-1 px-3 py-6 text-center text-[12px] text-fg-3"
327
+ >
328
+ This server reports nothing for these keys.
329
+ </div>
330
+ {:else}
331
+ <div
332
+ class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1"
333
+ >
334
+ {#each rows as row (row.key)}
335
+ {#if row.type === 'denied'}
336
+ <ListRow title={row.label} hint={row.key}>
337
+ {#snippet action()}
338
+ <span
339
+ class="inline-flex items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2 py-1 text-[11px] font-mono text-fg-3"
340
+ title={row.reason}
341
+ >
342
+ <Lock class="size-3" />
343
+ requires {row.grant}
344
+ </span>
345
+ {/snippet}
346
+ </ListRow>
347
+ {:else}
348
+ <ListRow
349
+ title={row.label}
350
+ description={row.control.description}
351
+ hint={row.key}
352
+ >
353
+ {#snippet action()}
354
+ {#if row.control.kind === 'boolean'}
355
+ <Pill tone={row.value ? 'accent' : 'muted'}>{row.value ? 'on' : 'off'}</Pill>
356
+ {:else if row.control.kind === 'enum'}
357
+ <span
358
+ class="font-mono text-[12px] text-accent"
359
+ title={`Options: ${row.control.options?.join(', ')}`}
360
+ >{row.value}</span>
361
+ {:else}
362
+ <span class="font-mono text-[12px] text-fg-1"
363
+ >{formatValue(row.control, row.value)}</span
364
+ >
365
+ {/if}
366
+ {/snippet}
367
+ </ListRow>
368
+ {/if}
369
+ {/each}
370
+ </div>
371
+ {/if}
372
+ </section>
373
+ </div>
374
+ {/snippet}
375
+ </SplitView>
376
+ {/if}
@@ -2,7 +2,7 @@
2
2
  import { Kbd } from '@reddb-io/ui-kit'
3
3
  import ConnectDropdown from './ConnectDropdown.svelte'
4
4
  import ActivityIndicator from './ActivityIndicator.svelte'
5
- import { Search, Sun, Moon, Network, Database, FileCode2, Shield } from 'lucide-svelte'
5
+ import { Search, Sun, Moon, Network, Database, FileCode2, Shield, Settings2 } from 'lucide-svelte'
6
6
  import { theme } from './theme.svelte'
7
7
  import { useRouter } from './router.svelte'
8
8
 
@@ -18,6 +18,7 @@
18
18
  const onCollections = $derived(router.view === 'collections')
19
19
  const onCluster = $derived(router.view === 'cluster')
20
20
  const onSecurity = $derived(router.view === 'security')
21
+ const onSettings = $derived(router.view === 'settings')
21
22
  </script>
22
23
 
23
24
  <header class="h-11 flex items-center justify-between px-3.5 bg-bg-1/80 backdrop-blur-md border-b border-line-1 relative z-10">
@@ -89,6 +90,19 @@
89
90
  <Shield class="size-3.5" />
90
91
  <span>Security</span>
91
92
  </a>
93
+ <a
94
+ href={router.href({ view: 'settings' })}
95
+ onclick={(e) => router.go({ view: 'settings' }, e)}
96
+ title="Settings"
97
+ aria-current={onSettings ? 'page' : undefined}
98
+ class={[
99
+ 'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
100
+ onSettings ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
101
+ ].join(' ')}
102
+ >
103
+ <Settings2 class="size-3.5" />
104
+ <span>Settings</span>
105
+ </a>
92
106
  </nav>
93
107
  </div>
94
108
 
@@ -15,9 +15,12 @@
15
15
  import CollectionsView from './CollectionsView.svelte'
16
16
  import ClusterView from './ClusterView.svelte'
17
17
  import SecurityView from './SecurityView.svelte'
18
+ import SettingsView from './SettingsView.svelte'
19
+ import Appearance from './Appearance.svelte'
18
20
  import { connection, setConnectionProvider } from './connections.svelte'
19
21
  import { runQueryPreferStream, type ConnectionProvider } from '#reddb'
20
22
  import { theme, type Theme } from './theme.svelte'
23
+ import { skins } from './skins/skins.svelte'
21
24
  import { secureStore } from './secureStore.svelte'
22
25
  import { pendingChanges, buildUpdateSql, type CommitOutcome } from './pending-changes.svelte'
23
26
  import { queryTabs } from './query-tabs.svelte'
@@ -63,7 +66,7 @@
63
66
 
64
67
  function navigateToBootRoute(route: string) {
65
68
  const legacyView = route as View
66
- if (legacyView === 'query' || legacyView === 'collections' || legacyView === 'cluster' || legacyView === 'security') {
69
+ if (legacyView === 'query' || legacyView === 'collections' || legacyView === 'cluster' || legacyView === 'security' || legacyView === 'settings' || legacyView === 'appearance') {
67
70
  router.go({ view: legacyView }, undefined, true)
68
71
  return
69
72
  }
@@ -133,6 +136,12 @@
133
136
  // svelte-ignore state_referenced_locally
134
137
  theme.init({ target: themeTarget ?? null, persist: persistTheme, initial: initialTheme })
135
138
 
139
+ // Skin-as-data (#75): apply the persisted skin's seed-derived custom
140
+ // properties over the active theme. Scoped to the same target the theme
141
+ // uses, so an embedded host's shadow root is skinned, not the document.
142
+ // svelte-ignore state_referenced_locally
143
+ skins.init(themeTarget ?? null)
144
+
136
145
  // Hard lock gate: while the secure store is locked, no automatic network
137
146
  // traffic should fire — probing the configured URL would leak its existence
138
147
  // and shape. Additionally (#21, acceptance #4) no traffic fires until a real
@@ -235,6 +244,10 @@
235
244
  <ClusterView />
236
245
  {:else if router.view === 'security'}
237
246
  <SecurityView />
247
+ {:else if router.view === 'settings'}
248
+ <SettingsView />
249
+ {:else if router.view === 'appearance'}
250
+ <Appearance />
238
251
  {:else}
239
252
  <CollectionsView />
240
253
  {/if}
package/src/lib/index.ts CHANGED
@@ -15,6 +15,7 @@ export { default as QueryEditor } from "./QueryEditor.svelte";
15
15
  export { default as ResultsPane } from "./ResultsPane.svelte";
16
16
  export { default as SchemaTree } from "./SchemaTree.svelte";
17
17
  export { default as SecurityView } from "./SecurityView.svelte";
18
+ export { default as SettingsView } from "./SettingsView.svelte";
18
19
  export { default as ShortcutOverlay } from "./ShortcutOverlay.svelte";
19
20
  export { default as Splash } from "./Splash.svelte";
20
21
  export { default as StatusBar } from "./StatusBar.svelte";
@@ -33,6 +34,7 @@ export type { Capability as CollectionCapability } from "./capability";
33
34
  export * from "./collection-catalog";
34
35
  export * from "./collection-pages";
35
36
  export * from "./router.svelte";
37
+ export * from "./settings-sections";
36
38
  export * from "./cn";
37
39
  export * from "./connections.svelte";
38
40
  export * from "./shortcuts";