@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.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- 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>
|