@reddb-io/ui 0.1.1 → 0.2.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 (59) 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/{Ov8N-vxt.js → BHBXmd1Y.js} +1 -1
  5. package/build/_app/immutable/chunks/BMc_qoms.js +973 -0
  6. package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.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/{BMHKbvs8.js → CwxFwvow.js} +1 -1
  10. package/build/_app/immutable/chunks/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
  11. package/build/_app/immutable/entry/app.CGfmRH-N.js +2 -0
  12. package/build/_app/immutable/entry/start._LhFJimm.js +1 -0
  13. package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.BYhY6O5-.js} +1 -1
  14. package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.4XYGRMul.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.DXZfNPNK.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.C7OOsMOJ.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-Dia9lxfb.js} +12622 -10614
  22. package/dist/embed/RedUiEmbedRoot-Dia9lxfb.js.map +1 -0
  23. package/dist/embed/index-Bq0TT7Ao.js +577 -0
  24. package/dist/embed/{index-B6ZQhCsu.js.map → index-Bq0TT7Ao.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-C2pqg5JH.js} +3 -3
  33. package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-C2pqg5JH.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 +322 -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 +249 -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/BQHbwf54.js +0 -916
  53. package/build/_app/immutable/chunks/Bmgx1mfv.js +0 -310
  54. package/build/_app/immutable/entry/app.D_IVtpdv.js +0 -2
  55. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +0 -1
  56. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +0 -1
  57. package/dist/embed/index-B6ZQhCsu.js +0 -577
  58. /package/build/_app/immutable/assets/{10.Dblyk0Kr.css → 12.Dblyk0Kr.css} +0 -0
  59. /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.2.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.2.0",
