rei-kit 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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/dist/app-error-DF9cijE0.js +63 -0
  4. package/dist/app-error-DF9cijE0.js.map +1 -0
  5. package/dist/components/BaseButton.vue.d.ts +20 -0
  6. package/dist/components/BaseInput.vue.d.ts +23 -0
  7. package/dist/components/BaseSheet.vue.d.ts +32 -0
  8. package/dist/components/EmptyState.vue.d.ts +19 -0
  9. package/dist/components/LocaleLinks.vue.d.ts +33 -0
  10. package/dist/components/PageHeader.vue.d.ts +20 -0
  11. package/dist/components/SectionHeading.vue.d.ts +26 -0
  12. package/dist/components/SegmentedControl.vue.d.ts +27 -0
  13. package/dist/components/SettingsGroup.vue.d.ts +16 -0
  14. package/dist/components/SettingsRow.vue.d.ts +35 -0
  15. package/dist/components/SkeletonList.vue.d.ts +8 -0
  16. package/dist/components/StatCard.vue.d.ts +8 -0
  17. package/dist/components/ToneDot.vue.d.ts +16 -0
  18. package/dist/composables/use-debounced-callback.d.ts +23 -0
  19. package/dist/composables/use-drag-scroll.d.ts +26 -0
  20. package/dist/composables/use-online.d.ts +18 -0
  21. package/dist/composables/use-theme.d.ts +23 -0
  22. package/dist/composables/use-today.d.ts +10 -0
  23. package/dist/composables/use-visual-viewport.d.ts +29 -0
  24. package/dist/i18n/runtime.d.ts +53 -0
  25. package/dist/index.d.ts +47 -0
  26. package/dist/index.js +1349 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/styles.css +27 -0
  29. package/dist/supabase/index.d.ts +28 -0
  30. package/dist/supabase.js +100 -0
  31. package/dist/supabase.js.map +1 -0
  32. package/dist/tokens.css +139 -0
  33. package/dist/utils/app-error.d.ts +58 -0
  34. package/dist/utils/date.d.ts +122 -0
  35. package/dist/utils/day-label.d.ts +27 -0
  36. package/dist/utils/download.d.ts +7 -0
  37. package/dist/utils/format.d.ts +24 -0
  38. package/dist/utils/haptics.d.ts +9 -0
  39. package/dist/utils/platform.d.ts +23 -0
  40. package/dist/utils/redirect.d.ts +41 -0
  41. package/package.json +98 -0
