@reddb-io/ui 0.1.1

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 (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,154 @@
1
+ import type { QueryResult } from '#reddb'
2
+
3
+ export interface VectorRow {
4
+ index: number
5
+ id: string
6
+ vectorColumn: string | null
7
+ vector: number[]
8
+ dimension: number
9
+ scalarColumn: string | null
10
+ scalar: number | null
11
+ metadata: Record<string, unknown>
12
+ }
13
+
14
+ const VECTOR_NAMES = /(^|_)(vector|embedding|embeddings|turbovec)($|_)/i
15
+ const SCALAR_NAMES = /(^|_)(scalar|score|distance|similarity|magnitude|norm)($|_)/i
16
+ const SYSTEM_NAMES = new Set([
17
+ 'rid',
18
+ 'entity_id',
19
+ 'red_entity_id',
20
+ 'red_entity_type',
21
+ 'red_kind',
22
+ 'red_collection',
23
+ 'red_sequence_id',
24
+ 'collection',
25
+ 'kind',
26
+ 'tenant',
27
+ 'created_at',
28
+ 'updated_at',
29
+ 'dimension',
30
+ ])
31
+
32
+ function isNumberArray(v: unknown): v is number[] {
33
+ return Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'number' && Number.isFinite(x))
34
+ }
35
+
36
+ function pickVector(values: Record<string, unknown>): [string, number[]] | null {
37
+ const named = Object.entries(values).find(([k, v]) => VECTOR_NAMES.test(k) && isNumberArray(v))
38
+ if (named) return named as [string, number[]]
39
+ const any = Object.entries(values).find(([, v]) => isNumberArray(v))
40
+ return any ? (any as [string, number[]]) : null
41
+ }
42
+
43
+ function pickScalar(values: Record<string, unknown>, vectorColumn: string | null): [string, number] | null {
44
+ const named = Object.entries(values).find(([k, v]) =>
45
+ k !== vectorColumn && SCALAR_NAMES.test(k) && typeof v === 'number' && Number.isFinite(v),
46
+ )
47
+ if (named) return named as [string, number]
48
+ return null
49
+ }
50
+
51
+ function numericDimension(v: unknown): number | null {
52
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : null
53
+ }
54
+
55
+ export function extractVectors(result: QueryResult): VectorRow[] {
56
+ return result.result.records.map((record, index) => {
57
+ const values = record.values ?? {}
58
+ const vectorResult = record.vector_results?.find((match) => isNumberArray(match.vector)) ?? null
59
+ const vector = vectorResult?.vector
60
+ ? (['vector_results.vector', vectorResult.vector] satisfies [string, number[]])
61
+ : pickVector(values)
62
+ const scalar = pickScalar(values, vector?.[0] ?? null)
63
+ const dimension = vector?.[1].length
64
+ ?? numericDimension(values.dimension)
65
+ ?? numericDimension(values.vector_dimension)
66
+ ?? 0
67
+ const metadata: Record<string, unknown> = {}
68
+ for (const [k, v] of Object.entries(values)) {
69
+ if (k === vector?.[0] || k === scalar?.[0] || SYSTEM_NAMES.has(k)) continue
70
+ metadata[k] = v
71
+ }
72
+ return {
73
+ index,
74
+ id: String(values.id ?? values.entity_id ?? values.red_entity_id ?? values.rid ?? index + 1),
75
+ vectorColumn: vector?.[0] ?? null,
76
+ vector: vector?.[1] ?? [],
77
+ dimension,
78
+ scalarColumn: scalar?.[0] ?? null,
79
+ scalar: scalar?.[1] ?? null,
80
+ metadata,
81
+ }
82
+ })
83
+ }
84
+
85
+ export function hasVectorShape(result: QueryResult): boolean {
86
+ if (result.capability === 'vector') return true
87
+ if (result.result.records.some((record) => (record.vector_results?.length ?? 0) > 0)) return true
88
+ return extractVectors(result).some((row) => row.vector.length > 0 || row.dimension > 0 || row.scalar !== null)
89
+ }
90
+
91
+ export function vectorMagnitude(v: number[]): number {
92
+ return Math.sqrt(v.reduce((sum, x) => sum + x * x, 0))
93
+ }
94
+
95
+ export function scalarLabel(row: Pick<VectorRow, 'scalarColumn'>): string {
96
+ if (!row.scalarColumn) return 'ranking metric'
97
+ if (/distance/i.test(row.scalarColumn)) return `search distance · ${row.scalarColumn}`
98
+ if (/similarity/i.test(row.scalarColumn)) return `similarity score · ${row.scalarColumn}`
99
+ if (/score/i.test(row.scalarColumn)) return `score · ${row.scalarColumn}`
100
+ if (/turbovec|scalar/i.test(row.scalarColumn)) return `scalar projection · ${row.scalarColumn}`
101
+ if (/magnitude|norm/i.test(row.scalarColumn)) return `vector magnitude · ${row.scalarColumn}`
102
+ return `ranking metric · ${row.scalarColumn}`
103
+ }
104
+
105
+ export function compactValue(v: unknown): string {
106
+ if (v === null || v === undefined) return 'NULL'
107
+ if (typeof v === 'object') return JSON.stringify(v)
108
+ return String(v)
109
+ }
110
+
111
+ export function isSyntheticVectorPreview(result: QueryResult): boolean {
112
+ return /^VECTOR\s+SEARCH\s+\S+\s+SIMILAR\s+TO\s+\[1(?:\.0)?,\s*0(?:\.0)?/i.test(result.query.trim())
113
+ }
114
+
115
+ export function isVectorSearchResult(result: QueryResult): boolean {
116
+ return /^VECTOR\s+SEARCH\s+/i.test(result.query.trim())
117
+ }
118
+
119
+ export function parseVectorInput(input: string): number[] {
120
+ const numbers = input
121
+ .replace(/[\[\]]/g, ' ')
122
+ .split(/[,\s]+/)
123
+ .map((part) => part.trim())
124
+ .filter(Boolean)
125
+ .map(Number)
126
+ if (numbers.length === 0) throw new Error('Paste a numeric vector first.')
127
+ const invalid = numbers.find((n) => !Number.isFinite(n))
128
+ if (invalid !== undefined) throw new Error('Vector contains a non-numeric value.')
129
+ return numbers
130
+ }
131
+
132
+ export function formatVectorLiteral(vector: number[]): string {
133
+ return `[${vector.map((n) => Number(n.toPrecision(8))).join(', ')}]`
134
+ }
135
+
136
+ function sqlString(s: string): string {
137
+ return `'${s.replace(/'/g, "''")}'`
138
+ }
139
+
140
+ function safeCollectionName(name: string): string {
141
+ return name.replace(/[^A-Za-z0-9_./-]/g, '')
142
+ }
143
+
144
+ export function buildVectorSearchQuery(
145
+ collection: string,
146
+ source: { kind: 'text'; text: string } | { kind: 'vector'; vector: number[] },
147
+ limit: number,
148
+ ): string {
149
+ const boundedLimit = Math.max(1, Math.min(200, Math.floor(Number(limit) || 20)))
150
+ const literal = source.kind === 'text'
151
+ ? sqlString(source.text)
152
+ : formatVectorLiteral(source.vector)
153
+ return `VECTOR SEARCH ${safeCollectionName(collection)} SIMILAR TO ${literal} INCLUDE VECTORS LIMIT ${boundedLimit}`
154
+ }
@@ -0,0 +1,9 @@
1
+ import type { CapabilityRenderer } from './types'
2
+ import { hasVectorShape } from './vector-render'
3
+ import VectorRenderer from './VectorRenderer.svelte'
4
+
5
+ export const vectorRenderer: CapabilityRenderer = {
6
+ capability: 'vector',
7
+ renders: (result) => hasVectorShape(result),
8
+ component: VectorRenderer,
9
+ }
@@ -0,0 +1,163 @@
1
+ // Router abstraction for the Mountable Root.
2
+ //
3
+ // The Core (`Workspace`) must mount without owning `window.history` or the
4
+ // page URL (ADR-0001). Navigation between its top-level views is expressed
5
+ // as a structured `RouteTarget` and resolved by an injected `Router`:
6
+ //
7
+ // - `createStateRouter()` keeps the location in component state and never
8
+ // touches the URL — this is the default the Mountable Root uses when no
9
+ // router is injected, so it can be embedded in a host that owns the page.
10
+ // - The SvelteKit surface injects a Kit-backed router (see
11
+ // `src/routes/kit-router.svelte.ts`) that reads `page.url` and `goto()`s,
12
+ // so the full app keeps deep links, refresh, and the back button.
13
+ //
14
+ // Both implementations satisfy the same `Router` contract, so every view
15
+ // component navigates the same way regardless of which Surface it runs in.
16
+
17
+ import { getContext, setContext } from 'svelte'
18
+ import { collectionPageHref } from './collection-pages'
19
+
20
+ /** The four top-level destinations of the Mountable Root. */
21
+ export type View = 'query' | 'collections' | 'cluster' | 'security'
22
+
23
+ /**
24
+ * A navigation intent. `collection` carries a selected collection + subpage
25
+ * within the collections workspace; the other targets are the bare views.
26
+ */
27
+ export type RouteTarget =
28
+ | { view: 'query' }
29
+ | { view: 'collections' }
30
+ | { view: 'cluster' }
31
+ | { view: 'security' }
32
+ | { view: 'collection'; collection: string; subpage: string }
33
+
34
+ /** Resolved current location, the reactive surface views read from. */
35
+ export interface RouteLocation {
36
+ view: View
37
+ /** Selected collection when inside the collections workspace, else null. */
38
+ collection: string | null
39
+ /** Selected collection subpage (`table`, `graph`, …), else null. */
40
+ subpage: string | null
41
+ }
42
+
43
+ export interface Router {
44
+ /** Current top-level view. */
45
+ readonly view: View
46
+ /** Selected collection, or null. */
47
+ readonly collection: string | null
48
+ /** Selected collection subpage, or null. */
49
+ readonly subpage: string | null
50
+ /** Canonical path for the current location (mirrors the SvelteKit URL). */
51
+ readonly path: string
52
+ /** The canonical href a `RouteTarget` maps to (for `<a href>` display). */
53
+ href(target: RouteTarget): string
54
+ /**
55
+ * Navigate to a target. When called from an anchor `onclick`, pass the
56
+ * event so the router can `preventDefault()` (state router) or honour
57
+ * modifier-clicks for open-in-new-tab (Kit router). `replace` swaps the
58
+ * current history entry instead of pushing (Kit only; ignored by state).
59
+ */
60
+ go(target: RouteTarget, event?: Event, replace?: boolean): void
61
+ }
62
+
63
+ /** Map a `RouteTarget` to its canonical path. */
64
+ export function targetToHref(target: RouteTarget): string {
65
+ switch (target.view) {
66
+ case 'query':
67
+ return '/query'
68
+ case 'collections':
69
+ return '/collections'
70
+ case 'cluster':
71
+ return '/cluster'
72
+ case 'security':
73
+ return '/security'
74
+ case 'collection':
75
+ return collectionPageHref(target.collection, target.subpage)
76
+ }
77
+ }
78
+
79
+ /** Map a `RouteTarget` to a resolved location. */
80
+ export function targetToLocation(target: RouteTarget): RouteLocation {
81
+ if (target.view === 'collection') {
82
+ return { view: 'collections', collection: target.collection, subpage: target.subpage }
83
+ }
84
+ return { view: target.view, collection: null, subpage: null }
85
+ }
86
+
87
+ /**
88
+ * Derive a location from a SvelteKit pathname. Mirrors the `routes/` tree:
89
+ * `/query`, `/cluster`, `/security`, `/c/<name>/p/<subpage>`, and `/` or
90
+ * `/collections` for the bare collections workspace.
91
+ */
92
+ export function pathToLocation(pathname: string): RouteLocation {
93
+ if (pathname.startsWith('/query')) return { view: 'query', collection: null, subpage: null }
94
+ if (pathname.startsWith('/cluster')) return { view: 'cluster', collection: null, subpage: null }
95
+ if (pathname.startsWith('/security')) return { view: 'security', collection: null, subpage: null }
96
+ const m = pathname.match(/^\/c\/([^/]+)(?:\/p\/([^/]+))?/)
97
+ if (m) {
98
+ return {
99
+ view: 'collections',
100
+ collection: decodeURIComponent(m[1]),
101
+ subpage: m[2] ? decodeURIComponent(m[2]) : null,
102
+ }
103
+ }
104
+ return { view: 'collections', collection: null, subpage: null }
105
+ }
106
+
107
+ /**
108
+ * A router that lives entirely in component state — it never reads or writes
109
+ * the URL. This is what the Mountable Root uses standalone, so embedding in a
110
+ * host that owns the page never fights over `window.history`.
111
+ */
112
+ export function createStateRouter(initial?: RouteLocation): Router {
113
+ let loc = $state<RouteLocation>(
114
+ initial ?? { view: 'collections', collection: null, subpage: null },
115
+ )
116
+ return {
117
+ get view() {
118
+ return loc.view
119
+ },
120
+ get collection() {
121
+ return loc.collection
122
+ },
123
+ get subpage() {
124
+ return loc.subpage
125
+ },
126
+ get path() {
127
+ return targetToHref(
128
+ loc.collection
129
+ ? { view: 'collection', collection: loc.collection, subpage: loc.subpage ?? 'table' }
130
+ : { view: loc.view },
131
+ )
132
+ },
133
+ href: (target) => targetToHref(target),
134
+ go: (target, event) => {
135
+ // The href is synthetic in state mode, so always suppress the anchor's
136
+ // default navigation and switch the in-memory location instead.
137
+ event?.preventDefault()
138
+ loc = targetToLocation(target)
139
+ },
140
+ }
141
+ }
142
+
143
+ const ROUTER_KEY = Symbol('red-ui-router')
144
+
145
+ let fallbackRouter: Router | null = null
146
+
147
+ /** Provide a router to descendant view components (called by the Mountable Root). */
148
+ export function setRouter(router: Router): Router {
149
+ setContext(ROUTER_KEY, router)
150
+ return router
151
+ }
152
+
153
+ /**
154
+ * Read the router from context. Falls back to a shared state router when a
155
+ * component is used outside a Mountable Root, so views never crash for the
156
+ * lack of a provider.
157
+ */
158
+ export function useRouter(): Router {
159
+ const router = getContext<Router | undefined>(ROUTER_KEY)
160
+ if (router) return router
161
+ if (!fallbackRouter) fallbackRouter = createStateRouter()
162
+ return fallbackRouter
163
+ }
@@ -0,0 +1,148 @@
1
+ // Session-scoped secure-store service. Picks a backend at runtime:
2
+ // - Tauri: defer entirely to the OS keychain (no master password prompt).
3
+ // - Web: no credential vault; target secrets are session-only. The
4
+ // WebEncryptedStore path is retained for legacy/manual unlocks.
5
+ //
6
+ // The store is exposed reactively so the workspace shell can render
7
+ // the lock dialog when `locked` is true, and standalone can hydrate history
8
+ // URLs the moment `store` becomes non-null.
9
+
10
+ import {
11
+ TauriEncryptedStore,
12
+ WebEncryptedStore,
13
+ SecureStoreError,
14
+ type EncryptedStore,
15
+ } from "#reddb";
16
+ import { bootGateLocked, detectSurface, type Surface } from "./surface";
17
+
18
+ const NAMESPACE = "red-ui:secure";
19
+ const PROBE_KEY = "__probe__";
20
+ const PROBE_VALUE = "ok";
21
+
22
+ function envelopesExist(): boolean {
23
+ if (typeof localStorage === "undefined") return false;
24
+ for (let i = 0; i < localStorage.length; i++) {
25
+ const k = localStorage.key(i);
26
+ if (k && k.startsWith(NAMESPACE + ":")) return true;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ class SecureStoreSvc {
32
+ /** The active backend, or null when locked. */
33
+ store = $state<EncryptedStore | null>(null);
34
+ /** True when the user must authenticate before the store can be used. */
35
+ locked = $state<boolean>(true);
36
+ /** True on the first ever launch — UI should ask for a *new* password (with confirm). */
37
+ needsSetup = $state<boolean>(false);
38
+ /** Backend kind, for the UI to skip the password prompt entirely on desktop. */
39
+ backend = $state<"tauri" | "web" | "unknown">("unknown");
40
+ /** The detected Surface (#21) — drives the Surface-aware boot gate. */
41
+ surface = $state<Surface>("web");
42
+ /** Last unlock attempt error, surface-able to the dialog. */
43
+ error = $state<string | null>(null);
44
+
45
+ constructor() {
46
+ if (typeof window === "undefined") {
47
+ this.backend = "unknown";
48
+ return;
49
+ }
50
+ this.surface = detectSurface();
51
+ if (this.surface === "standalone") {
52
+ // Tauri desktop — defer to the OS keychain, which unlocks the vault.
53
+ this.backend = "tauri";
54
+ this.bindTauri();
55
+ return;
56
+ }
57
+ // embedded or web. Neither Surface restores credential secrets from
58
+ // storage: embedded persists nothing, and web persists target labels only.
59
+ this.backend = "web";
60
+ const hasSecret = envelopesExist();
61
+ this.needsSetup = false;
62
+ this.locked = bootGateLocked(this.surface, hasSecret);
63
+ }
64
+
65
+ private async bindTauri() {
66
+ try {
67
+ const mod = await import("@tauri-apps/api/core");
68
+ this.store = new TauriEncryptedStore({ invoke: mod.invoke });
69
+ this.locked = false;
70
+ } catch (e) {
71
+ this.error = `Tauri keychain bridge unavailable: ${(e as Error).message}`;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Unlock the web store with a master password.
77
+ *
78
+ * - If a probe envelope already exists, verifying it confirms the password.
79
+ * - If no probe exists yet (first launch), writes one as the canary.
80
+ *
81
+ * Returns true on success, false on wrong-password (with `error` populated).
82
+ */
83
+ async unlock(password: string): Promise<boolean> {
84
+ this.error = null;
85
+ if (this.backend !== "web") return true;
86
+ let candidate: WebEncryptedStore;
87
+ try {
88
+ candidate = new WebEncryptedStore(password, { namespace: NAMESPACE });
89
+ } catch (e) {
90
+ this.error = (e as Error).message;
91
+ return false;
92
+ }
93
+ try {
94
+ const existing = await candidate.get(PROBE_KEY);
95
+ if (existing === null) {
96
+ await candidate.put(PROBE_KEY, PROBE_VALUE);
97
+ } else if (existing !== PROBE_VALUE) {
98
+ this.error = "Wrong password";
99
+ return false;
100
+ }
101
+ } catch (e) {
102
+ if (e instanceof SecureStoreError && e.code === "DECRYPT_FAILED") {
103
+ this.error = "Wrong password";
104
+ return false;
105
+ }
106
+ this.error = (e as Error).message;
107
+ return false;
108
+ }
109
+ this.store = candidate;
110
+ this.locked = false;
111
+ this.needsSetup = false;
112
+ return true;
113
+ }
114
+
115
+ lock() {
116
+ this.store = null;
117
+ if (this.backend === "web")
118
+ this.locked = bootGateLocked(this.surface, envelopesExist());
119
+ }
120
+
121
+ /**
122
+ * Nuclear option: wipe every red-ui artifact from this browser and
123
+ * return to a clean "new install" state. Removes the encrypted
124
+ * envelopes (`red-ui:secure:*`), the active-connection pin
125
+ * (`red-ui:connection`), the history labels (`red-ui:history`), and
126
+ * the theme preference (`red-ui:theme`) — everything else is in-memory
127
+ * only and resets when the page reloads.
128
+ */
129
+ wipe() {
130
+ if (typeof localStorage === "undefined") return;
131
+ const drop: string[] = [];
132
+ for (let i = 0; i < localStorage.length; i++) {
133
+ const k = localStorage.key(i);
134
+ if (!k) continue;
135
+ if (k.startsWith(NAMESPACE + ":")) drop.push(k);
136
+ else if (k.startsWith("red-ui:")) drop.push(k);
137
+ }
138
+ for (const k of drop) localStorage.removeItem(k);
139
+ this.store = null;
140
+ if (this.backend === "web") {
141
+ this.locked = bootGateLocked(this.surface, false);
142
+ this.needsSetup = false;
143
+ }
144
+ this.error = null;
145
+ }
146
+ }
147
+
148
+ export const secureStore = new SecureStoreSvc();
@@ -0,0 +1,198 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from 'svelte'
3
+ import { BitsConfig, Dialog, Popover } from 'bits-ui'
4
+ import Workspace from '../Workspace.svelte'
5
+
6
+ type FindingState = 'pending' | 'working' | 'blocked'
7
+
8
+ type Finding = {
9
+ state: FindingState
10
+ detail: string
11
+ }
12
+
13
+ let { portalTarget }: { portalTarget: HTMLElement } = $props()
14
+
15
+ let popoverOpen = $state(false)
16
+ let dialogOpen = $state(false)
17
+ let popoverTrigger: HTMLButtonElement | null = $state(null)
18
+ let popoverContent: HTMLDivElement | null = $state(null)
19
+ let dialogContent: HTMLDivElement | null = $state(null)
20
+ let dialogInput: HTMLInputElement | null = $state(null)
21
+ let findings = $state<Record<'portals' | 'focus' | 'measurement', Finding>>({
22
+ portals: { state: 'pending', detail: 'waiting for automated probe' },
23
+ focus: { state: 'pending', detail: 'waiting for automated probe' },
24
+ measurement: { state: 'pending', detail: 'waiting for automated probe' },
25
+ })
26
+
27
+ const statusClass: Record<FindingState, string> = {
28
+ pending: 'border-line-2 bg-bg-2 text-fg-2',
29
+ working: 'border-ok/40 bg-ok/10 text-ok',
30
+ blocked: 'border-danger/40 bg-danger/10 text-danger',
31
+ }
32
+
33
+ function ownerShadowRoot(node: Node | null) {
34
+ const root = node?.getRootNode()
35
+ return root instanceof ShadowRoot ? root : null
36
+ }
37
+
38
+ function setFinding(key: keyof typeof findings, state: FindingState, detail: string) {
39
+ findings[key] = { state, detail }
40
+ }
41
+
42
+ function nextFrame() {
43
+ return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
44
+ }
45
+
46
+ async function settleLayout() {
47
+ await tick()
48
+ await nextFrame()
49
+ await nextFrame()
50
+ }
51
+
52
+ async function runProbe() {
53
+ popoverOpen = true
54
+ await settleLayout()
55
+
56
+ const popoverRoot = ownerShadowRoot(popoverContent)
57
+ const popoverPortalledInsideShadow = Boolean(
58
+ popoverContent && popoverRoot && portalTarget.contains(popoverContent),
59
+ )
60
+ const popoverLeakedToBody = Boolean(popoverContent && document.body.contains(popoverContent))
61
+
62
+ if (popoverPortalledInsideShadow && !popoverLeakedToBody) {
63
+ setFinding('portals', 'working', 'bits-ui Portal renders into an HTMLElement inside the shadow root')
64
+ } else {
65
+ setFinding('portals', 'blocked', 'portal content escaped the configured shadow-root portal target')
66
+ }
67
+
68
+ const triggerRect = popoverTrigger?.getBoundingClientRect()
69
+ const contentRect = popoverContent?.getBoundingClientRect()
70
+ const measured = Boolean(
71
+ triggerRect &&
72
+ contentRect &&
73
+ triggerRect.width > 0 &&
74
+ triggerRect.height > 0 &&
75
+ contentRect.width > 0 &&
76
+ contentRect.height > 0 &&
77
+ Number.isFinite(contentRect.left) &&
78
+ Number.isFinite(contentRect.top),
79
+ )
80
+
81
+ if (measured) {
82
+ setFinding(
83
+ 'measurement',
84
+ 'working',
85
+ `trigger ${Math.round(triggerRect!.width)}x${Math.round(triggerRect!.height)}, popover ${Math.round(
86
+ contentRect!.width,
87
+ )}x${Math.round(contentRect!.height)}`,
88
+ )
89
+ } else {
90
+ setFinding('measurement', 'blocked', 'floating content or trigger produced a zero/invalid layout rect')
91
+ }
92
+
93
+ dialogOpen = true
94
+ await settleLayout()
95
+ dialogInput?.focus()
96
+ await settleLayout()
97
+
98
+ const shadowRoot = ownerShadowRoot(dialogContent)
99
+ const focused = shadowRoot?.activeElement
100
+ const focusInsideDialog = Boolean(
101
+ focused && dialogContent && (focused === dialogContent || dialogContent.contains(focused)),
102
+ )
103
+
104
+ if (focusInsideDialog) {
105
+ setFinding('focus', 'working', 'dialog focus is contained in the shadow root via shadowRoot.activeElement')
106
+ } else {
107
+ setFinding('focus', 'blocked', 'dialog focus did not move to or remain inside the shadow-root dialog')
108
+ }
109
+ }
110
+
111
+ onMount(() => {
112
+ void runProbe()
113
+ })
114
+ </script>
115
+
116
+ <BitsConfig defaultPortalTo={portalTarget}>
117
+ <section class="min-h-[720px] bg-bg-0 text-fg-0 font-sans">
118
+ <div class="grid min-h-[720px] grid-cols-[minmax(0,1fr)_360px]">
119
+ <div class="relative overflow-hidden border-r border-line-2">
120
+ <Workspace />
121
+ </div>
122
+
123
+ <aside class="flex flex-col gap-4 bg-bg-1 p-4">
124
+ <div>
125
+ <p class="type-label">Shadow DOM spike</p>
126
+ <h1 class="mt-2 text-lg font-semibold text-fg-0">Svelte 5 + Tailwind v4 + bits-ui</h1>
127
+ </div>
128
+
129
+ <div class="space-y-2">
130
+ {#each Object.entries(findings) as [key, finding]}
131
+ <div class={`rounded-md border p-3 ${statusClass[finding.state]}`} data-spike-finding={key}>
132
+ <div class="flex items-center justify-between gap-3">
133
+ <span class="font-mono text-[11px] uppercase tracking-[0.08em]">{key}</span>
134
+ <span class="font-mono text-[10px] uppercase tracking-[0.08em]">{finding.state}</span>
135
+ </div>
136
+ <p class="mt-2 text-xs leading-5 text-fg-1">{finding.detail}</p>
137
+ </div>
138
+ {/each}
139
+ </div>
140
+
141
+ <Popover.Root bind:open={popoverOpen}>
142
+ <Popover.Trigger
143
+ bind:ref={popoverTrigger}
144
+ data-spike-popover-trigger
145
+ class="h-9 rounded-md border border-line-2 bg-bg-2 px-3 text-left text-sm text-fg-0 hover:bg-bg-3"
146
+ >
147
+ Open popover
148
+ </Popover.Trigger>
149
+ <Popover.Portal>
150
+ <Popover.Content
151
+ bind:ref={popoverContent}
152
+ data-spike-popover-content
153
+ side="bottom"
154
+ align="start"
155
+ sideOffset={8}
156
+ class="z-50 w-[260px] rounded-md border border-line-2 bg-bg-1 p-3 text-sm text-fg-1 shadow-lg"
157
+ >
158
+ <p class="font-medium text-fg-0">Portalled inside shadow root</p>
159
+ <p class="mt-1 text-xs text-fg-2">Floating UI measured this against the trigger.</p>
160
+ <Popover.Arrow class="fill-bg-1" />
161
+ </Popover.Content>
162
+ </Popover.Portal>
163
+ </Popover.Root>
164
+
165
+ <Dialog.Root bind:open={dialogOpen}>
166
+ <Dialog.Trigger class="h-9 rounded-md border border-line-2 bg-bg-2 px-3 text-left text-sm text-fg-0 hover:bg-bg-3">
167
+ Open dialog
168
+ </Dialog.Trigger>
169
+ <Dialog.Portal>
170
+ <Dialog.Overlay class="fixed inset-0 z-40 bg-black/45" />
171
+ <Dialog.Content
172
+ bind:ref={dialogContent}
173
+ data-spike-dialog-content
174
+ trapFocus
175
+ class="fixed left-1/2 top-1/2 z-50 w-[320px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-line-2 bg-bg-1 p-4 shadow-lg"
176
+ >
177
+ <Dialog.Title class="text-sm font-semibold text-fg-0">Focus probe</Dialog.Title>
178
+ <Dialog.Description class="mt-1 text-xs text-fg-2">
179
+ Active focus is checked through shadowRoot.activeElement.
180
+ </Dialog.Description>
181
+ <input
182
+ bind:this={dialogInput}
183
+ data-spike-dialog-input
184
+ class="mt-3 h-9 w-full rounded-md border border-line-2 bg-bg-2 px-3 text-sm text-fg-0 outline-none focus:border-accent"
185
+ value="shadow-root focus target"
186
+ />
187
+ <div class="mt-4 flex justify-end">
188
+ <Dialog.Close class="h-8 rounded-md border border-line-2 bg-bg-2 px-3 text-xs text-fg-0 hover:bg-bg-3">
189
+ Close
190
+ </Dialog.Close>
191
+ </div>
192
+ </Dialog.Content>
193
+ </Dialog.Portal>
194
+ </Dialog.Root>
195
+ </aside>
196
+ </div>
197
+ </section>
198
+ </BitsConfig>