65
+ "@reddb-io/ui-mcp": "0.2.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,322 @@
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
+ Kbd,
20
+ } from '@reddb-io/ui-kit'
21
+ import {
22
+ Settings2,
23
+ Database,
24
+ Network,
25
+ Plug,
26
+ Shield,
27
+ Search,
28
+ RefreshCw,
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
+ type ControlDescriptor,
44
+ type SettingsSource,
45
+ } from '$lib/settings-sections'
46
+
47
+ const icons: Record<string, typeof Icon> = {
48
+ settings: Settings2,
49
+ database: Database,
50
+ network: Network,
51
+ plug: Plug,
52
+ shield: Shield,
53
+ }
54
+
55
+ // The grant a denied source is gated on — shown on the "absent" chip.
56
+ const GRANTS: Record<SettingsSource, string> = {
57
+ stats: 'stats:read',
58
+ cluster: 'cluster:status',
59
+ capabilities: 'capabilities',
60
+ session: 'auth:whoami',
61
+ }
62
+
63
+ let activeId = $state(SECTIONS[0].id)
64
+ const active = $derived(resolveSection(activeId))
65
+
66
+ // Flattened live config and, per source, the reason its grant is absent
67
+ // (null ⇒ available). Both are rebuilt atomically on every refresh.
68
+ let values = $state<Record<string, unknown>>({})
69
+ let denied = $state<Record<SettingsSource, string | null>>({
70
+ stats: null,
71
+ cluster: null,
72
+ capabilities: null,
73
+ session: null,
74
+ })
75
+ let loading = $state(false)
76
+
77
+ const connected = $derived(connection.connected)
78
+ const activeUrl = $derived(connection.active.url)
79
+
80
+ async function refresh() {
81
+ const client = connection.client
82
+ if (!client || !connected) {
83
+ values = {}
84
+ return
85
+ }
86
+ loading = true
87
+ const next: Record<string, unknown> = {}
88
+ const nextDenied: Record<SettingsSource, string | null> = {
89
+ stats: null,
90
+ cluster: null,
91
+ capabilities: null,
92
+ session: null,
93
+ }
94
+
95
+ // /capabilities is the negotiation itself — always available from the store.
96
+ Object.assign(next, flattenConfig({ capabilities: connection.capabilities }))
97
+
98
+ try {
99
+ const stats = await activity.track('settings · stats', () => client.stats())
100
+ Object.assign(next, flattenConfig(stats as unknown as Record<string, unknown>))
101
+ } catch (e) {
102
+ nextDenied.stats = (e as Error).message
103
+ }
104
+
105
+ // Cluster topology is capability-gated: if the server can't honour
106
+ // /cluster/status, those keys are absent + explained, never faked.
107
+ if (!connection.capabilities.clusterStatus) {
108
+ nextDenied.cluster = 'Server does not expose /cluster/status.'
109
+ } else {
110
+ try {
111
+ const cluster = await activity.track('settings · cluster', () =>
112
+ client.clusterStatus(),
113
+ )
114
+ Object.assign(next, flattenConfig(cluster as unknown as Record<string, unknown>))
115
+ } catch (e) {
116
+ nextDenied.cluster = (e as Error).message
117
+ }
118
+ }
119
+
120
+ try {
121
+ const session = await activity.track('settings · whoami', () => client.whoami())
122
+ Object.assign(
123
+ next,
124
+ flattenConfig({ session: session as unknown as Record<string, unknown> }),
125
+ )
126
+ } catch (e) {
127
+ nextDenied.session = (e as Error).message
128
+ }
129
+
130
+ values = next
131
+ denied = nextDenied
132
+ loading = false
133
+ }
134
+
135
+ $effect(() => {
136
+ void activeUrl
137
+ void connected
138
+ refresh()
139
+ })
140
+
141
+ type RenderRow =
142
+ | { type: 'value'; key: string; label: string; control: ControlDescriptor; value: unknown }
143
+ | { type: 'denied'; key: string; label: string; reason: string; grant: string }
144
+
145
+ // Resolve the active section's curated keys to render rows. A key whose
146
+ // source is denied becomes a "denied" row (absent control + explanation); a
147
+ // key with a live value becomes a "value" row; a key the server simply does
148
+ // not report is dropped (graceful degradation — no empty placeholder).
149
+ const rows = $derived.by<RenderRow[]>(() => {
150
+ const out: RenderRow[] = []
151
+ for (const key of active.keys) {
152
+ const src = sourceForKey(key)
153
+ const reason = denied[src]
154
+ const label = resolveControl(key).label
155
+ if (reason) {
156
+ out.push({ type: 'denied', key, label, reason, grant: GRANTS[src] })
157
+ } else if (key in values) {
158
+ out.push({
159
+ type: 'value',
160
+ key,
161
+ label,
162
+ control: resolveControl(key, values[key]),
163
+ value: values[key],
164
+ })
165
+ }
166
+ }
167
+ return out
168
+ })
169
+
170
+ function formatBytes(n: number): string {
171
+ if (!Number.isFinite(n)) return String(n)
172
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
173
+ let v = n
174
+ let i = 0
175
+ while (v >= 1024 && i < units.length - 1) {
176
+ v /= 1024
177
+ i++
178
+ }
179
+ return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`
180
+ }
181
+
182
+ function formatValue(control: ControlDescriptor, value: unknown): string {
183
+ if (control.kind === 'number' && control.key.endsWith('_bytes')) {
184
+ return formatBytes(Number(value))
185
+ }
186
+ return String(value)
187
+ }
188
+
189
+ // Reuse the global command palette as the search surface — the same ⌘K idiom
190
+ // the topbar uses, so Esc-to-close comes for free from the palette overlay.
191
+ function openPalette() {
192
+ window.dispatchEvent(
193
+ new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true }),
194
+ )
195
+ }
196
+ </script>
197
+
198
+ {#if !connected}
199
+ <div class="h-full overflow-auto bg-bg-0 p-6">
200
+ <PageHeader
201
+ eyebrow="Settings"
202
+ title="Settings"
203
+ subtitle="Live configuration for the active reddb connection."
204
+ />
205
+ <EmptyState
206
+ icon={Unplug}
207
+ title="No active connection"
208
+ message="Connect to a reddb instance to inspect its live configuration."
209
+ hint="red serve"
210
+ />
211
+ </div>
212
+ {:else}
213
+ <SplitView searchShortcut={null}>
214
+ {#snippet search()}
215
+ <button
216
+ type="button"
217
+ onclick={openPalette}
218
+ class="flex h-7 w-full items-center gap-2 rounded-md border border-line-2 bg-bg-2 px-2.5 text-xs text-fg-2 transition-colors hover:border-line-3 hover:text-fg-1"
219
+ >
220
+ <Search class="size-3.5 text-fg-3" />
221
+ <span class="flex-1 text-left">Search settings</span>
222
+ <span class="inline-flex gap-0.5"><Kbd>⌘</Kbd><Kbd>K</Kbd></span>
223
+ </button>
224
+ {/snippet}
225
+
226
+ {#snippet nav()}
227
+ <nav class="grid gap-0.5 p-2">
228
+ {#each SECTIONS as section (section.id)}
229
+ {@const SectionIcon = icons[section.icon] ?? Settings2}
230
+ <NavItem
231
+ label={section.label}
232
+ active={section.id === active.id}
233
+ onclick={() => (activeId = section.id)}
234
+ >
235
+ {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
236
+ </NavItem>
237
+ {/each}
238
+ </nav>
239
+ {/snippet}
240
+
241
+ {#snippet footer()}
242
+ <div class="px-3 py-2.5 text-[11px] text-fg-3">
243
+ Live from <span class="font-mono text-fg-2">{activeUrl}</span>.
244
+ </div>
245
+ {/snippet}
246
+
247
+ {#snippet content()}
248
+ {@const SectionIcon = icons[active.icon] ?? Settings2}
249
+ <div class="p-6">
250
+ <PageHeader eyebrow="Settings" title={active.label} subtitle={active.blurb}>
251
+ {#snippet actions()}
252
+ <button
253
+ type="button"
254
+ onclick={refresh}
255
+ disabled={loading}
256
+ 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"
257
+ >
258
+ <RefreshCw class={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
259
+ refresh
260
+ </button>
261
+ {/snippet}
262
+ </PageHeader>
263
+
264
+ <section class="mb-6">
265
+ <SectionHeading title={active.label} class="mb-2">
266
+ {#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
267
+ {#snippet meta()}<Pill>{rows.length}</Pill>{/snippet}
268
+ </SectionHeading>
269
+
270
+ {#if rows.length === 0}
271
+ <div
272
+ class="rounded-lg border border-dashed border-line-1 bg-bg-1 px-3 py-6 text-center text-[12px] text-fg-3"
273
+ >
274
+ This server reports nothing for these keys.
275
+ </div>
276
+ {:else}
277
+ <div
278
+ class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1"
279
+ >
280
+ {#each rows as row (row.key)}
281
+ {#if row.type === 'denied'}
282
+ <ListRow title={row.label} hint={row.key}>
283
+ {#snippet action()}
284
+ <span
285
+ 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"
286
+ title={row.reason}
287
+ >
288
+ <Lock class="size-3" />
289
+ requires {row.grant}
290
+ </span>
291
+ {/snippet}
292
+ </ListRow>
293
+ {:else}
294
+ <ListRow
295
+ title={row.label}
296
+ description={row.control.description}
297
+ hint={row.key}
298
+ >
299
+ {#snippet action()}
300
+ {#if row.control.kind === 'boolean'}
301
+ <Pill tone={row.value ? 'accent' : 'muted'}>{row.value ? 'on' : 'off'}</Pill>
302
+ {:else if row.control.kind === 'enum'}
303
+ <span
304
+ class="font-mono text-[12px] text-accent"
305
+ title={`Options: ${row.control.options?.join(', ')}`}
306
+ >{row.value}</span>
307
+ {:else}
308
+ <span class="font-mono text-[12px] text-fg-1"
309
+ >{formatValue(row.control, row.value)}</span
310
+ >
311
+ {/if}
312
+ {/snippet}
313
+ </ListRow>
314
+ {/if}
315
+ {/each}
316
+ </div>
317
+ {/if}
318
+ </section>
319
+ </div>
320
+ {/snippet}
321
+ </SplitView>
322
+ {/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";