package/dist/index.js ADDED
@@ -0,0 +1,1349 @@
1
+ import { n as registerErrorMapper, r as toAppError, t as AppError } from "./app-error-DF9cijE0.js";
2
+ import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, mergeModels, mergeProps, nextTick, normalizeClass, normalizeStyle, onMounted, onScopeDispose, onUnmounted, openBlock, readonly, ref, renderList, renderSlot, resolveDynamicComponent, toDisplayString, unref, useId, useModel, vModelDynamic, vModelRadio, watch, watchEffect, withCtx, withDirectives } from "vue";
3
+ import { ArrowDown, ArrowRight, ArrowUp, ChevronRight, X } from "lucide-vue-next";
4
+ import { createI18n } from "vue-i18n";
5
+ //#region src/utils/date.ts
6
+ /**
7
+ * Local calendar-day helpers.
8
+ *
9
+ * Every function is pure and works on `YYYY-MM-DD` keys, the same shape as the
10
+ * `date` columns in Postgres. Nothing here calls `toISOString`: that converts to
11
+ * UTC, so in a UTC+9 timezone every entry made between midnight and 09:00 would
12
+ * be written to the previous day.
13
+ */
14
+ /**
15
+ * Formats a `Date` as a local `YYYY-MM-DD` key.
16
+ *
17
+ * @param date - Any `Date`; only its local year, month and day are read.
18
+ * @returns The calendar day in the runtime's own timezone.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * // 2026-08-23 01:30 in Tokyo
23
+ * toDateKey(new Date()) // '2026-08-23'
24
+ * new Date().toISOString() // '2026-08-22T16:30…' ← the bug
25
+ * ```
26
+ */
27
+ function toDateKey(date) {
28
+ return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
29
+ }
30
+ /** Today's key in the user's own timezone. */
31
+ function todayKey() {
32
+ return toDateKey(/* @__PURE__ */ new Date());
33
+ }
34
+ /**
35
+ * Parses a `YYYY-MM-DD` key into a `Date` at local midnight.
36
+ *
37
+ * @param key - A key produced by {@link toDateKey}.
38
+ * @returns Local midnight of that calendar day.
39
+ * @throws If the key is not three numeric parts.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * fromDateKey('2026-08-23') // local midnight, correct
44
+ * new Date('2026-08-23') // UTC midnight — shifts a day in some zones
45
+ * ```
46
+ */
47
+ function fromDateKey(key) {
48
+ const [year, month, day] = key.split("-").map(Number);
49
+ if (year === void 0 || month === void 0 || day === void 0) throw new Error(`Invalid date key: ${key}`);
50
+ return new Date(year, month - 1, day);
51
+ }
52
+ /**
53
+ * Shifts a date key by whole calendar days.
54
+ *
55
+ * Uses `setDate`, which is calendar-aware: it rolls over month and year ends,
56
+ * and stays correct across daylight-saving transitions. Adding
57
+ * `days * 86_400_000` milliseconds would not — a DST day is 23 or 25 hours long.
58
+ *
59
+ * @param key - Starting `YYYY-MM-DD` key.
60
+ * @param days - Days to add; negative goes back.
61
+ * @returns The resulting key.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * addDays('2026-01-31', 1) // '2026-02-01'
66
+ * addDays('2026-01-01', -1) // '2025-12-31'
67
+ * addDays('2028-02-28', 1) // '2028-02-29' — leap year
68
+ * ```
69
+ */
70
+ function addDays(key, days) {
71
+ const date = fromDateKey(key);
72
+ date.setDate(date.getDate() + days);
73
+ return toDateKey(date);
74
+ }
75
+ /**
76
+ * The last `count` days ending today, oldest first.
77
+ *
78
+ * `today` is a parameter so the function stays pure and testable; call sites
79
+ * normally omit it.
80
+ *
81
+ * @param count - How many days to return, including `today`.
82
+ * @param today - End of the range. Defaults to the real today.
83
+ * @returns Keys in ascending order.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * lastNDays(3, '2026-08-23') // ['2026-08-21', '2026-08-22', '2026-08-23']
88
+ * ```
89
+ */
90
+ function lastNDays(count, today = todayKey()) {
91
+ const keys = [];
92
+ for (let offset = count - 1; offset >= 0; offset -= 1) keys.push(addDays(today, -offset));
93
+ return keys;
94
+ }
95
+ /**
96
+ * The first day of the week containing `key`.
97
+ *
98
+ * The user's preference is a parameter, not a module-level setting: changing it
99
+ * in Profile has to re-render the week grid and the year heatmap immediately,
100
+ * and a global would make that a hidden dependency.
101
+ *
102
+ * @param key - Any day in the week.
103
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
104
+ * @returns Key of that week's first day.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * // 2026-08-23 is a Sunday
109
+ * startOfWeek('2026-08-23', 1) // '2026-08-17' — previous Monday
110
+ * startOfWeek('2026-08-23', 0) // '2026-08-23' — already Sunday
111
+ * ```
112
+ */
113
+ function startOfWeek(key, weekStartsOn) {
114
+ return addDays(key, -((fromDateKey(key).getDay() - weekStartsOn + 7) % 7));
115
+ }
116
+ /**
117
+ * Every day of a calendar year, in order.
118
+ *
119
+ * Leap years fall out of the loop for free: it walks day by day until the year
120
+ * rolls over, so February 29 is included when it exists.
121
+ *
122
+ * @param year - Four-digit year.
123
+ * @returns 365 or 366 keys, oldest first.
124
+ */
125
+ function eachDayOfYear(year) {
126
+ const keys = [];
127
+ const date = new Date(year, 0, 1);
128
+ while (date.getFullYear() === year) {
129
+ keys.push(toDateKey(date));
130
+ date.setDate(date.getDate() + 1);
131
+ }
132
+ return keys;
133
+ }
134
+ /**
135
+ * Empty cells before a block's first day in a seven-row column grid.
136
+ *
137
+ * The grid fills column by column, so the first column is only partly used
138
+ * unless the block starts exactly on the week's first day. An off-by-one here
139
+ * shifts the whole block by a row, so this is unit tested.
140
+ *
141
+ * @param firstDayKey - First day of the block, e.g. `'2026-02-01'`.
142
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
143
+ * @returns 0-6 blank cells.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * leadingBlanks('2026-01-01', 1) // 3 — a Thursday, Mon-Wed are blank
148
+ * leadingBlanks('2024-01-01', 1) // 0 — a Monday
149
+ * ```
150
+ */
151
+ function leadingBlanks(firstDayKey, weekStartsOn) {
152
+ return (fromDateKey(firstDayKey).getDay() - weekStartsOn + 7) % 7;
153
+ }
154
+ //#endregion
155
+ //#region src/utils/format.ts
156
+ /**
157
+ * The locale `Intl` formatting uses.
158
+ *
159
+ * Held here rather than imported from an i18n runtime so the utilities have no
160
+ * i18n dependency at all: an app that never installs vue-i18n still gets dates
161
+ * in the right language. `createI18nRuntime` sets this when it is used.
162
+ */
163
+ var locale = ref(typeof navigator === "undefined" ? "en" : navigator.language ?? "en");
164
+ /**
165
+ * Points every formatter at a new locale.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * setFormatLocale('tr-TR')
170
+ * ```
171
+ */
172
+ function setFormatLocale(next) {
173
+ locale.value = next;
174
+ }
175
+ /**
176
+ * `Intl.DateTimeFormat` is expensive to construct, so instances are cached per
177
+ * locale and option set. The key includes the locale, which is what lets the
178
+ * cache survive a language change instead of returning stale formatters.
179
+ */
180
+ var cache = /* @__PURE__ */ new Map();
181
+ /**
182
+ * Formats a date in the active locale.
183
+ *
184
+ * Reading the locale ref here is deliberate: called from a `computed`, the
185
+ * result re-evaluates when the language changes.
186
+ *
187
+ * @param date - Date to format.
188
+ * @param options - Passed straight to `Intl.DateTimeFormat`.
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * formatDate(new Date(), { weekday: 'narrow' }) // 'T'
193
+ * ```
194
+ */
195
+ function formatDate(date, options) {
196
+ const tag = locale.value;
197
+ const key = `${tag}:${JSON.stringify(options)}`;
198
+ let formatter = cache.get(key);
199
+ if (!formatter) {
200
+ formatter = new Intl.DateTimeFormat(tag, options);
201
+ cache.set(key, formatter);
202
+ }
203
+ return formatter.format(date);
204
+ }
205
+ //#endregion
206
+ //#region src/utils/day-label.ts
207
+ /**
208
+ * A short name for a day, relative to today.
209
+ *
210
+ * "Today" and "Yesterday" are worth spelling out — they are the two a user
211
+ * actually reaches for. Anything older gets its weekday, which inside a
212
+ * five-day window is unambiguous and stays two or three characters in every
213
+ * language.
214
+ *
215
+ * The two words are arguments rather than translated here: a library that calls
216
+ * `t()` forces every consumer onto one i18n setup.
217
+ *
218
+ * @param dateKey - The day to label (`YYYY-MM-DD`).
219
+ * @param today - Today's key, passed in so the caller controls the clock.
220
+ * @param labels - What to call today and yesterday.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * relativeDayLabel('2026-08-28', '2026-08-31', { today: 'Today', yesterday: 'Yesterday' })
225
+ * // 'Fri'
226
+ * ```
227
+ */
228
+ function relativeDayLabel(dateKey, today, labels) {
229
+ if (dateKey === today) return labels.today;
230
+ if (dateKey === addDays(today, -1)) return labels.yesterday;
231
+ return formatDate(fromDateKey(dateKey), { weekday: "short" });
232
+ }
233
+ //#endregion
234
+ //#region src/utils/download.ts
235
+ /**
236
+ * Hands the user a file without a server round trip.
237
+ *
238
+ * @param data - Anything `JSON.stringify` can serialise.
239
+ * @param filename - Suggested name, e.g. `hibi-export-2026-08-24.json`.
240
+ */
241
+ function downloadJson(data, filename) {
242
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
243
+ const url = URL.createObjectURL(blob);
244
+ const link = document.createElement("a");
245
+ link.href = url;
246
+ link.download = filename;
247
+ link.click();
248
+ URL.revokeObjectURL(url);
249
+ }
250
+ //#endregion
251
+ //#region src/utils/redirect.ts
252
+ /**
253
+ * Resolves a `?redirect=` query value into a safe in-app path.
254
+ *
255
+ * Only same-origin paths are accepted. Anything else falls back to `/`,
256
+ * so a crafted link cannot bounce a user from the real login page to a
257
+ * phishing clone.
258
+ *
259
+ * Pure: takes the query value instead of reading the router, so it also
260
+ * works inside navigation guards and can be unit tested.
261
+ *
262
+ * @param target - Raw `route.query.redirect` value. May be a string, an
263
+ * array (repeated query key), `null`, or `undefined`.
264
+ * @returns A path starting with a single `/`. Defaults to `/`.
265
+ *
266
+ * @example
267
+ * ```ts
268
+ * // in a view
269
+ * await router.push(safeRedirect(route.query.redirect))
270
+ *
271
+ * // in a guard
272
+ * return safeRedirect(to.query.redirect)
273
+ * ```
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * safeRedirect('/week') // '/week'
278
+ * safeRedirect('https://evil.com') // '/'
279
+ * safeRedirect('//evil.com') // '/' (protocol-relative URL)
280
+ * safeRedirect(['/a', '/b']) // '/'
281
+ * safeRedirect(undefined) // '/'
282
+ * ```
283
+ */
284
+ function safeRedirect(target) {
285
+ if (typeof target === "string" && target.startsWith("/") && !target.startsWith("//")) return target;
286
+ return "/";
287
+ }
288
+ //#endregion
289
+ //#region src/utils/haptics.ts
290
+ /**
291
+ * A short vibration for a confirmed tap.
292
+ *
293
+ * Optional chaining is not decoration: iOS Safari has no `vibrate` at all, and
294
+ * calling it unguarded would throw on every marked day.
295
+ *
296
+ * @param duration - Milliseconds. Keep it under ~15ms; longer reads as an alert.
297
+ */
298
+ function tapFeedback(duration = 10) {
299
+ navigator.vibrate?.(duration);
300
+ }
301
+ //#endregion
302
+ //#region src/utils/platform.ts
303
+ /**
304
+ * Whether the app is running from the Home Screen rather than a browser tab.
305
+ *
306
+ * Two checks because iOS predates the standard one: `display-mode: standalone`
307
+ * is the modern signal, `navigator.standalone` is Safari's own.
308
+ */
309
+ function isInstalled() {
310
+ if (typeof window === "undefined") return false;
311
+ return window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
312
+ }
313
+ /** iPhone and iPad, including iPadOS reporting itself as a Mac. */
314
+ function isApplePortable() {
315
+ if (typeof window === "undefined") return false;
316
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
317
+ }
318
+ /**
319
+ * Whether this device can only receive notifications once the app is installed.
320
+ *
321
+ * Safari on iOS grants notification permission to an installed web app and to
322
+ * nothing else — in a normal tab the request does not even prompt. Telling the
323
+ * user to allow notifications there is asking for something the browser will
324
+ * not offer, so the UI has to say "add to Home Screen" instead.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * if (needsIosInstall()) // show the Home Screen instruction, not the button
329
+ * ```
330
+ */
331
+ function needsIosInstall() {
332
+ return isApplePortable() && !isInstalled();
333
+ }
334
+ //#endregion
335
+ //#region src/composables/use-theme.ts
336
+ /**
337
+ * Namespaced by the app, not by this package.
338
+ *
339
+ * Two rei-kit apps served from the same origin would otherwise share one theme
340
+ * setting — and during development on localhost, they will be.
341
+ */
342
+ var storageKey = "rei-theme";
343
+ function isThemePreference(value) {
344
+ return value === "system" || value === "light" || value === "dark";
345
+ }
346
+ /** Reads the stored preference, falling back to `system`. */
347
+ function readStoredTheme() {
348
+ try {
349
+ const stored = localStorage.getItem(storageKey);
350
+ return isThemePreference(stored) ? stored : "system";
351
+ } catch {
352
+ return "system";
353
+ }
354
+ }
355
+ function storeTheme(preference) {
356
+ try {
357
+ localStorage.setItem(storageKey, preference);
358
+ } catch {}
359
+ }
360
+ /** Adds or removes `.dark` on `<html>`, resolving `system` against the OS. */
361
+ function applyTheme(preference) {
362
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
363
+ const isDark = preference === "dark" || preference === "system" && prefersDark;
364
+ document.documentElement.classList.toggle("dark", isDark);
365
+ }
366
+ /**
367
+ * The shared preference, created on first use rather than at import.
368
+ *
369
+ * Lazy on purpose: reading storage at import time would lock in the default key
370
+ * before an app had a chance to set its own, leaving the controller reading one
371
+ * key and writing another.
372
+ */
373
+ var preference = null;
374
+ function controller() {
375
+ if (preference) return preference;
376
+ preference = ref(readStoredTheme());
377
+ watch(preference, (next) => {
378
+ storeTheme(next);
379
+ applyTheme(next);
380
+ }, { immediate: true });
381
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
382
+ if (preference?.value === "system") applyTheme("system");
383
+ });
384
+ return preference;
385
+ }
386
+ /**
387
+ * Sets where the preference is stored.
388
+ *
389
+ * Safe in either order: called before the first `useTheme()` it simply changes
390
+ * the key, and called after it re-reads under the new one, so the controller
391
+ * never reads from one key while writing to another.
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * setThemeStorageKey('hibi-theme') // once, at startup
396
+ * ```
397
+ */
398
+ function setThemeStorageKey(key) {
399
+ storageKey = key;
400
+ if (preference) preference.value = readStoredTheme();
401
+ }
402
+ /** @returns The shared preference ref; assigning to it stores and applies it. */
403
+ function useTheme() {
404
+ return controller();
405
+ }
406
+ //#endregion
407
+ //#region src/composables/use-today.ts
408
+ /**
409
+ * Today's date key, kept current while the app stays open.
410
+ *
411
+ * `todayKey()` called once in `setup` freezes the date for the lifetime of the
412
+ * component. Nobody notices in a session that lasts minutes, but a phone left
413
+ * on the Today screen overnight would keep marking yesterday, and the Week grid
414
+ * would disable the column that just became today.
415
+ */
416
+ var current = ref(todayKey());
417
+ var timer;
418
+ /** A second past midnight, so a fast timer cannot fire on the old date. */
419
+ function msUntilMidnight() {
420
+ const now = /* @__PURE__ */ new Date();
421
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1).getTime() - now.getTime();
422
+ }
423
+ function refresh() {
424
+ current.value = todayKey();
425
+ }
426
+ function schedule() {
427
+ clearTimeout(timer);
428
+ timer = setTimeout(() => {
429
+ refresh();
430
+ schedule();
431
+ }, msUntilMidnight());
432
+ }
433
+ schedule();
434
+ document.addEventListener("visibilitychange", () => {
435
+ if (document.visibilityState !== "visible") return;
436
+ refresh();
437
+ schedule();
438
+ });
439
+ /**
440
+ * @returns Read-only ref holding today's `YYYY-MM-DD` key.
441
+ *
442
+ * @example
443
+ * ```ts
444
+ * const today = useToday()
445
+ * const isFuture = computed(() => day > today.value)
446
+ * ```
447
+ */
448
+ function useToday() {
449
+ return readonly(current);
450
+ }
451
+ //#endregion
452
+ //#region src/composables/use-online.ts
453
+ /**
454
+ * Tracks whether the browser thinks it has a network connection.
455
+ *
456
+ * Note the limit: `navigator.onLine` only reports whether a network interface
457
+ * is up, not whether requests actually succeed. Treat it as a hint for the UI,
458
+ * never as a reason to skip error handling.
459
+ *
460
+ * Listeners are removed on unmount, so the composable is safe to call per view.
461
+ *
462
+ * @returns A readonly ref that flips with the browser's online/offline events.
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * const isOnline = useOnline()
467
+ * // <p v-if="!isOnline">You're offline.</p>
468
+ * ```
469
+ */
470
+ function useOnline() {
471
+ const isOnline = ref(true);
472
+ function update() {
473
+ isOnline.value = navigator.onLine;
474
+ }
475
+ onMounted(() => {
476
+ update();
477
+ window.addEventListener("online", update);
478
+ window.addEventListener("offline", update);
479
+ });
480
+ onUnmounted(() => {
481
+ window.removeEventListener("online", update);
482
+ window.removeEventListener("offline", update);
483
+ });
484
+ return readonly(isOnline);
485
+ }
486
+ //#endregion
487
+ //#region src/composables/use-debounced-callback.ts
488
+ /**
489
+ * Delays a callback until the caller stops calling it.
490
+ *
491
+ * Used for note autosave: a request per keystroke would be wasteful, but losing
492
+ * the last keystrokes when the user navigates away would be worse — so the
493
+ * pending call is flushed on dispose, and `flush` is exposed for route guards.
494
+ *
495
+ * @param callback - Runs with the arguments of the most recent call.
496
+ * @param delay - Quiet period in milliseconds.
497
+ * @returns `run` to schedule, `flush` to run now, `cancel` to drop.
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * const save = useDebouncedCallback((body: string) => mutate(body), 800)
502
+ * watch(text, (value) => save.run(value))
503
+ * onBeforeRouteLeave(() => save.flush())
504
+ * ```
505
+ */
506
+ function useDebouncedCallback(callback, delay = 800) {
507
+ let timer = null;
508
+ let pending = null;
509
+ /** Runs the pending call right now, if there is one. */
510
+ function flush() {
511
+ if (timer !== null) clearTimeout(timer);
512
+ timer = null;
513
+ if (pending !== null) {
514
+ const args = pending;
515
+ pending = null;
516
+ callback(...args);
517
+ }
518
+ }
519
+ /** Drops the pending call without running it. */
520
+ function cancel() {
521
+ if (timer !== null) clearTimeout(timer);
522
+ timer = null;
523
+ pending = null;
524
+ }
525
+ function run(...args) {
526
+ pending = args;
527
+ if (timer !== null) clearTimeout(timer);
528
+ timer = setTimeout(flush, delay);
529
+ }
530
+ onScopeDispose(flush);
531
+ return {
532
+ run,
533
+ flush,
534
+ cancel
535
+ };
536
+ }
537
+ //#endregion
538
+ //#region src/composables/use-drag-scroll.ts
539
+ /** Movement before a press counts as a drag rather than a tap. */
540
+ var DRAG_THRESHOLD_PX = 6;
541
+ /**
542
+ * Drag-to-scroll for a horizontally scrolling element.
543
+ *
544
+ * The app puts `touch-action: pan-y` on the page content so the tab-swipe
545
+ * gesture keeps its pointer events — the browser never claims a horizontal
546
+ * drag, which also means it never pans this element natively. Rather than give
547
+ * that up, horizontal scrolling is driven here.
548
+ *
549
+ * @param target - The scroll container.
550
+ * @returns `didDrag`, so a click handler can ignore the press that ended a drag.
551
+ *
552
+ * @example
553
+ * ```ts
554
+ * const scroller = ref<HTMLElement | null>(null)
555
+ * const { didDrag } = useDragScroll(scroller)
556
+ *
557
+ * function onClick() {
558
+ * if (didDrag()) return
559
+ * // …treat as a tap
560
+ * }
561
+ * ```
562
+ */
563
+ function useDragScroll(target) {
564
+ let pointerId = null;
565
+ let startX = 0;
566
+ let startScroll = 0;
567
+ let dragged = false;
568
+ function onPointerDown(event) {
569
+ const element = target.value;
570
+ if (!element || event.pointerType === "mouse") return;
571
+ pointerId = event.pointerId;
572
+ startX = event.clientX;
573
+ startScroll = element.scrollLeft;
574
+ dragged = false;
575
+ }
576
+ function onPointerMove(event) {
577
+ const element = target.value;
578
+ if (!element || event.pointerId !== pointerId) return;
579
+ const dx = event.clientX - startX;
580
+ if (!dragged && Math.abs(dx) < DRAG_THRESHOLD_PX) return;
581
+ if (!dragged) {
582
+ dragged = true;
583
+ element.setPointerCapture(event.pointerId);
584
+ }
585
+ element.scrollLeft = startScroll - dx;
586
+ }
587
+ function onPointerUp(event) {
588
+ const element = target.value;
589
+ if (element?.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId);
590
+ pointerId = null;
591
+ }
592
+ function bind(element) {
593
+ element.addEventListener("pointerdown", onPointerDown);
594
+ element.addEventListener("pointermove", onPointerMove);
595
+ element.addEventListener("pointerup", onPointerUp);
596
+ element.addEventListener("pointercancel", onPointerUp);
597
+ }
598
+ function unbind(element) {
599
+ element.removeEventListener("pointerdown", onPointerDown);
600
+ element.removeEventListener("pointermove", onPointerMove);
601
+ element.removeEventListener("pointerup", onPointerUp);
602
+ element.removeEventListener("pointercancel", onPointerUp);
603
+ }
604
+ watch(target, (element, previous) => {
605
+ if (previous) unbind(previous);
606
+ if (element) bind(element);
607
+ }, { immediate: true });
608
+ onScopeDispose(() => {
609
+ if (target.value) unbind(target.value);
610
+ });
611
+ return { didDrag: () => dragged };
612
+ }
613
+ //#endregion
614
+ //#region src/composables/use-visual-viewport.ts
615
+ /**
616
+ * Tracks the visual viewport.
617
+ *
618
+ * Chrome and Android browsers honour `interactive-widget=resizes-content`, so
619
+ * the layout viewport already shrinks for the keyboard there. Safari on iOS
620
+ * does not implement it: it shrinks only the *visual* viewport, leaving a sheet
621
+ * sized in `dvh` sitting partly underneath the keyboard.
622
+ *
623
+ * `null` means the API is unavailable, which callers should read as "trust the
624
+ * layout viewport" rather than as zero.
625
+ *
626
+ * @example
627
+ * ```ts
628
+ * const viewport = useVisualViewport()
629
+ * // :style="viewport ? { height: `${viewport.height}px` } : undefined"
630
+ * ```
631
+ */
632
+ function useVisualViewport() {
633
+ const rect = ref(null);
634
+ const viewport = window.visualViewport;
635
+ if (!viewport) return readonly(rect);
636
+ function read() {
637
+ if (!viewport) return;
638
+ rect.value = {
639
+ height: viewport.height,
640
+ offsetTop: viewport.offsetTop
641
+ };
642
+ }
643
+ read();
644
+ viewport.addEventListener("resize", read);
645
+ viewport.addEventListener("scroll", read);
646
+ onScopeDispose(() => {
647
+ viewport.removeEventListener("resize", read);
648
+ viewport.removeEventListener("scroll", read);
649
+ });
650
+ return readonly(rect);
651
+ }
652
+ //#endregion
653
+ //#region src/components/BaseButton.vue?vue&type=script&setup=true&lang.ts
654
+ var _hoisted_1$12 = [
655
+ "type",
656
+ "disabled",
657
+ "aria-busy"
658
+ ];
659
+ var _hoisted_2$11 = {
660
+ key: 0,
661
+ class: "size-4 animate-spin rounded-full border-2 border-current border-t-transparent",
662
+ "aria-hidden": "true"
663
+ };
664
+ //#endregion
665
+ //#region src/components/BaseButton.vue
666
+ var BaseButton_default = /* @__PURE__ */ defineComponent({
667
+ __name: "BaseButton",
668
+ props: {
669
+ variant: { default: "primary" },
670
+ size: { default: "md" },
671
+ loading: {
672
+ type: Boolean,
673
+ default: false
674
+ },
675
+ disabled: {
676
+ type: Boolean,
677
+ default: false
678
+ },
679
+ type: { default: "button" }
680
+ },
681
+ setup(__props) {
682
+ const VARIANT_CLASS = {
683
+ primary: "bg-primary text-white hover:bg-primary/90",
684
+ ghost: "bg-transparent text-ink hover:bg-muted",
685
+ danger: "bg-negative text-white hover:bg-negative/90"
686
+ };
687
+ const SIZE_CLASS = {
688
+ sm: "h-9 px-3 text-sm",
689
+ md: "h-11 px-4 text-base"
690
+ };
691
+ return (_ctx, _cache) => {
692
+ return openBlock(), createElementBlock("button", {
693
+ type: __props.type,
694
+ disabled: __props.disabled || __props.loading,
695
+ "aria-busy": __props.loading,
696
+ class: normalizeClass(["rounded-card focus-visible:outline-primary inline-flex items-center justify-center gap-2 font-medium transition-transform duration-100 select-none focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95 disabled:pointer-events-none disabled:opacity-50", [VARIANT_CLASS[__props.variant], SIZE_CLASS[__props.size]]])
697
+ }, [__props.loading ? (openBlock(), createElementBlock("span", _hoisted_2$11)) : createCommentVNode("", true), renderSlot(_ctx.$slots, "default")], 10, _hoisted_1$12);
698
+ };
699
+ }
700
+ });
701
+ //#endregion
702
+ //#region src/components/BaseInput.vue?vue&type=script&setup=true&lang.ts
703
+ var _hoisted_1$11 = { class: "flex flex-col gap-1.5" };
704
+ var _hoisted_2$10 = ["for"];
705
+ var _hoisted_3$6 = [
706
+ "id",
707
+ "type",
708
+ "aria-invalid",
709
+ "aria-describedby"
710
+ ];
711
+ //#endregion
712
+ //#region src/components/BaseInput.vue
713
+ var BaseInput_default = /* @__PURE__ */ defineComponent({
714
+ inheritAttrs: false,
715
+ __name: "BaseInput",
716
+ props: /*@__PURE__*/ mergeModels({
717
+ label: {},
718
+ error: { default: "" },
719
+ hint: { default: "" },
720
+ labelHidden: {
721
+ type: Boolean,
722
+ default: false
723
+ },
724
+ type: { default: "text" }
725
+ }, {
726
+ "modelValue": {},
727
+ "modelModifiers": {}
728
+ }),
729
+ emits: ["update:modelValue"],
730
+ setup(__props) {
731
+ const model = useModel(__props, "modelValue");
732
+ const id = useId();
733
+ const errorId = `${id}-error`;
734
+ const hintId = `${id}-hint`;
735
+ const describedBy = computed(() => {
736
+ if (__props.error) return errorId;
737
+ if (__props.hint) return hintId;
738
+ });
739
+ return (_ctx, _cache) => {
740
+ return openBlock(), createElementBlock("div", _hoisted_1$11, [
741
+ createElementVNode("label", {
742
+ for: unref(id),
743
+ class: normalizeClass(["text-ink text-sm font-medium", __props.labelHidden ? "sr-only" : ""])
744
+ }, toDisplayString(__props.label), 11, _hoisted_2$10),
745
+ withDirectives(createElementVNode("input", mergeProps({
746
+ id: unref(id),
747
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => model.value = $event),
748
+ type: __props.type,
749
+ "aria-invalid": Boolean(__props.error),
750
+ "aria-describedby": describedBy.value
751
+ }, _ctx.$attrs, { class: ["border-hair bg-surface text-ink rounded-card focus-visible:outline-primary h-11 border px-3 focus-visible:outline-2 focus-visible:outline-offset-1", __props.error ? "border-negative" : ""] }), null, 16, _hoisted_3$6), [[vModelDynamic, model.value]]),
752
+ __props.error ? (openBlock(), createElementBlock("p", {
753
+ key: 0,
754
+ id: errorId,
755
+ class: "text-negative text-xs"
756
+ }, toDisplayString(__props.error), 1)) : __props.hint ? (openBlock(), createElementBlock("p", {
757
+ key: 1,
758
+ id: hintId,
759
+ class: "text-ink-soft text-xs"
760
+ }, toDisplayString(__props.hint), 1)) : createCommentVNode("", true)
761
+ ]);
762
+ };
763
+ }
764
+ });
765
+ //#endregion
766
+ //#region src/components/BaseSheet.vue?vue&type=script&setup=true&lang.ts
767
+ var _hoisted_1$10 = { class: "shell-frame md:rounded-shell relative flex max-h-full flex-col justify-end overflow-hidden" };
768
+ var _hoisted_2$9 = ["aria-label"];
769
+ var _hoisted_3$5 = { class: "flex shrink-0 items-start gap-3 px-6 pt-4 pb-5" };
770
+ var _hoisted_4$4 = { class: "min-w-0 flex-1" };
771
+ var _hoisted_5$3 = { class: "text-ink text-xl leading-tight font-semibold" };
772
+ var _hoisted_6$1 = {
773
+ key: 0,
774
+ class: "text-ink-soft mt-1 text-sm leading-snug"
775
+ };
776
+ var _hoisted_7$1 = ["aria-label"];
777
+ var _hoisted_8 = { class: "min-h-0 flex-1 overflow-y-auto px-6 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]" };
778
+ var BaseSheet_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
779
+ __name: "BaseSheet",
780
+ props: /*@__PURE__*/ mergeModels({
781
+ title: {},
782
+ subtitle: { default: "" },
783
+ closeLabel: { default: "Close" }
784
+ }, {
785
+ "modelValue": {
786
+ type: Boolean,
787
+ required: true
788
+ },
789
+ "modelModifiers": {}
790
+ }),
791
+ emits: ["update:modelValue"],
792
+ setup(__props) {
793
+ const open = useModel(__props, "modelValue");
794
+ const viewport = useVisualViewport();
795
+ /**
796
+ * Pins the sheet to the area the keyboard has left visible.
797
+ *
798
+ * Only needed where the layout viewport does not shrink on its own — iOS. On
799
+ * Android the numbers already agree, so this is a no-op there rather than a
800
+ * second, competing adjustment.
801
+ */
802
+ const viewportStyle = computed(() => viewport.value ? {
803
+ height: `${viewport.value.height}px`,
804
+ top: `${viewport.value.offsetTop}px`
805
+ } : void 0);
806
+ const panel = ref(null);
807
+ let lastFocused = null;
808
+ function close() {
809
+ open.value = false;
810
+ }
811
+ function onKeydown(event) {
812
+ if (event.key === "Escape") close();
813
+ }
814
+ watch(open, async (isOpen) => {
815
+ if (isOpen) {
816
+ setBackgroundInert(true);
817
+ lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
818
+ window.addEventListener("keydown", onKeydown);
819
+ await nextTick();
820
+ panel.value?.focus();
821
+ } else {
822
+ window.removeEventListener("keydown", onKeydown);
823
+ lastFocused?.focus();
824
+ lastFocused = null;
825
+ setBackgroundInert(false);
826
+ }
827
+ });
828
+ /**
829
+ * `inert` takes the whole app out of tab order and pointer events while the
830
+ * sheet is open — a real focus trap without keydown bookkeeping.
831
+ *
832
+ * The sheet itself is teleported to `#sheet-root`, a sibling of `#app`, so it
833
+ * stays interactive.
834
+ */
835
+ function setBackgroundInert(isInert) {
836
+ document.getElementById("app")?.toggleAttribute("inert", isInert);
837
+ }
838
+ onUnmounted(() => {
839
+ window.removeEventListener("keydown", onKeydown);
840
+ setBackgroundInert(false);
841
+ });
842
+ return (_ctx, _cache) => {
843
+ return openBlock(), createBlock(Teleport, { to: "#sheet-root" }, [createVNode(Transition, { name: "sheet" }, {
844
+ default: withCtx(() => [open.value ? (openBlock(), createElementBlock("div", {
845
+ key: 0,
846
+ class: "fixed inset-x-0 top-0 bottom-0 z-50 flex items-center justify-center",
847
+ style: normalizeStyle(viewportStyle.value)
848
+ }, [createElementVNode("div", _hoisted_1$10, [createElementVNode("div", {
849
+ class: "bg-ink/45 absolute inset-0 backdrop-blur-[2px]",
850
+ onClick: close
851
+ }), createElementVNode("section", {
852
+ ref_key: "panel",
853
+ ref: panel,
854
+ role: "dialog",
855
+ "aria-modal": "true",
856
+ "aria-label": __props.title,
857
+ tabindex: "-1",
858
+ class: "sheet-panel bg-surface relative flex max-h-[94%] min-h-[56dvh] flex-col rounded-t-[28px] shadow-2xl outline-none"
859
+ }, [
860
+ _cache[0] || (_cache[0] = createElementVNode("div", {
861
+ class: "flex shrink-0 justify-center pt-3",
862
+ "aria-hidden": "true"
863
+ }, [createElementVNode("span", { class: "bg-hair h-1.5 w-10 rounded-full" })], -1)),
864
+ createElementVNode("header", _hoisted_3$5, [createElementVNode("div", _hoisted_4$4, [createElementVNode("h2", _hoisted_5$3, toDisplayString(__props.title), 1), __props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_6$1, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)]), createElementVNode("button", {
865
+ type: "button",
866
+ class: "text-ink-soft hover:bg-muted hover:text-ink -mt-1 flex size-10 shrink-0 items-center justify-center rounded-full transition-colors active:scale-90",
867
+ "aria-label": __props.closeLabel,
868
+ onClick: close
869
+ }, [createVNode(unref(X), { class: "size-5" })], 8, _hoisted_7$1)]),
870
+ createElementVNode("div", _hoisted_8, [renderSlot(_ctx.$slots, "default", {}, void 0, true)])
871
+ ], 8, _hoisted_2$9)])], 4)) : createCommentVNode("", true)]),
872
+ _: 3
873
+ })]);
874
+ };
875
+ }
876
+ });
877
+ //#endregion
878
+ //#region \0plugin-vue:export-helper
879
+ var _plugin_vue_export_helper_default = (sfc, props) => {
880
+ const target = sfc.__vccOpts || sfc;
881
+ for (const [key, val] of props) target[key] = val;
882
+ return target;
883
+ };
884
+ //#endregion
885
+ //#region src/components/BaseSheet.vue
886
+ var BaseSheet_default = /*#__PURE__*/ _plugin_vue_export_helper_default(BaseSheet_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-3f2e9ce4"]]);
887
+ //#endregion
888
+ //#region src/components/EmptyState.vue?vue&type=script&setup=true&lang.ts
889
+ var _hoisted_1$9 = { class: "flex flex-col items-center gap-3 px-6 py-10 text-center" };
890
+ var _hoisted_2$8 = {
891
+ key: 0,
892
+ class: "bg-muted text-primary rounded-card flex size-12 items-center"
893
+ };
894
+ var _hoisted_3$4 = { class: "text-ink text-base font-semibold" };
895
+ var _hoisted_4$3 = {
896
+ key: 1,
897
+ class: "text-ink-soft max-w-[36ch] text-sm"
898
+ };
899
+ var _hoisted_5$2 = {
900
+ key: 2,
901
+ class: "mt-2 flex w-full flex-col gap-2"
902
+ };
903
+ //#endregion
904
+ //#region src/components/EmptyState.vue
905
+ var EmptyState_default = /* @__PURE__ */ defineComponent({
906
+ __name: "EmptyState",
907
+ props: {
908
+ title: {},
909
+ description: { default: "" }
910
+ },
911
+ setup(__props) {
912
+ return (_ctx, _cache) => {
913
+ return openBlock(), createElementBlock("div", _hoisted_1$9, [
914
+ _ctx.$slots.icon ? (openBlock(), createElementBlock("div", _hoisted_2$8, [renderSlot(_ctx.$slots, "icon")])) : createCommentVNode("", true),
915
+ createElementVNode("h3", _hoisted_3$4, toDisplayString(__props.title), 1),
916
+ __props.description ? (openBlock(), createElementBlock("p", _hoisted_4$3, toDisplayString(__props.description), 1)) : createCommentVNode("", true),
917
+ _ctx.$slots.action ? (openBlock(), createElementBlock("div", _hoisted_5$2, [renderSlot(_ctx.$slots, "action")])) : createCommentVNode("", true)
918
+ ]);
919
+ };
920
+ }
921
+ });
922
+ //#endregion
923
+ //#region src/components/PageHeader.vue?vue&type=script&setup=true&lang.ts
924
+ var _hoisted_1$8 = { class: "grid h-12 shrink-0 grid-cols-[2.5rem_1fr_2.5rem] items-center" };
925
+ var _hoisted_2$7 = { class: "justify-self-start" };
926
+ var _hoisted_3$3 = { class: "text-ink flex min-w-0 justify-center text-base font-semibold tabular-nums" };
927
+ var _hoisted_4$2 = { class: "truncate" };
928
+ var _hoisted_5$1 = { class: "justify-self-end" };
929
+ //#endregion
930
+ //#region src/components/PageHeader.vue
931
+ var PageHeader_default = /* @__PURE__ */ defineComponent({
932
+ __name: "PageHeader",
933
+ props: { title: {} },
934
+ setup(__props) {
935
+ return (_ctx, _cache) => {
936
+ return openBlock(), createElementBlock("header", _hoisted_1$8, [
937
+ createElementVNode("div", _hoisted_2$7, [renderSlot(_ctx.$slots, "left")]),
938
+ createElementVNode("h1", _hoisted_3$3, [renderSlot(_ctx.$slots, "title", {}, () => [createElementVNode("span", _hoisted_4$2, toDisplayString(__props.title), 1)])]),
939
+ createElementVNode("div", _hoisted_5$1, [renderSlot(_ctx.$slots, "right")])
940
+ ]);
941
+ };
942
+ }
943
+ });
944
+ //#endregion
945
+ //#region src/components/ToneDot.vue?vue&type=script&setup=true&lang.ts
946
+ var _hoisted_1$7 = { class: "inline-flex items-center gap-1.5" };
947
+ var _hoisted_2$6 = {
948
+ key: 0,
949
+ class: "text-ink-soft text-xs font-medium"
950
+ };
951
+ //#endregion
952
+ //#region src/components/ToneDot.vue
953
+ var ToneDot_default = /* @__PURE__ */ defineComponent({
954
+ __name: "ToneDot",
955
+ props: {
956
+ fill: {},
957
+ label: { default: "" }
958
+ },
959
+ setup(__props) {
960
+ /**
961
+ * A small coloured dot, optionally labelled.
962
+ *
963
+ * Takes the colour as a class rather than a category, so an app can key it off
964
+ * whatever its own domain calls a category — habit kinds, expense types,
965
+ * priorities — without this component knowing about any of them.
966
+ */
967
+ return (_ctx, _cache) => {
968
+ return openBlock(), createElementBlock("span", _hoisted_1$7, [createElementVNode("span", { class: normalizeClass(["size-2 rounded-full", __props.fill]) }, null, 2), __props.label ? (openBlock(), createElementBlock("span", _hoisted_2$6, toDisplayString(__props.label), 1)) : createCommentVNode("", true)]);
969
+ };
970
+ }
971
+ });
972
+ //#endregion
973
+ //#region src/components/SectionHeading.vue?vue&type=script&setup=true&lang.ts
974
+ var _hoisted_1$6 = {
975
+ key: 0,
976
+ class: "text-ink-soft text-xs tabular-nums"
977
+ };
978
+ //#endregion
979
+ //#region src/components/SectionHeading.vue
980
+ var SectionHeading_default = /* @__PURE__ */ defineComponent({
981
+ __name: "SectionHeading",
982
+ props: {
983
+ tone: {},
984
+ label: {},
985
+ count: { default: 0 }
986
+ },
987
+ setup(__props) {
988
+ return (_ctx, _cache) => {
989
+ return openBlock(), createElementBlock("h2", { class: normalizeClass(["flex items-center gap-2 self-start rounded-full border px-3 py-1", __props.tone.card]) }, [
990
+ createVNode(ToneDot_default, { fill: __props.tone.fill }, null, 8, ["fill"]),
991
+ createElementVNode("span", { class: normalizeClass(["text-xs font-semibold tracking-wide uppercase", __props.tone.text]) }, toDisplayString(__props.label), 3),
992
+ __props.count > 0 ? (openBlock(), createElementBlock("span", _hoisted_1$6, toDisplayString(__props.count), 1)) : createCommentVNode("", true)
993
+ ], 2);
994
+ };
995
+ }
996
+ });
997
+ //#endregion
998
+ //#region src/components/SegmentedControl.vue?vue&type=script&setup=true&lang.ts
999
+ var _hoisted_1$5 = { class: "bg-muted rounded-card flex w-full gap-1 p-1" };
1000
+ var _hoisted_2$5 = ["value", "name"];
1001
+ //#endregion
1002
+ //#region src/components/SegmentedControl.vue
1003
+ var SegmentedControl_default = /* @__PURE__ */ defineComponent({
1004
+ __name: "SegmentedControl",
1005
+ props: /*@__PURE__*/ mergeModels({ options: {} }, {
1006
+ "modelValue": { required: true },
1007
+ "modelModifiers": {}
1008
+ }),
1009
+ emits: ["update:modelValue"],
1010
+ setup(__props) {
1011
+ const model = useModel(__props, "modelValue");
1012
+ const name = useId();
1013
+ return (_ctx, _cache) => {
1014
+ return openBlock(), createElementBlock("div", _hoisted_1$5, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.options, (option) => {
1015
+ return openBlock(), createElementBlock("label", {
1016
+ key: String(option.value),
1017
+ class: "flex-1 cursor-pointer"
1018
+ }, [withDirectives(createElementVNode("input", {
1019
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => model.value = $event),
1020
+ type: "radio",
1021
+ value: option.value,
1022
+ name: unref(name),
1023
+ class: "sr-only"
1024
+ }, null, 8, _hoisted_2$5), [[vModelRadio, model.value]]), createElementVNode("span", { class: normalizeClass(["flex h-10 items-center justify-center rounded-xl px-2 text-sm font-medium transition-colors select-none", model.value === option.value ? "bg-surface text-ink shadow-sm" : "text-ink-soft"]) }, toDisplayString(option.label), 3)]);
1025
+ }), 128))]);
1026
+ };
1027
+ }
1028
+ });
1029
+ //#endregion
1030
+ //#region src/components/SettingsGroup.vue?vue&type=script&setup=true&lang.ts
1031
+ var _hoisted_1$4 = { class: "flex flex-col gap-2" };
1032
+ var _hoisted_2$4 = { class: "text-ink-soft px-1 text-xs font-semibold tracking-wide uppercase" };
1033
+ var _hoisted_3$2 = { class: "border-hair bg-surface rounded-card divide-hair divide-y overflow-hidden border" };
1034
+ //#endregion
1035
+ //#region src/components/SettingsGroup.vue
1036
+ var SettingsGroup_default = /* @__PURE__ */ defineComponent({
1037
+ __name: "SettingsGroup",
1038
+ props: { title: {} },
1039
+ setup(__props) {
1040
+ return (_ctx, _cache) => {
1041
+ return openBlock(), createElementBlock("section", _hoisted_1$4, [createElementVNode("h2", _hoisted_2$4, toDisplayString(__props.title), 1), createElementVNode("div", _hoisted_3$2, [renderSlot(_ctx.$slots, "default")])]);
1042
+ };
1043
+ }
1044
+ });
1045
+ //#endregion
1046
+ //#region src/components/SettingsRow.vue?vue&type=script&setup=true&lang.ts
1047
+ var _hoisted_1$3 = { class: "flex items-center gap-3" };
1048
+ var _hoisted_2$3 = {
1049
+ key: 0,
1050
+ class: "bg-muted text-ink-soft flex size-9 shrink-0 items-center justify-center rounded-xl",
1051
+ "aria-hidden": "true"
1052
+ };
1053
+ var _hoisted_3$1 = { class: "min-w-0 flex-1" };
1054
+ var _hoisted_4$1 = { class: "text-ink text-sm font-medium" };
1055
+ var _hoisted_5 = {
1056
+ key: 0,
1057
+ class: "text-ink-soft mt-0.5 text-xs leading-snug"
1058
+ };
1059
+ var _hoisted_6 = {
1060
+ key: 1,
1061
+ class: "shrink-0"
1062
+ };
1063
+ var _hoisted_7 = { key: 0 };
1064
+ //#endregion
1065
+ //#region src/components/SettingsRow.vue
1066
+ var SettingsRow_default = /* @__PURE__ */ defineComponent({
1067
+ __name: "SettingsRow",
1068
+ props: {
1069
+ label: {},
1070
+ description: { default: "" },
1071
+ icon: { default: () => void 0 },
1072
+ interactive: {
1073
+ type: Boolean,
1074
+ default: false
1075
+ },
1076
+ stacked: {
1077
+ type: Boolean,
1078
+ default: false
1079
+ }
1080
+ },
1081
+ emits: ["click"],
1082
+ setup(__props, { emit: __emit }) {
1083
+ const emit = __emit;
1084
+ return (_ctx, _cache) => {
1085
+ return openBlock(), createBlock(resolveDynamicComponent(__props.interactive ? "button" : "div"), {
1086
+ type: __props.interactive ? "button" : void 0,
1087
+ class: normalizeClass(["flex w-full items-center gap-3 px-4 py-3 text-left", [__props.interactive ? "hover:bg-muted/60 transition-colors active:scale-[0.99]" : "", __props.stacked ? "flex-col items-stretch gap-3" : ""]]),
1088
+ onClick: _cache[0] || (_cache[0] = ($event) => __props.interactive && emit("click"))
1089
+ }, {
1090
+ default: withCtx(() => [createElementVNode("div", _hoisted_1$3, [
1091
+ __props.icon ? (openBlock(), createElementBlock("span", _hoisted_2$3, [(openBlock(), createBlock(resolveDynamicComponent(__props.icon), { class: "size-[18px]" }))])) : createCommentVNode("", true),
1092
+ createElementVNode("div", _hoisted_3$1, [createElementVNode("p", _hoisted_4$1, toDisplayString(__props.label), 1), __props.description ? (openBlock(), createElementBlock("p", _hoisted_5, toDisplayString(__props.description), 1)) : createCommentVNode("", true)]),
1093
+ !__props.stacked ? (openBlock(), createElementBlock("div", _hoisted_6, [renderSlot(_ctx.$slots, "default")])) : createCommentVNode("", true),
1094
+ __props.interactive ? (openBlock(), createBlock(unref(ChevronRight), {
1095
+ key: 2,
1096
+ class: "text-ink-soft size-4 shrink-0",
1097
+ "aria-hidden": "true"
1098
+ })) : createCommentVNode("", true)
1099
+ ]), __props.stacked ? (openBlock(), createElementBlock("div", _hoisted_7, [renderSlot(_ctx.$slots, "default")])) : createCommentVNode("", true)]),
1100
+ _: 3
1101
+ }, 8, ["type", "class"]);
1102
+ };
1103
+ }
1104
+ });
1105
+ //#endregion
1106
+ //#region src/components/SkeletonList.vue?vue&type=script&setup=true&lang.ts
1107
+ var _hoisted_1$2 = {
1108
+ role: "status",
1109
+ class: "flex flex-col gap-1"
1110
+ };
1111
+ var _hoisted_2$2 = { class: "sr-only" };
1112
+ //#endregion
1113
+ //#region src/components/SkeletonList.vue
1114
+ var SkeletonList_default = /* @__PURE__ */ defineComponent({
1115
+ __name: "SkeletonList",
1116
+ props: {
1117
+ rows: { default: 3 },
1118
+ rowHeight: { default: "h-14" },
1119
+ label: { default: "Loading…" }
1120
+ },
1121
+ setup(__props) {
1122
+ return (_ctx, _cache) => {
1123
+ return openBlock(), createElementBlock("div", _hoisted_1$2, [createElementVNode("span", _hoisted_2$2, toDisplayString(__props.label), 1), (openBlock(true), createElementBlock(Fragment, null, renderList(__props.rows, (row) => {
1124
+ return openBlock(), createElementBlock("div", {
1125
+ key: row,
1126
+ class: normalizeClass(["bg-muted rounded-card animate-pulse", __props.rowHeight]),
1127
+ "aria-hidden": "true"
1128
+ }, null, 2);
1129
+ }), 128))]);
1130
+ };
1131
+ }
1132
+ });
1133
+ //#endregion
1134
+ //#region src/components/StatCard.vue?vue&type=script&setup=true&lang.ts
1135
+ var _hoisted_1$1 = { class: "border-hair rounded-card flex flex-1 flex-col gap-0.5 border p-3" };
1136
+ var _hoisted_2$1 = { class: "flex items-baseline gap-1" };
1137
+ var _hoisted_3 = { class: "text-ink text-xl font-semibold tabular-nums" };
1138
+ var _hoisted_4 = { class: "text-ink-soft text-xs" };
1139
+ //#endregion
1140
+ //#region src/components/StatCard.vue
1141
+ var StatCard_default = /* @__PURE__ */ defineComponent({
1142
+ __name: "StatCard",
1143
+ props: {
1144
+ value: {},
1145
+ label: {},
1146
+ trend: { default: null }
1147
+ },
1148
+ setup(__props) {
1149
+ const TREND_ICON = {
1150
+ up: ArrowUp,
1151
+ down: ArrowDown,
1152
+ flat: ArrowRight
1153
+ };
1154
+ return (_ctx, _cache) => {
1155
+ return openBlock(), createElementBlock("div", _hoisted_1$1, [createElementVNode("div", _hoisted_2$1, [createElementVNode("span", _hoisted_3, toDisplayString(__props.value), 1), __props.trend ? (openBlock(), createBlock(resolveDynamicComponent(TREND_ICON[__props.trend]), {
1156
+ key: 0,
1157
+ class: "text-ink-soft size-3"
1158
+ })) : createCommentVNode("", true)]), createElementVNode("span", _hoisted_4, toDisplayString(__props.label), 1)]);
1159
+ };
1160
+ }
1161
+ });
1162
+ //#endregion
1163
+ //#region src/components/LocaleLinks.vue?vue&type=script&setup=true&lang.ts
1164
+ var _hoisted_1 = ["aria-label"];
1165
+ var _hoisted_2 = [
1166
+ "lang",
1167
+ "aria-pressed",
1168
+ "onClick"
1169
+ ];
1170
+ //#endregion
1171
+ //#region src/components/LocaleLinks.vue
1172
+ var LocaleLinks_default = /* @__PURE__ */ defineComponent({
1173
+ __name: "LocaleLinks",
1174
+ props: /*@__PURE__*/ mergeModels({
1175
+ locales: {},
1176
+ labels: {},
1177
+ label: { default: "" }
1178
+ }, {
1179
+ "modelValue": { required: true },
1180
+ "modelModifiers": {}
1181
+ }),
1182
+ emits: ["update:modelValue"],
1183
+ setup(__props) {
1184
+ /**
1185
+ * A flat language switcher for screens with no Settings behind them.
1186
+ *
1187
+ * The list and the labels are props: only the app knows which languages it
1188
+ * ships, and endonyms — each language written in itself — are what make the
1189
+ * right option legible to someone who cannot read the current interface.
1190
+ */
1191
+ /**
1192
+ * Two-way bound rather than taking the runtime's ref as a prop: props are not
1193
+ * unwrapped in a template and cannot be assigned to, so the ref would compare
1194
+ * against itself and the click handler would not compile.
1195
+ */
1196
+ const preference = useModel(__props, "modelValue");
1197
+ return (_ctx, _cache) => {
1198
+ return openBlock(), createElementBlock("nav", {
1199
+ class: "flex flex-wrap items-center justify-center gap-1",
1200
+ "aria-label": __props.label || void 0
1201
+ }, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.locales, (locale) => {
1202
+ return openBlock(), createElementBlock("button", {
1203
+ key: locale,
1204
+ type: "button",
1205
+ lang: locale,
1206
+ class: normalizeClass(["rounded-full px-2.5 py-1.5 text-xs transition-colors", preference.value === locale ? "bg-muted text-ink font-semibold" : "text-ink-soft hover:text-ink"]),
1207
+ "aria-pressed": preference.value === locale,
1208
+ onClick: ($event) => preference.value = locale
1209
+ }, toDisplayString(__props.labels[locale]), 11, _hoisted_2);
1210
+ }), 128))], 8, _hoisted_1);
1211
+ };
1212
+ }
1213
+ });
1214
+ //#endregion
1215
+ //#region src/i18n/runtime.ts
1216
+ /**
1217
+ * Builds an i18n runtime around an app's own catalogue.
1218
+ *
1219
+ * A factory rather than a module singleton because the schema is the app's:
1220
+ * typing every locale as `typeof en` is what makes a missing key a build error,
1221
+ * and this package has no `en` of its own to type against.
1222
+ *
1223
+ * @example
1224
+ * ```ts
1225
+ * export const { i18n, t, useLocalePreference, loadActiveLocale } =
1226
+ * createI18nRuntime({
1227
+ * locales: ['en', 'tr'] as const,
1228
+ * fallback: 'en',
1229
+ * intlTags: { en: 'en-GB', tr: 'tr-TR' },
1230
+ * messages: en,
1231
+ * loaders: { tr: () => import('./locales/tr') },
1232
+ * storageKey: 'myapp-locale',
1233
+ * })
1234
+ * ```
1235
+ */
1236
+ function createI18nRuntime(options) {
1237
+ const { locales, fallback, intlTags, messages, storageKey = "rei-locale" } = options;
1238
+ const loaders = options.loaders ?? {};
1239
+ function isSupported(value) {
1240
+ return locales.includes(value);
1241
+ }
1242
+ /**
1243
+ * First browser language the app can actually speak.
1244
+ *
1245
+ * `navigator.languages` is ordered by the user's own preference, so the first
1246
+ * match is the best one — not simply the first entry.
1247
+ */
1248
+ function detectSystemLocale() {
1249
+ for (const tag of navigator.languages ?? [navigator.language]) {
1250
+ const base = tag.split("-")[0]?.toLowerCase();
1251
+ if (base && isSupported(base)) return base;
1252
+ }
1253
+ return fallback;
1254
+ }
1255
+ function readStored() {
1256
+ try {
1257
+ const stored = localStorage.getItem(storageKey);
1258
+ if (stored === "system" || stored && isSupported(stored)) return stored;
1259
+ } catch {}
1260
+ return "system";
1261
+ }
1262
+ const preference = ref(readStored());
1263
+ const activeLocale = computed(() => preference.value === "system" ? detectSystemLocale() : preference.value);
1264
+ const intlLocale = computed(() => intlTags[activeLocale.value]);
1265
+ const initial = { [fallback]: messages };
1266
+ const i18n = createI18n({
1267
+ legacy: false,
1268
+ locale: activeLocale.value,
1269
+ fallbackLocale: fallback,
1270
+ messages: initial
1271
+ });
1272
+ /**
1273
+ * A narrow view of the instance.
1274
+ *
1275
+ * vue-i18n infers its own generics from the messages it is handed, which
1276
+ * fights a runtime that is generic over the app's schema. Casting once, here,
1277
+ * keeps that fight out of every call site — and the surface below is the
1278
+ * whole of what this runtime uses.
1279
+ */
1280
+ const core = i18n.global;
1281
+ const loaded = /* @__PURE__ */ new Set([fallback]);
1282
+ /**
1283
+ * Makes sure a locale's messages are in place before it becomes active.
1284
+ *
1285
+ * Awaited rather than fired and forgotten: setting the locale first paints one
1286
+ * frame of the fallback at every other user, which is the flash a fallback
1287
+ * exists to prevent, not cause.
1288
+ */
1289
+ async function ensureMessages(locale) {
1290
+ if (loaded.has(locale)) return;
1291
+ const load = loaders[locale];
1292
+ if (!load) return;
1293
+ try {
1294
+ const module = await load();
1295
+ core.setLocaleMessage(locale, module.default);
1296
+ loaded.add(locale);
1297
+ } catch {}
1298
+ }
1299
+ /** Loads whatever the stored preference resolves to. Call before mounting. */
1300
+ function loadActiveLocale() {
1301
+ return ensureMessages(activeLocale.value);
1302
+ }
1303
+ watchEffect(() => {
1304
+ core.locale.value = activeLocale.value;
1305
+ setFormatLocale(intlLocale.value);
1306
+ document.documentElement.lang = activeLocale.value;
1307
+ });
1308
+ /** Read and write the language preference. */
1309
+ function useLocalePreference() {
1310
+ return computed({
1311
+ get: () => preference.value,
1312
+ set: (next) => {
1313
+ ensureMessages(next === "system" ? detectSystemLocale() : next).then(() => {
1314
+ preference.value = next;
1315
+ });
1316
+ try {
1317
+ localStorage.setItem(storageKey, next);
1318
+ } catch {}
1319
+ }
1320
+ });
1321
+ }
1322
+ return {
1323
+ i18n,
1324
+ /** `t` for code outside a component. Tracks the locale inside a computed. */
1325
+ t: core.t,
1326
+ activeLocale,
1327
+ intlLocale,
1328
+ ensureMessages,
1329
+ loadActiveLocale,
1330
+ useLocalePreference
1331
+ };
1332
+ }
1333
+ //#endregion
1334
+ //#region src/index.ts
1335
+ /**
1336
+ * rei-kit — the layer every app starts from.
1337
+ *
1338
+ * Everything here is free of any backend, router or i18n choice. Components
1339
+ * take strings rather than calling a translator, and utilities take the clock
1340
+ * rather than reading it, so nothing in this package can force a decision on
1341
+ * the app that installs it.
1342
+ *
1343
+ * @see https://github.com/ramazandogna/rei-kit
1344
+ */
1345
+ var VERSION = "0.0.0";
1346
+ //#endregion
1347
+ export { AppError, BaseButton_default as BaseButton, BaseInput_default as BaseInput, BaseSheet_default as BaseSheet, EmptyState_default as EmptyState, LocaleLinks_default as LocaleLinks, PageHeader_default as PageHeader, SectionHeading_default as SectionHeading, SegmentedControl_default as SegmentedControl, SettingsGroup_default as SettingsGroup, SettingsRow_default as SettingsRow, SkeletonList_default as SkeletonList, StatCard_default as StatCard, ToneDot_default as ToneDot, VERSION, addDays, applyTheme, createI18nRuntime, downloadJson, eachDayOfYear, formatDate, fromDateKey, isApplePortable, isInstalled, isThemePreference, lastNDays, leadingBlanks, needsIosInstall, readStoredTheme, registerErrorMapper, relativeDayLabel, safeRedirect, setFormatLocale, setThemeStorageKey, startOfWeek, tapFeedback, toAppError, toDateKey, todayKey, useDebouncedCallback, useDragScroll, useOnline, useTheme, useToday, useVisualViewport };
1348
+
1349
+ //# sourceMappingURL=index.js.map