@karyl-chan/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +58 -0
  2. package/src/components/AppBadge.vue +155 -0
  3. package/src/components/AppButton.vue +171 -0
  4. package/src/components/AppConfirmDialog.vue +176 -0
  5. package/src/components/AppItemCard.vue +216 -0
  6. package/src/components/AppMenu.vue +197 -0
  7. package/src/components/AppMenuItem.vue +43 -0
  8. package/src/components/AppModal.vue +257 -0
  9. package/src/components/AppPopover.vue +275 -0
  10. package/src/components/AppSelect.vue +89 -0
  11. package/src/components/AppSelectField.vue +260 -0
  12. package/src/components/AppTabs.vue +310 -0
  13. package/src/components/AppTabsRouted.vue +409 -0
  14. package/src/components/AppTextArea.vue +128 -0
  15. package/src/components/AppTextField.vue +165 -0
  16. package/src/components/AppToast.vue +65 -0
  17. package/src/components/AppToggle.vue +105 -0
  18. package/src/components/Draggable.vue +178 -0
  19. package/src/components/GlobalConfirmDialog.vue +25 -0
  20. package/src/components/UnreadPill.vue +28 -0
  21. package/src/components/UserAvatar.vue +114 -0
  22. package/src/components/UserCard.vue +274 -0
  23. package/src/components/UserItem.vue +167 -0
  24. package/src/components/tab-types.ts +9 -0
  25. package/src/composables/use-app-shell.ts +65 -0
  26. package/src/composables/use-breakpoint.ts +14 -0
  27. package/src/composables/use-click-outside-stack.ts +67 -0
  28. package/src/composables/use-color-scheme.ts +75 -0
  29. package/src/composables/use-confirm.ts +36 -0
  30. package/src/composables/use-drawer.css +47 -0
  31. package/src/composables/use-drawer.ts +58 -0
  32. package/src/composables/use-escape-stack.ts +80 -0
  33. package/src/composables/use-file-drop.ts +47 -0
  34. package/src/composables/use-long-press.ts +62 -0
  35. package/src/composables/use-popover.css +32 -0
  36. package/src/composables/use-popover.ts +1324 -0
  37. package/src/composables/use-shift-key.ts +18 -0
  38. package/src/index.ts +75 -0
  39. package/src/lib/avatar.ts +35 -0
  40. package/src/reset.css +53 -0
  41. package/src/stores/toastStore.ts +26 -0
  42. package/src/tokens.css +159 -0
