@joewinke/jatui 0.1.19 → 0.1.20
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/package.json +1 -1
- package/src/lib/components/ChipInput.svelte +12 -13
- package/src/lib/components/GPSTracker.svelte +202 -0
- package/src/lib/components/InlineEdit.svelte +7 -9
- package/src/lib/components/KeyboardShortcutsOverlay.svelte +296 -0
- package/src/lib/components/LocationMap.svelte +186 -0
- package/src/lib/components/MapView.svelte +341 -0
- package/src/lib/components/linked-columns/LinkedColumns.svelte +520 -0
- package/src/lib/components/replay/ChapterTimeline.svelte +326 -0
- package/src/lib/components/session-nav/transcriptModel.ts +352 -0
- package/src/lib/index.ts +47 -0
- package/src/lib/types/googleMaps.d.ts +51 -0
- package/src/lib/types/maps.ts +43 -0
- package/src/lib/utils/googleMapsLoader.ts +84 -0
package/package.json
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
<script
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ChipInput - Generic contenteditable input with inline chips and autocomplete.
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Contenteditable div with placeholder
|
|
7
|
+
* - Chip insertion/deletion (non-editable inline spans)
|
|
8
|
+
* - Autocomplete dropdown with keyboard navigation
|
|
9
|
+
* - Serialization (DOM -> text with chip markers)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// --- Types ---
|
|
2
13
|
export interface ChipSuggestion {
|
|
3
14
|
label: string;
|
|
4
15
|
description?: string;
|
|
@@ -28,18 +39,6 @@
|
|
|
28
39
|
/** If true, insert as plain text instead of a chip element */
|
|
29
40
|
insertAsText?: boolean;
|
|
30
41
|
}
|
|
31
|
-
</script>
|
|
32
|
-
|
|
33
|
-
<script lang="ts">
|
|
34
|
-
/**
|
|
35
|
-
* ChipInput - Generic contenteditable input with inline chips and autocomplete.
|
|
36
|
-
*
|
|
37
|
-
* Handles:
|
|
38
|
-
* - Contenteditable div with placeholder
|
|
39
|
-
* - Chip insertion/deletion (non-editable inline spans)
|
|
40
|
-
* - Autocomplete dropdown with keyboard navigation
|
|
41
|
-
* - Serialization (DOM -> text with chip markers)
|
|
42
|
-
*/
|
|
43
42
|
|
|
44
43
|
// --- Props ---
|
|
45
44
|
let {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* GPSTracker — headless GPS tracking action component.
|
|
4
|
+
*
|
|
5
|
+
* Watches the device's geolocation and fires `onLocationUpdate` on each new
|
|
6
|
+
* fix. Uses `watchPosition` on mobile; falls back to interval polling on
|
|
7
|
+
* desktop. Renders a small fixed status indicator in the corner.
|
|
8
|
+
*
|
|
9
|
+
* The host app is responsible for sending coordinates to the server —
|
|
10
|
+
* use the `onLocationUpdate` callback to do so (no hardcoded endpoint).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
*
|
|
14
|
+
* <GPSTracker
|
|
15
|
+
* enabled={true}
|
|
16
|
+
* onLocationUpdate={async (pos) => {
|
|
17
|
+
* await fetch('/api/location', {
|
|
18
|
+
* method: 'POST',
|
|
19
|
+
* body: JSON.stringify({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
|
20
|
+
* });
|
|
21
|
+
* }}
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* Ported from flush technician/GPSTracker (298L) — steelbridge fork capture (jst-e0d1u.4).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
/** How often to poll position in seconds (mobile uses watchPosition + this; desktop uses this * 2). */
|
|
30
|
+
updateInterval?: number;
|
|
31
|
+
onLocationUpdate?: (position: GeolocationPosition) => void;
|
|
32
|
+
onError?: (error: GeolocationPositionError) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let { enabled = true, updateInterval = 30, onLocationUpdate, onError }: Props = $props();
|
|
36
|
+
|
|
37
|
+
let watchId: number | null = null;
|
|
38
|
+
let intervalId: number | null = null;
|
|
39
|
+
let lastPosition = $state<GeolocationPosition | null>(null);
|
|
40
|
+
let isTracking = $state(false);
|
|
41
|
+
let hasPermission = $state<boolean | null>(null);
|
|
42
|
+
let lastUpdate = $state<Date | null>(null);
|
|
43
|
+
let isMounted = false;
|
|
44
|
+
|
|
45
|
+
import { onMount, onDestroy } from 'svelte';
|
|
46
|
+
|
|
47
|
+
onMount(() => {
|
|
48
|
+
if (isMounted) return;
|
|
49
|
+
isMounted = true;
|
|
50
|
+
if (typeof navigator !== 'undefined' && enabled) {
|
|
51
|
+
checkPermission();
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (hasPermission !== false) startTracking();
|
|
54
|
+
}, 100);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
onDestroy(() => {
|
|
59
|
+
isMounted = false;
|
|
60
|
+
stopTracking();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function checkPermission() {
|
|
64
|
+
if (typeof navigator === 'undefined' || !navigator.permissions) return;
|
|
65
|
+
try {
|
|
66
|
+
const result = await navigator.permissions.query({ name: 'geolocation' });
|
|
67
|
+
hasPermission = result.state === 'granted';
|
|
68
|
+
result.addEventListener('change', () => {
|
|
69
|
+
hasPermission = result.state === 'granted';
|
|
70
|
+
if (hasPermission && enabled) startTracking();
|
|
71
|
+
else stopTracking();
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// permissions API not available — proceed optimistically
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function startTracking() {
|
|
79
|
+
if (typeof navigator === 'undefined' || !navigator.geolocation || !enabled || isTracking) return;
|
|
80
|
+
|
|
81
|
+
isTracking = true;
|
|
82
|
+
|
|
83
|
+
// Initial fix with relaxed settings (faster on desktop)
|
|
84
|
+
navigator.geolocation.getCurrentPosition(
|
|
85
|
+
(pos) => {
|
|
86
|
+
handleSuccess(pos);
|
|
87
|
+
// Try for a better fix
|
|
88
|
+
navigator.geolocation.getCurrentPosition(handleSuccess, () => {}, {
|
|
89
|
+
enableHighAccuracy: true,
|
|
90
|
+
timeout: 10000,
|
|
91
|
+
maximumAge: 0
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
(err) => {
|
|
95
|
+
if (err.code === 3) {
|
|
96
|
+
// TIMEOUT — try once more with any cached position
|
|
97
|
+
navigator.geolocation.getCurrentPosition(handleSuccess, handleError, {
|
|
98
|
+
enableHighAccuracy: false,
|
|
99
|
+
timeout: 15000,
|
|
100
|
+
maximumAge: Infinity
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
handleError(err);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Continuous watch on mobile only
|
|
110
|
+
const isMobile =
|
|
111
|
+
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
112
|
+
if (isMobile) {
|
|
113
|
+
watchId = navigator.geolocation.watchPosition(handleSuccess, handleError, {
|
|
114
|
+
enableHighAccuracy: true,
|
|
115
|
+
timeout: 10000,
|
|
116
|
+
maximumAge: 30000
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Interval polling
|
|
121
|
+
const freq = (isMobile ? updateInterval : updateInterval * 2) * 1000;
|
|
122
|
+
intervalId = window.setInterval(() => {
|
|
123
|
+
navigator.geolocation.getCurrentPosition(
|
|
124
|
+
handleSuccess,
|
|
125
|
+
() => {}, // ignore interval failures silently
|
|
126
|
+
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }
|
|
127
|
+
);
|
|
128
|
+
}, freq);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stopTracking() {
|
|
132
|
+
isTracking = false;
|
|
133
|
+
if (watchId !== null) {
|
|
134
|
+
navigator.geolocation.clearWatch(watchId);
|
|
135
|
+
watchId = null;
|
|
136
|
+
}
|
|
137
|
+
if (intervalId !== null) {
|
|
138
|
+
clearInterval(intervalId);
|
|
139
|
+
intervalId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function handleSuccess(position: GeolocationPosition) {
|
|
144
|
+
lastPosition = position;
|
|
145
|
+
lastUpdate = new Date();
|
|
146
|
+
onLocationUpdate?.(position);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleError(error: GeolocationPositionError) {
|
|
150
|
+
if (error.code === error.PERMISSION_DENIED) {
|
|
151
|
+
hasPermission = false;
|
|
152
|
+
stopTracking();
|
|
153
|
+
}
|
|
154
|
+
onError?.(error);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
$effect(() => {
|
|
158
|
+
if (enabled && !isTracking && hasPermission !== false) startTracking();
|
|
159
|
+
else if (!enabled && isTracking) stopTracking();
|
|
160
|
+
});
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<!-- GPS status indicator (fixed bottom-right) -->
|
|
164
|
+
{#if typeof navigator !== 'undefined'}
|
|
165
|
+
<div class="fixed bottom-20 right-4 bg-base-200 rounded-lg shadow-lg p-2 text-xs z-40">
|
|
166
|
+
<div class="flex items-center gap-2">
|
|
167
|
+
<div
|
|
168
|
+
class="w-2 h-2 rounded-full {isTracking
|
|
169
|
+
? 'bg-success'
|
|
170
|
+
: hasPermission === false
|
|
171
|
+
? 'bg-error'
|
|
172
|
+
: 'bg-warning'}"
|
|
173
|
+
></div>
|
|
174
|
+
<span>GPS {isTracking ? 'Active' : hasPermission === false ? 'Denied' : 'Inactive'}</span>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{#if hasPermission === false}
|
|
178
|
+
<div class="mt-2">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
class="btn btn-xs btn-primary"
|
|
182
|
+
onclick={() => {
|
|
183
|
+
hasPermission = null;
|
|
184
|
+
isTracking = false;
|
|
185
|
+
startTracking();
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
Enable GPS
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="text-error mt-1">Location required</div>
|
|
192
|
+
{/if}
|
|
193
|
+
|
|
194
|
+
{#if lastUpdate && isTracking}
|
|
195
|
+
<div class="text-base-content/70">Updated: {lastUpdate.toLocaleTimeString()}</div>
|
|
196
|
+
{/if}
|
|
197
|
+
|
|
198
|
+
{#if lastPosition && isTracking}
|
|
199
|
+
<div class="text-base-content/70">Accuracy: {Math.round(lastPosition.coords.accuracy)}m</div>
|
|
200
|
+
{/if}
|
|
201
|
+
</div>
|
|
202
|
+
{/if}
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
<script module lang="ts">
|
|
2
|
-
/** A display segment for formula-aware rendering */
|
|
3
|
-
export interface DisplaySegment {
|
|
4
|
-
type: 'text' | 'formula';
|
|
5
|
-
display: string;
|
|
6
|
-
tooltip?: string;
|
|
7
|
-
}
|
|
8
|
-
</script>
|
|
9
|
-
|
|
10
1
|
<script lang="ts">
|
|
11
2
|
/**
|
|
12
3
|
* InlineEdit Component
|
|
@@ -23,6 +14,13 @@
|
|
|
23
14
|
* - Optional formula-aware display: segments with type/display/tooltip
|
|
24
15
|
*/
|
|
25
16
|
|
|
17
|
+
/** A display segment for formula-aware rendering */
|
|
18
|
+
export interface DisplaySegment {
|
|
19
|
+
type: 'text' | 'formula';
|
|
20
|
+
display: string;
|
|
21
|
+
tooltip?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
26
24
|
interface Props {
|
|
27
25
|
/** Current value */
|
|
28
26
|
value: string;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* KeyboardShortcutsOverlay — press `?` to toggle a modal listing the
|
|
4
|
+
* keyboard shortcuts available on the current page. Press `?` again or
|
|
5
|
+
* `Escape` to dismiss. Input/contenteditable fields and Ctrl/Meta/Alt
|
|
6
|
+
* combos are ignored so the shortcut never interferes with typing.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
*
|
|
10
|
+
* <KeyboardShortcutsOverlay shortcuts={[
|
|
11
|
+
* { key: 'j / ↓', description: 'Focus next item' },
|
|
12
|
+
* { key: 'k / ↑', description: 'Focus previous item' },
|
|
13
|
+
* { key: 'Enter', description: 'Open detail' },
|
|
14
|
+
* ]} />
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface KeyboardShortcut {
|
|
18
|
+
key: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SectionEntry {
|
|
23
|
+
key: string;
|
|
24
|
+
description: string;
|
|
25
|
+
/** Optional oklch color to tint the key badge — used for legend entries */
|
|
26
|
+
color?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ShortcutSection {
|
|
30
|
+
title: string;
|
|
31
|
+
shortcuts: SectionEntry[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
shortcuts = [],
|
|
36
|
+
sections,
|
|
37
|
+
title = 'Keyboard Shortcuts',
|
|
38
|
+
open = $bindable(false)
|
|
39
|
+
}: {
|
|
40
|
+
shortcuts?: KeyboardShortcut[];
|
|
41
|
+
/**
|
|
42
|
+
* Optional grouped sections (e.g. mode-specific shortcut tables for
|
|
43
|
+
* /inbox: list / detail / compose). When provided, takes
|
|
44
|
+
* precedence over the flat `shortcuts` prop.
|
|
45
|
+
*/
|
|
46
|
+
sections?: ShortcutSection[];
|
|
47
|
+
title?: string;
|
|
48
|
+
open?: boolean;
|
|
49
|
+
} = $props();
|
|
50
|
+
|
|
51
|
+
let isOpen = $state(open);
|
|
52
|
+
|
|
53
|
+
$effect(() => {
|
|
54
|
+
isOpen = open;
|
|
55
|
+
});
|
|
56
|
+
$effect(() => {
|
|
57
|
+
open = isOpen;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
61
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
62
|
+
const tag = target.tagName;
|
|
63
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
64
|
+
return target.isContentEditable;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onKeydown(e: KeyboardEvent) {
|
|
68
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
69
|
+
|
|
70
|
+
if (e.key === '?') {
|
|
71
|
+
// `?` is always Shift+/. Don't trigger while typing — would swallow the char.
|
|
72
|
+
if (isTypingTarget(e.target)) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
isOpen = !isOpen;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isOpen && e.key === 'Escape') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
isOpen = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
window.addEventListener('keydown', onKeydown);
|
|
86
|
+
return () => window.removeEventListener('keydown', onKeydown);
|
|
87
|
+
});
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
{#if isOpen}
|
|
91
|
+
<div
|
|
92
|
+
class="kso-backdrop"
|
|
93
|
+
role="presentation"
|
|
94
|
+
onclick={() => (isOpen = false)}
|
|
95
|
+
>
|
|
96
|
+
<div
|
|
97
|
+
class="kso-panel animate-scale-in"
|
|
98
|
+
role="dialog"
|
|
99
|
+
aria-modal="true"
|
|
100
|
+
aria-label={title}
|
|
101
|
+
onclick={(e) => e.stopPropagation()}
|
|
102
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
103
|
+
tabindex="-1"
|
|
104
|
+
>
|
|
105
|
+
<div class="kso-header">
|
|
106
|
+
<h2 class="kso-title">{title}</h2>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
class="kso-close"
|
|
110
|
+
onclick={() => (isOpen = false)}
|
|
111
|
+
aria-label="Close keyboard shortcuts"
|
|
112
|
+
>
|
|
113
|
+
×
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="kso-list">
|
|
117
|
+
{#if sections && sections.length > 0}
|
|
118
|
+
{#each sections as section, sIdx (section.title + '::' + sIdx)}
|
|
119
|
+
<div class="kso-section-title">{section.title}</div>
|
|
120
|
+
{#each section.shortcuts as { key, description, color }, i (section.title + '::' + key + '::' + i)}
|
|
121
|
+
<div class="kso-row">
|
|
122
|
+
<kbd
|
|
123
|
+
class="kso-key"
|
|
124
|
+
style={color
|
|
125
|
+
? `color:${color};border-color:color-mix(in oklch,${color} 50%,oklch(0.35 0.02 250));background:color-mix(in oklch,${color} 12%,oklch(0.25 0.02 250))`
|
|
126
|
+
: ''}
|
|
127
|
+
>
|
|
128
|
+
{key}
|
|
129
|
+
</kbd>
|
|
130
|
+
<span class="kso-description">{description}</span>
|
|
131
|
+
</div>
|
|
132
|
+
{/each}
|
|
133
|
+
{/each}
|
|
134
|
+
{:else}
|
|
135
|
+
{#each shortcuts as { key, description }, i (key + '::' + i)}
|
|
136
|
+
<div class="kso-row">
|
|
137
|
+
<kbd class="kso-key">{key}</kbd>
|
|
138
|
+
<span class="kso-description">{description}</span>
|
|
139
|
+
</div>
|
|
140
|
+
{/each}
|
|
141
|
+
{/if}
|
|
142
|
+
<div class="kso-row kso-row-meta">
|
|
143
|
+
<kbd class="kso-key">?</kbd>
|
|
144
|
+
<span class="kso-description">Toggle this overlay</span>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="kso-row kso-row-meta">
|
|
147
|
+
<kbd class="kso-key">Esc</kbd>
|
|
148
|
+
<span class="kso-description">Close overlay</span>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{/if}
|
|
154
|
+
|
|
155
|
+
<style>
|
|
156
|
+
.kso-backdrop {
|
|
157
|
+
position: fixed;
|
|
158
|
+
inset: 0;
|
|
159
|
+
background: oklch(0 0 0 / 0.6);
|
|
160
|
+
backdrop-filter: blur(2px);
|
|
161
|
+
z-index: 60;
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
justify-content: center;
|
|
165
|
+
padding: 1.5rem;
|
|
166
|
+
animation: kso-fade 0.15s ease-out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.kso-panel {
|
|
170
|
+
background: oklch(0.18 0.02 250);
|
|
171
|
+
border: 1px solid oklch(0.30 0.03 250);
|
|
172
|
+
border-radius: 0.75rem;
|
|
173
|
+
box-shadow: 0 20px 60px oklch(0 0 0 / 0.6);
|
|
174
|
+
max-width: 34rem;
|
|
175
|
+
width: 100%;
|
|
176
|
+
max-height: 80vh;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
display: flex;
|
|
179
|
+
flex-direction: column;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.kso-header {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
justify-content: space-between;
|
|
186
|
+
padding: 0.9rem 1.25rem;
|
|
187
|
+
border-bottom: 1px solid oklch(0.25 0.02 250);
|
|
188
|
+
flex-shrink: 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.kso-title {
|
|
192
|
+
font-size: 0.95rem;
|
|
193
|
+
font-weight: 600;
|
|
194
|
+
color: oklch(0.92 0.02 250);
|
|
195
|
+
margin: 0;
|
|
196
|
+
letter-spacing: 0.01em;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.kso-close {
|
|
200
|
+
background: transparent;
|
|
201
|
+
border: none;
|
|
202
|
+
color: oklch(0.65 0.02 250);
|
|
203
|
+
font-size: 1.5rem;
|
|
204
|
+
line-height: 1;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
padding: 0.15rem 0.5rem;
|
|
207
|
+
border-radius: 0.35rem;
|
|
208
|
+
transition:
|
|
209
|
+
background 0.12s ease,
|
|
210
|
+
color 0.12s ease;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.kso-close:hover {
|
|
214
|
+
color: oklch(0.94 0.02 250);
|
|
215
|
+
background: oklch(0.25 0.02 250);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.kso-list {
|
|
219
|
+
padding: 0.75rem 1.25rem 1.25rem;
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
gap: 0.3rem;
|
|
223
|
+
overflow-y: auto;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.kso-row {
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
gap: 1rem;
|
|
230
|
+
padding: 0.4rem 0.25rem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.kso-section-title {
|
|
234
|
+
font-size: 0.7rem;
|
|
235
|
+
text-transform: uppercase;
|
|
236
|
+
letter-spacing: 0.08em;
|
|
237
|
+
color: oklch(0.62 0.05 240);
|
|
238
|
+
padding: 0.55rem 0.25rem 0.2rem;
|
|
239
|
+
border-bottom: 1px solid oklch(0.25 0.02 250);
|
|
240
|
+
margin-top: 0.25rem;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.kso-section-title:first-of-type {
|
|
244
|
+
margin-top: 0;
|
|
245
|
+
padding-top: 0.1rem;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.kso-row-meta {
|
|
249
|
+
opacity: 0.7;
|
|
250
|
+
border-top: 1px dashed oklch(0.25 0.02 250);
|
|
251
|
+
margin-top: 0.25rem;
|
|
252
|
+
padding-top: 0.5rem;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.kso-row-meta:not(:first-of-type) {
|
|
256
|
+
border-top: none;
|
|
257
|
+
margin-top: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.kso-key {
|
|
261
|
+
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
|
262
|
+
font-size: 0.78rem;
|
|
263
|
+
background: oklch(0.25 0.02 250);
|
|
264
|
+
border: 1px solid oklch(0.35 0.02 250);
|
|
265
|
+
border-bottom-width: 2px;
|
|
266
|
+
border-radius: 0.35rem;
|
|
267
|
+
padding: 0.18rem 0.55rem;
|
|
268
|
+
min-width: 3.25rem;
|
|
269
|
+
text-align: center;
|
|
270
|
+
color: oklch(0.93 0.02 250);
|
|
271
|
+
flex-shrink: 0;
|
|
272
|
+
white-space: nowrap;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.kso-description {
|
|
276
|
+
font-size: 0.85rem;
|
|
277
|
+
color: oklch(0.78 0.02 250);
|
|
278
|
+
line-height: 1.35;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@keyframes kso-fade {
|
|
282
|
+
from {
|
|
283
|
+
opacity: 0;
|
|
284
|
+
}
|
|
285
|
+
to {
|
|
286
|
+
opacity: 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@media (prefers-reduced-motion: reduce) {
|
|
291
|
+
.kso-backdrop,
|
|
292
|
+
.kso-panel {
|
|
293
|
+
animation: none !important;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
</style>
|