@@ -0,0 +1,274 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * UserCard — full user profile card with banner, animated avatar,
4
+ * display name, tag line, and slot-driven extras.
5
+ *
6
+ * Pure presentation. Mirror of the bot's DiscordUserCard chrome but
7
+ * with no store / router / api coupling — caller passes the resolved
8
+ * values directly.
9
+ *
10
+ * Animation defaults to `always` (banner + avatar) because the card
11
+ * is typically opened from a deliberate click — the user wants to
12
+ * see the animated asset they came here for. Override per-instance
13
+ * with `avatarAnimate` / `bannerAnimate`.
14
+ *
15
+ * Slots:
16
+ * - `#facts`: key/value rows under the name. Use a `<dl class="facts">`
17
+ * inside, or anything you want.
18
+ * - `#actions`: a row of buttons under the facts (e.g. "Send DM").
19
+ * - `#default`: free-form content area appended to the body, useful
20
+ * for plugin-specific extras (recent activity, last seen, etc.).
21
+ */
22
+ import { computed } from 'vue';
23
+ import { animatedAvatarUrl, isAnimatedBanner } from '../lib/avatar';
24
+ import UserAvatar from './UserAvatar.vue';
25
+
26
+ const props = withDefaults(defineProps<{
27
+ name: string;
28
+ /** Optional secondary display line (server-profile vs. global name divergence). */
29
+ nickname?: string | null;
30
+ /** `@username` (no `@` needed — the component renders the prefix). */
31
+ username?: string | null;
32
+ /** Discriminator (`#1234`). Rendered alongside `username` if set. */
33
+ discriminator?: string | null;
34
+ /** Avatar URL — animated detection auto-handled by UserAvatar. */
35
+ avatarUrl?: string | null;
36
+ /** Banner URL — animated detection auto-handled here. */
37
+ bannerUrl?: string | null;
38
+ /** Discord accent colour (24-bit integer). Used when there's no banner. */
39
+ accentColor?: number | null;
40
+ /** Renders a 'BOT' tag next to the name. */
41
+ isBot?: boolean;
42
+ /** Avatar animation mode. Default `always`. */
43
+ avatarAnimate?: 'never' | 'hover' | 'always';
44
+ /** Banner animation mode. Default `always`. */
45
+ bannerAnimate?: 'never' | 'hover' | 'always';
46
+ /** Skeleton mode — shows shimmer placeholders for name / facts. */
47
+ loading?: boolean;
48
+ /** Optional inline error message. */
49
+ error?: string | null;
50
+ }>(), {
51
+ nickname: null,
52
+ username: null,
53
+ discriminator: null,
54
+ avatarUrl: null,
55
+ bannerUrl: null,
56
+ accentColor: null,
57
+ isBot: false,
58
+ avatarAnimate: 'always',
59
+ bannerAnimate: 'always',
60
+ loading: false,
61
+ error: null,
62
+ });
63
+
64
+ defineSlots<{
65
+ facts?: (props: Record<string, never>) => unknown;
66
+ actions?: (props: Record<string, never>) => unknown;
67
+ default?: (props: Record<string, never>) => unknown;
68
+ }>();
69
+
70
+ const bannerStyle = computed(() => {
71
+ if (props.loading) return { backgroundColor: 'var(--bg-surface-2)' };
72
+ if (props.bannerUrl) {
73
+ const animated =
74
+ props.bannerAnimate === 'always' && isAnimatedBanner(props.bannerUrl);
75
+ const url = animated ? animatedAvatarUrl(props.bannerUrl) : props.bannerUrl;
76
+ return { backgroundImage: `url(${url})` };
77
+ }
78
+ if (typeof props.accentColor === 'number') {
79
+ return {
80
+ backgroundColor: `#${props.accentColor.toString(16).padStart(6, '0')}`,
81
+ };
82
+ }
83
+ return { backgroundColor: 'var(--accent)' };
84
+ });
85
+
86
+ const showNicknameLine = computed(
87
+ () =>
88
+ !!props.nickname &&
89
+ !!props.username &&
90
+ props.nickname !== props.username &&
91
+ props.nickname !== props.name,
92
+ );
93
+ </script>
94
+
95
+ <template>
96
+ <div :class="['user-card', { 'is-loading': loading }]">
97
+ <div :class="['banner', { 'skeleton-block': loading }]" :style="bannerStyle"></div>
98
+
99
+ <div class="avatar-wrap">
100
+ <UserAvatar
101
+ :src="avatarUrl"
102
+ :name="name"
103
+ :size="72"
104
+ :animate="avatarAnimate"
105
+ class="avatar-ring"
106
+ />
107
+ </div>
108
+
109
+ <div class="body">
110
+ <template v-if="loading">
111
+ <div class="skeleton-line skeleton-block skeleton-name"></div>
112
+ <div class="skeleton-line skeleton-block skeleton-tag"></div>
113
+ <div class="skeleton-line skeleton-block skeleton-facts"></div>
114
+ </template>
115
+
116
+ <template v-else>
117
+ <p v-if="error" class="error">{{ error }}</p>
118
+
119
+ <div class="headline">
120
+ <span class="display-name">{{ name }}</span>
121
+ <span v-if="isBot" class="bot-tag">BOT</span>
122
+ </div>
123
+
124
+ <div v-if="showNicknameLine" class="nickname">
125
+ {{ nickname }}
126
+ </div>
127
+
128
+ <div v-if="username" class="tagline">
129
+ <span class="tag">@{{ username }}<span v-if="discriminator">#{{ discriminator }}</span></span>
130
+ </div>
131
+
132
+ <div v-if="$slots.facts" class="facts-slot">
133
+ <slot name="facts" />
134
+ </div>
135
+
136
+ <div v-if="$slots.actions" class="actions">
137
+ <slot name="actions" />
138
+ </div>
139
+
140
+ <div v-if="$slots.default" class="extra">
141
+ <slot />
142
+ </div>
143
+ </template>
144
+ </div>
145
+ </div>
146
+ </template>
147
+
148
+ <style scoped>
149
+ .user-card {
150
+ width: 300px;
151
+ background: var(--bg-surface);
152
+ border: 1px solid var(--border);
153
+ border-radius: var(--radius-lg);
154
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
155
+ overflow: hidden;
156
+ }
157
+ @media (max-width: 768px) {
158
+ .user-card {
159
+ width: 100%;
160
+ border: none;
161
+ border-radius: 0;
162
+ box-shadow: none;
163
+ }
164
+ }
165
+
166
+ .banner {
167
+ height: 102px;
168
+ background-size: cover;
169
+ background-position: center;
170
+ background-color: var(--accent);
171
+ }
172
+
173
+ .avatar-wrap {
174
+ position: relative;
175
+ margin-top: -32px;
176
+ margin-left: 14px;
177
+ width: 72px;
178
+ height: 72px;
179
+ }
180
+ :deep(.avatar-ring) {
181
+ border: 4px solid var(--bg-surface);
182
+ background: var(--bg-surface-2);
183
+ /* The 4px ring is part of the visual; UserAvatar's own background
184
+ is overridden so the ring colour doesn't bleed through on the
185
+ letter-fallback path. */
186
+ }
187
+
188
+ .body {
189
+ padding: 0.2rem 0.9rem 0.9rem;
190
+ display: flex;
191
+ flex-direction: column;
192
+ gap: 0.4rem;
193
+ }
194
+
195
+ .headline {
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 0.4rem;
199
+ flex-wrap: wrap;
200
+ }
201
+ .display-name {
202
+ font-weight: 700;
203
+ font-size: 1.05rem;
204
+ color: var(--text-strong);
205
+ overflow-wrap: anywhere;
206
+ }
207
+ .bot-tag {
208
+ background: var(--accent);
209
+ color: var(--text-on-accent);
210
+ font-size: 0.65rem;
211
+ font-weight: 700;
212
+ padding: 1px 5px;
213
+ border-radius: 3px;
214
+ line-height: 1.2;
215
+ }
216
+ .nickname {
217
+ color: var(--text);
218
+ font-size: 0.9rem;
219
+ margin-top: -0.2rem;
220
+ }
221
+ .tagline {
222
+ color: var(--text-muted);
223
+ font-size: 0.85rem;
224
+ }
225
+ .tag {
226
+ overflow-wrap: anywhere;
227
+ }
228
+
229
+ .facts-slot {
230
+ margin-top: 0.3rem;
231
+ }
232
+ .actions {
233
+ margin-top: 0.5rem;
234
+ display: flex;
235
+ flex-wrap: wrap;
236
+ gap: 0.4rem;
237
+ }
238
+ .extra {
239
+ margin-top: 0.4rem;
240
+ border-top: 1px solid var(--border);
241
+ padding-top: 0.5rem;
242
+ color: var(--text);
243
+ font-size: 0.85rem;
244
+ }
245
+
246
+ .skeleton-block {
247
+ background-color: var(--bg-surface-2);
248
+ background-image: linear-gradient(
249
+ 90deg,
250
+ var(--bg-surface-2) 0%,
251
+ var(--bg-surface-hover, rgba(255, 255, 255, 0.08)) 50%,
252
+ var(--bg-surface-2) 100%
253
+ );
254
+ background-size: 200% 100%;
255
+ animation: skeleton-shimmer 1.2s linear infinite;
256
+ }
257
+ @keyframes skeleton-shimmer {
258
+ 0% { background-position: 100% 0; }
259
+ 100% { background-position: -100% 0; }
260
+ }
261
+ .skeleton-line {
262
+ height: 0.9rem;
263
+ border-radius: var(--radius-sm);
264
+ }
265
+ .skeleton-name { width: 55%; margin-top: 0.3rem; }
266
+ .skeleton-tag { width: 35%; }
267
+ .skeleton-facts { width: 70%; margin-top: 0.4rem; }
268
+
269
+ .error {
270
+ color: var(--danger);
271
+ margin: 0.3rem 0;
272
+ font-size: 0.85rem;
273
+ }
274
+ </style>
@@ -0,0 +1,167 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * UserItem — compact user row (avatar + primary + secondary text +
4
+ * optional trailing content).
5
+ *
6
+ * Pure presentation: caller passes the data and handles `@click`
7
+ * themselves. Avatar defaults to hover-animation (Discord DM-list
8
+ * convention); pass `avatar-animate="always"` or `"never"` to override.
9
+ *
10
+ * Use the `#trailing` slot for timestamps, unread pills, action
11
+ * buttons, or anything that should sit on the right edge of the row.
12
+ * `#sub` overrides the default subtitle slot rendering when you want
13
+ * richer secondary content (chips, status icons, …).
14
+ */
15
+ import { computed } from 'vue';
16
+ import UserAvatar from './UserAvatar.vue';
17
+
18
+ const props = withDefaults(defineProps<{
19
+ userId?: string;
20
+ avatarUrl?: string | null;
21
+ name: string;
22
+ /** Optional secondary line (username, status, last message preview, …). */
23
+ subtitle?: string | null;
24
+ /** Renders a 'BOT' tag next to the name. */
25
+ isBot?: boolean;
26
+ /** Avatar pixel size. Default 40. */
27
+ avatarSize?: number;
28
+ /** When to animate the avatar (default `hover`). */
29
+ avatarAnimate?: 'never' | 'hover' | 'always';
30
+ /** Whether the row is currently selected (e.g. active conversation). */
31
+ active?: boolean;
32
+ /** Disables interactions + dims the row. */
33
+ disabled?: boolean;
34
+ /** Tag elements as `<button>` for keyboard navigation. Default `<div>`. */
35
+ interactive?: boolean;
36
+ }>(), {
37
+ userId: undefined,
38
+ avatarUrl: null,
39
+ subtitle: null,
40
+ isBot: false,
41
+ avatarSize: 40,
42
+ avatarAnimate: 'hover',
43
+ active: false,
44
+ disabled: false,
45
+ interactive: false,
46
+ });
47
+
48
+ defineEmits<{
49
+ (e: 'click'): void;
50
+ }>();
51
+
52
+ defineSlots<{
53
+ sub?: (props: Record<string, never>) => unknown;
54
+ trailing?: (props: Record<string, never>) => unknown;
55
+ }>();
56
+
57
+ const tag = computed(() => (props.interactive ? 'button' : 'div'));
58
+ </script>
59
+
60
+ <template>
61
+ <component
62
+ :is="tag"
63
+ :class="['user-item', { active, disabled, interactive }]"
64
+ :type="interactive ? 'button' : undefined"
65
+ :disabled="interactive && disabled ? true : undefined"
66
+ @click="!disabled && $emit('click')"
67
+ >
68
+ <UserAvatar
69
+ :src="avatarUrl"
70
+ :name="name"
71
+ :size="avatarSize"
72
+ :animate="avatarAnimate"
73
+ />
74
+ <div class="text">
75
+ <div class="row">
76
+ <span class="name">{{ name }}</span>
77
+ <span v-if="isBot" class="bot-tag">BOT</span>
78
+ </div>
79
+ <div v-if="$slots.sub || subtitle" class="sub">
80
+ <slot name="sub">{{ subtitle }}</slot>
81
+ </div>
82
+ </div>
83
+ <div v-if="$slots.trailing" class="trailing">
84
+ <slot name="trailing" />
85
+ </div>
86
+ </component>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .user-item {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 0.6rem;
94
+ padding: 0.45rem 0.7rem;
95
+ border-radius: var(--radius-base);
96
+ background: transparent;
97
+ color: var(--text);
98
+ width: 100%;
99
+ text-align: left;
100
+ /* button-tag defaults reset */
101
+ border: none;
102
+ font: inherit;
103
+ }
104
+ .user-item.interactive {
105
+ cursor: pointer;
106
+ }
107
+ .user-item.interactive:hover:not(.disabled) {
108
+ background: var(--bg-surface-hover);
109
+ }
110
+ .user-item.active {
111
+ background: var(--bg-surface-active);
112
+ }
113
+ .user-item.disabled {
114
+ opacity: 0.55;
115
+ cursor: not-allowed;
116
+ }
117
+
118
+ .text {
119
+ flex: 1;
120
+ min-width: 0;
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 0.05rem;
124
+ }
125
+ .row {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 0.35rem;
129
+ min-width: 0;
130
+ }
131
+ .name {
132
+ font-weight: 500;
133
+ font-size: 0.92rem;
134
+ color: var(--text);
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ white-space: nowrap;
138
+ min-width: 0;
139
+ }
140
+ .bot-tag {
141
+ background: var(--accent);
142
+ color: var(--text-on-accent);
143
+ font-size: 0.6rem;
144
+ font-weight: 700;
145
+ padding: 1px 5px;
146
+ border-radius: 3px;
147
+ line-height: 1.2;
148
+ flex-shrink: 0;
149
+ }
150
+ .sub {
151
+ color: var(--text-muted);
152
+ font-size: 0.78rem;
153
+ overflow: hidden;
154
+ text-overflow: ellipsis;
155
+ white-space: nowrap;
156
+ min-width: 0;
157
+ }
158
+
159
+ .trailing {
160
+ flex-shrink: 0;
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 0.4rem;
164
+ color: var(--text-muted);
165
+ font-size: 0.78rem;
166
+ }
167
+ </style>
@@ -0,0 +1,9 @@
1
+ /** Single source of truth for the `TabDef` shape consumed by both
2
+ * `AppTabs` (button-only) and `AppTabsRouted` (vue-router-aware). */
3
+ export interface TabDef {
4
+ key: string;
5
+ label: string;
6
+ /** Optional iconify icon name shown next to the label. */
7
+ icon?: string;
8
+ disabled?: boolean;
9
+ }
@@ -0,0 +1,65 @@
1
+ import { computed, inject, onBeforeUnmount, provide, ref, type InjectionKey, type Ref } from 'vue';
2
+
3
+ export type OverlayView = 'nav' | 'extras';
4
+
5
+ export interface AppShellContext {
6
+ overlayOpen: Ref<boolean>;
7
+ openOverlay(): void;
8
+ closeOverlay(): void;
9
+ flushMain: Ref<boolean>;
10
+ setFlushMain(value: boolean): void;
11
+ hasExtras: Ref<boolean>;
12
+ overlayView: Ref<OverlayView>;
13
+ toggleOverlayView(): void;
14
+ registerExtras(): void;
15
+ unregisterExtras(): void;
16
+ }
17
+
18
+ const key: InjectionKey<AppShellContext> = Symbol('app-shell');
19
+
20
+ export function provideAppShell(): AppShellContext {
21
+ const overlayOpen = ref(false);
22
+ const flushMain = ref(false);
23
+ const extrasCount = ref(0);
24
+ const hasExtras = computed(() => extrasCount.value > 0);
25
+ const overlayView = ref<OverlayView>('nav');
26
+
27
+ const ctx: AppShellContext = {
28
+ overlayOpen,
29
+ openOverlay: () => {
30
+ overlayOpen.value = true;
31
+ overlayView.value = hasExtras.value ? 'extras' : 'nav';
32
+ },
33
+ closeOverlay: () => { overlayOpen.value = false; },
34
+ flushMain,
35
+ setFlushMain: (v: boolean) => { flushMain.value = v; },
36
+ hasExtras,
37
+ overlayView,
38
+ toggleOverlayView: () => {
39
+ overlayView.value = overlayView.value === 'nav' ? 'extras' : 'nav';
40
+ },
41
+ registerExtras: () => { extrasCount.value++; },
42
+ unregisterExtras: () => { extrasCount.value = Math.max(0, extrasCount.value - 1); }
43
+ };
44
+
45
+ provide(key, ctx);
46
+ return ctx;
47
+ }
48
+
49
+ export function useAppShell(): AppShellContext {
50
+ const ctx = inject(key);
51
+ if (!ctx) throw new Error('useAppShell must be called within App shell');
52
+ return ctx;
53
+ }
54
+
55
+ export function useFlushMain(): void {
56
+ const { setFlushMain } = useAppShell();
57
+ setFlushMain(true);
58
+ onBeforeUnmount(() => setFlushMain(false));
59
+ }
60
+
61
+ export function useOverlayExtras(): void {
62
+ const { registerExtras, unregisterExtras } = useAppShell();
63
+ registerExtras();
64
+ onBeforeUnmount(unregisterExtras);
65
+ }
@@ -0,0 +1,14 @@
1
+ import { onBeforeUnmount, ref } from 'vue';
2
+
3
+ const MOBILE_QUERY = '(max-width: 768px)';
4
+
5
+ export function useBreakpoint() {
6
+ const mq = window.matchMedia(MOBILE_QUERY);
7
+ const isMobile = ref(mq.matches);
8
+ const update = (e: MediaQueryListEvent) => {
9
+ isMobile.value = e.matches;
10
+ };
11
+ mq.addEventListener('change', update);
12
+ onBeforeUnmount(() => mq.removeEventListener('change', update));
13
+ return { isMobile };
14
+ }
@@ -0,0 +1,67 @@
1
+ import { nanoid } from 'nanoid'
2
+
3
+ interface StackEntry {
4
+ id: string
5
+ shouldIgnore: () => boolean
6
+ isInside: (target: Node) => boolean
7
+ close: () => void
8
+ }
9
+
10
+ const stack: StackEntry[] = []
11
+ let listenerInstalled = false
12
+
13
+ function ensureListener() {
14
+ if (listenerInstalled || typeof document === 'undefined') return
15
+ listenerInstalled = true
16
+
17
+ document.addEventListener(
18
+ 'click',
19
+ (e) => {
20
+ const target = e.target as Node | null
21
+ if (!target || !document.body.contains(target)) return
22
+
23
+ // 由頂端往下走:
24
+ // - shouldIgnore:跳過此層繼續檢查下一層(例如同一個 click 剛觸發了 show)
25
+ // - isInside:停止。點擊落在這一層,該層與其下所有層都保留
26
+ // - 其餘:關閉此層,繼續檢查下一層,達成巢狀同時關閉
27
+ for (let i = stack.length - 1; i >= 0; i--) {
28
+ const entry = stack[i]
29
+ if (entry.shouldIgnore()) continue
30
+ if (entry.isInside(target)) break
31
+ entry.close()
32
+ }
33
+ },
34
+ true,
35
+ )
36
+ }
37
+
38
+ /**
39
+ * 統一的 overlay click-outside 管理
40
+ *
41
+ * 所有 usePopover 共用同一個 stack,點擊外部時依開啟順序(top-down)檢查:
42
+ * - 點擊落在某一層上 → 該層與底下保留
43
+ * - 點擊落在某一層外 → 關閉該層並繼續往下
44
+ *
45
+ * 這讓巢狀 popover(例如 Popover 內含 Select)在點擊真正外部時能同時關閉多層。
46
+ */
47
+ export const useClickOutsideStack = () => {
48
+ const id = nanoid()
49
+
50
+ const register = (handlers: Omit<StackEntry, 'id'>) => {
51
+ // 先移除舊的(若存在),讓重新 register 時能移到 stack 頂端
52
+ const idx = stack.findIndex(e => e.id === id)
53
+ if (idx !== -1) stack.splice(idx, 1)
54
+ stack.push({ id, ...handlers })
55
+ ensureListener()
56
+ }
57
+
58
+ const unregister = () => {
59
+ const idx = stack.findIndex(e => e.id === id)
60
+ if (idx !== -1) stack.splice(idx, 1)
61
+ }
62
+
63
+ return {
64
+ register,
65
+ unregister,
66
+ }
67
+ }
@@ -0,0 +1,75 @@
1
+ import { ref, watch } from 'vue';
2
+
3
+ /**
4
+ * Global colour-scheme controller.
5
+ *
6
+ * Tri-state, matching what most apps expose to users:
7
+ * - `light` — force light tokens, regardless of system.
8
+ * - `dark` — force dark tokens, regardless of system.
9
+ * - `system` — follow `prefers-color-scheme` (the default).
10
+ *
11
+ * Wired up via a `data-theme` attribute on `<html>`. The token sheet
12
+ * (`tokens.css`) reads that attribute alongside the `prefers-color-scheme`
13
+ * media query, so:
14
+ * - no attribute → media query decides
15
+ * - `light` → light tokens win, even under system dark
16
+ * - `dark` → dark tokens win, even under system light
17
+ *
18
+ * Persisted to localStorage so the choice survives reloads. Storage is a
19
+ * best-effort side channel — failures (privacy mode, quota, SSR) are
20
+ * swallowed and the in-memory state still works for the current session.
21
+ *
22
+ * Singleton: the ref is module-scoped, so every consumer sees the same
23
+ * value and a write from one component fans out to all of them.
24
+ */
25
+
26
+ export type ColorScheme = 'light' | 'dark' | 'system';
27
+
28
+ const STORAGE_KEY = 'karyl:color-scheme';
29
+
30
+ function readStored(): ColorScheme {
31
+ if (typeof window === 'undefined') return 'system';
32
+ try {
33
+ const v = window.localStorage.getItem(STORAGE_KEY);
34
+ if (v === 'light' || v === 'dark' || v === 'system') return v;
35
+ } catch {
36
+ // localStorage may be unavailable (privacy mode, SSR-ish env).
37
+ }
38
+ return 'system';
39
+ }
40
+
41
+ function apply(scheme: ColorScheme): void {
42
+ if (typeof document === 'undefined') return;
43
+ const root = document.documentElement;
44
+ if (scheme === 'system') root.removeAttribute('data-theme');
45
+ else root.setAttribute('data-theme', scheme);
46
+ }
47
+
48
+ const current = ref<ColorScheme>(readStored());
49
+
50
+ let initialized = false;
51
+ function init(): void {
52
+ if (initialized) return;
53
+ initialized = true;
54
+ apply(current.value);
55
+ watch(current, (v) => {
56
+ apply(v);
57
+ if (typeof window === 'undefined') return;
58
+ try {
59
+ if (v === 'system') window.localStorage.removeItem(STORAGE_KEY);
60
+ else window.localStorage.setItem(STORAGE_KEY, v);
61
+ } catch {
62
+ // best-effort; in-memory state still wins for this session.
63
+ }
64
+ });
65
+ }
66
+
67
+ export function useColorScheme() {
68
+ init();
69
+ return {
70
+ colorScheme: current,
71
+ setColorScheme(v: ColorScheme): void {
72
+ current.value = v;
73
+ },
74
+ };
75
+ }