rei-kit 0.12.1 → 0.13.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.
- package/dist/_plugin-vue_export-helper-3AcMDTtW.js +339 -0
- package/dist/_plugin-vue_export-helper-3AcMDTtW.js.map +1 -0
- package/dist/app/AuthShell.vue.d.ts +29 -0
- package/dist/app/index.d.ts +16 -0
- package/dist/app/use-tab-transition.d.ts +52 -0
- package/dist/app/use-theme-sync.d.ts +23 -0
- package/dist/app.js +140 -0
- package/dist/app.js.map +1 -0
- package/dist/index.js +9 -433
- package/dist/index.js.map +1 -1
- package/dist/pwa/InstallPrompt.vue.d.ts +26 -0
- package/dist/pwa/UpdatePrompt.vue.d.ts +48 -0
- package/dist/pwa/index.d.ts +15 -0
- package/dist/pwa/use-install.d.ts +41 -0
- package/dist/pwa/use-snooze.d.ts +22 -0
- package/dist/pwa.js +236 -0
- package/dist/pwa.js.map +1 -0
- package/dist/styles.css +69 -0
- package/dist/use-theme-_MnaDD5v.js +94 -0
- package/dist/use-theme-_MnaDD5v.js.map +1 -0
- package/package.json +9 -1
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { computed, createBlock, createCommentVNode, createElementBlock, defineComponent, mergeProps, openBlock, renderSlot, resolveDynamicComponent, withCtx } from "vue";
|
|
2
|
+
//#region src/utils/date.ts
|
|
3
|
+
/**
|
|
4
|
+
* Local calendar-day helpers.
|
|
5
|
+
*
|
|
6
|
+
* Every function is pure and works on `YYYY-MM-DD` keys, the same shape as the
|
|
7
|
+
* `date` columns in Postgres. Nothing here calls `toISOString`: that converts to
|
|
8
|
+
* UTC, so in a UTC+9 timezone every entry made between midnight and 09:00 would
|
|
9
|
+
* be written to the previous day.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Formats a `Date` as a local `YYYY-MM-DD` key.
|
|
13
|
+
*
|
|
14
|
+
* @param date - Any `Date`; only its local year, month and day are read.
|
|
15
|
+
* @returns The calendar day in the runtime's own timezone.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* // 2026-08-23 01:30 in Tokyo
|
|
20
|
+
* toDateKey(new Date()) // '2026-08-23'
|
|
21
|
+
* new Date().toISOString() // '2026-08-22T16:30…' ← the bug
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
function toDateKey(date) {
|
|
25
|
+
return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
26
|
+
}
|
|
27
|
+
/** Today's key in the user's own timezone. */
|
|
28
|
+
function todayKey() {
|
|
29
|
+
return toDateKey(/* @__PURE__ */ new Date());
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parses a `YYYY-MM-DD` key into a `Date` at local midnight.
|
|
33
|
+
*
|
|
34
|
+
* @param key - A key produced by {@link toDateKey}.
|
|
35
|
+
* @returns Local midnight of that calendar day.
|
|
36
|
+
* @throws If the key is not three numeric parts.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* fromDateKey('2026-08-23') // local midnight, correct
|
|
41
|
+
* new Date('2026-08-23') // UTC midnight — shifts a day in some zones
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
function fromDateKey(key) {
|
|
45
|
+
const [year, month, day] = key.split("-").map(Number);
|
|
46
|
+
if (year === void 0 || month === void 0 || day === void 0) throw new Error(`Invalid date key: ${key}`);
|
|
47
|
+
return new Date(year, month - 1, day);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Shifts a date key by whole calendar days.
|
|
51
|
+
*
|
|
52
|
+
* Uses `setDate`, which is calendar-aware: it rolls over month and year ends,
|
|
53
|
+
* and stays correct across daylight-saving transitions. Adding
|
|
54
|
+
* `days * 86_400_000` milliseconds would not — a DST day is 23 or 25 hours long.
|
|
55
|
+
*
|
|
56
|
+
* @param key - Starting `YYYY-MM-DD` key.
|
|
57
|
+
* @param days - Days to add; negative goes back.
|
|
58
|
+
* @returns The resulting key.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* addDays('2026-01-31', 1) // '2026-02-01'
|
|
63
|
+
* addDays('2026-01-01', -1) // '2025-12-31'
|
|
64
|
+
* addDays('2028-02-28', 1) // '2028-02-29' — leap year
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function addDays(key, days) {
|
|
68
|
+
const date = fromDateKey(key);
|
|
69
|
+
date.setDate(date.getDate() + days);
|
|
70
|
+
return toDateKey(date);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The last `count` days ending today, oldest first.
|
|
74
|
+
*
|
|
75
|
+
* `today` is a parameter so the function stays pure and testable; call sites
|
|
76
|
+
* normally omit it.
|
|
77
|
+
*
|
|
78
|
+
* @param count - How many days to return, including `today`.
|
|
79
|
+
* @param today - End of the range. Defaults to the real today.
|
|
80
|
+
* @returns Keys in ascending order.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* lastNDays(3, '2026-08-23') // ['2026-08-21', '2026-08-22', '2026-08-23']
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
function lastNDays(count, today = todayKey()) {
|
|
88
|
+
const keys = [];
|
|
89
|
+
for (let offset = count - 1; offset >= 0; offset -= 1) keys.push(addDays(today, -offset));
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The first day of the week containing `key`.
|
|
94
|
+
*
|
|
95
|
+
* The user's preference is a parameter, not a module-level setting: changing it
|
|
96
|
+
* in Profile has to re-render the week grid and the year heatmap immediately,
|
|
97
|
+
* and a global would make that a hidden dependency.
|
|
98
|
+
*
|
|
99
|
+
* @param key - Any day in the week.
|
|
100
|
+
* @param weekStartsOn - 0 for Sunday, 1 for Monday.
|
|
101
|
+
* @returns Key of that week's first day.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* // 2026-08-23 is a Sunday
|
|
106
|
+
* startOfWeek('2026-08-23', 1) // '2026-08-17' — previous Monday
|
|
107
|
+
* startOfWeek('2026-08-23', 0) // '2026-08-23' — already Sunday
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
function startOfWeek(key, weekStartsOn) {
|
|
111
|
+
return addDays(key, -((fromDateKey(key).getDay() - weekStartsOn + 7) % 7));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Every day of a calendar year, in order.
|
|
115
|
+
*
|
|
116
|
+
* Leap years fall out of the loop for free: it walks day by day until the year
|
|
117
|
+
* rolls over, so February 29 is included when it exists.
|
|
118
|
+
*
|
|
119
|
+
* @param year - Four-digit year.
|
|
120
|
+
* @returns 365 or 366 keys, oldest first.
|
|
121
|
+
*/
|
|
122
|
+
function eachDayOfYear(year) {
|
|
123
|
+
const keys = [];
|
|
124
|
+
const date = new Date(year, 0, 1);
|
|
125
|
+
while (date.getFullYear() === year) {
|
|
126
|
+
keys.push(toDateKey(date));
|
|
127
|
+
date.setDate(date.getDate() + 1);
|
|
128
|
+
}
|
|
129
|
+
return keys;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Empty cells before a block's first day in a seven-row column grid.
|
|
133
|
+
*
|
|
134
|
+
* The grid fills column by column, so the first column is only partly used
|
|
135
|
+
* unless the block starts exactly on the week's first day. An off-by-one here
|
|
136
|
+
* shifts the whole block by a row, so this is unit tested.
|
|
137
|
+
*
|
|
138
|
+
* @param firstDayKey - First day of the block, e.g. `'2026-02-01'`.
|
|
139
|
+
* @param weekStartsOn - 0 for Sunday, 1 for Monday.
|
|
140
|
+
* @returns 0-6 blank cells.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* leadingBlanks('2026-01-01', 1) // 3 — a Thursday, Mon-Wed are blank
|
|
145
|
+
* leadingBlanks('2024-01-01', 1) // 0 — a Monday
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
function leadingBlanks(firstDayKey, weekStartsOn) {
|
|
149
|
+
return (fromDateKey(firstDayKey).getDay() - weekStartsOn + 7) % 7;
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/utils/platform.ts
|
|
153
|
+
/**
|
|
154
|
+
* Whether the app is running from the Home Screen rather than a browser tab.
|
|
155
|
+
*
|
|
156
|
+
* Two checks because iOS predates the standard one: `display-mode: standalone`
|
|
157
|
+
* is the modern signal, `navigator.standalone` is Safari's own.
|
|
158
|
+
*/
|
|
159
|
+
function isInstalled() {
|
|
160
|
+
if (typeof window === "undefined") return false;
|
|
161
|
+
return window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
|
|
162
|
+
}
|
|
163
|
+
/** iPhone and iPad, including iPadOS reporting itself as a Mac. */
|
|
164
|
+
function isApplePortable() {
|
|
165
|
+
if (typeof window === "undefined") return false;
|
|
166
|
+
return /iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Whether this device can only receive notifications once the app is installed.
|
|
170
|
+
*
|
|
171
|
+
* Safari on iOS grants notification permission to an installed web app and to
|
|
172
|
+
* nothing else — in a normal tab the request does not even prompt. Telling the
|
|
173
|
+
* user to allow notifications there is asking for something the browser will
|
|
174
|
+
* not offer, so the UI has to say "add to Home Screen" instead.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* if (needsIosInstall()) // show the Home Screen instruction, not the button
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
function needsIosInstall() {
|
|
182
|
+
return isApplePortable() && !isInstalled();
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/components/BaseButton.vue?vue&type=script&setup=true&lang.ts
|
|
186
|
+
var _hoisted_1 = {
|
|
187
|
+
key: 0,
|
|
188
|
+
class: "size-4 animate-spin rounded-full border-2 border-current border-t-transparent",
|
|
189
|
+
"aria-hidden": "true"
|
|
190
|
+
};
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/components/BaseButton.vue
|
|
193
|
+
var BaseButton_default = /* @__PURE__ */ defineComponent({
|
|
194
|
+
__name: "BaseButton",
|
|
195
|
+
props: {
|
|
196
|
+
as: { default: "button" },
|
|
197
|
+
variant: { default: "primary" },
|
|
198
|
+
size: { default: "md" },
|
|
199
|
+
loading: {
|
|
200
|
+
type: Boolean,
|
|
201
|
+
default: false
|
|
202
|
+
},
|
|
203
|
+
disabled: {
|
|
204
|
+
type: Boolean,
|
|
205
|
+
default: false
|
|
206
|
+
},
|
|
207
|
+
type: { default: "button" },
|
|
208
|
+
icon: {
|
|
209
|
+
type: Boolean,
|
|
210
|
+
default: false
|
|
211
|
+
},
|
|
212
|
+
block: {
|
|
213
|
+
type: Boolean,
|
|
214
|
+
default: false
|
|
215
|
+
},
|
|
216
|
+
to: { default: () => void 0 },
|
|
217
|
+
href: { default: () => void 0 },
|
|
218
|
+
pill: {
|
|
219
|
+
type: Boolean,
|
|
220
|
+
default: false
|
|
221
|
+
},
|
|
222
|
+
pressed: {
|
|
223
|
+
type: Boolean,
|
|
224
|
+
default: () => void 0
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
setup(__props) {
|
|
228
|
+
const VARIANT_CLASS = {
|
|
229
|
+
primary: "bg-primary text-white hover:bg-primary/90",
|
|
230
|
+
secondary: "border-hair bg-surface text-ink border hover:bg-muted",
|
|
231
|
+
ghost: "bg-transparent text-ink hover:bg-muted",
|
|
232
|
+
destructive: "bg-transparent text-ink-soft hover:text-negative",
|
|
233
|
+
row: "w-full justify-start text-left bg-transparent text-ink hover:bg-muted",
|
|
234
|
+
quiet: "bg-transparent text-ink-soft hover:bg-muted hover:text-ink",
|
|
235
|
+
danger: "bg-negative text-white hover:bg-negative/90",
|
|
236
|
+
positive: "bg-positive text-white hover:bg-positive/90",
|
|
237
|
+
warning: "bg-warning text-white hover:bg-warning/90",
|
|
238
|
+
accent: "bg-accent text-white hover:bg-accent/90",
|
|
239
|
+
link: "bg-transparent underline underline-offset-2 hover:opacity-80",
|
|
240
|
+
unstyled: ""
|
|
241
|
+
};
|
|
242
|
+
const SIZE_CLASS = {
|
|
243
|
+
xs: "h-8 px-3 text-xs",
|
|
244
|
+
sm: "h-9 px-3 text-sm",
|
|
245
|
+
md: "h-11 px-4 text-base",
|
|
246
|
+
lg: "h-14 px-6 text-lg"
|
|
247
|
+
};
|
|
248
|
+
const ICON_SIZE_CLASS = {
|
|
249
|
+
xs: "size-8 text-xs",
|
|
250
|
+
sm: "size-9 text-sm",
|
|
251
|
+
md: "size-11 text-base",
|
|
252
|
+
lg: "size-14 text-lg"
|
|
253
|
+
};
|
|
254
|
+
const ROW_SIZE_CLASS = {
|
|
255
|
+
xs: "px-2 py-1.5 text-xs",
|
|
256
|
+
sm: "px-3 py-2 text-sm",
|
|
257
|
+
md: "px-3 py-2.5 text-base",
|
|
258
|
+
lg: "px-4 py-3 text-lg"
|
|
259
|
+
};
|
|
260
|
+
const LINK_SIZE_CLASS = {
|
|
261
|
+
xs: "text-xs",
|
|
262
|
+
sm: "text-sm",
|
|
263
|
+
md: "text-base",
|
|
264
|
+
lg: "text-lg"
|
|
265
|
+
};
|
|
266
|
+
const sizing = computed(() => {
|
|
267
|
+
if (__props.variant === "unstyled") return "";
|
|
268
|
+
if (__props.variant === "link") return LINK_SIZE_CLASS[__props.size];
|
|
269
|
+
if (__props.variant === "row") return ROW_SIZE_CLASS[__props.size];
|
|
270
|
+
return __props.icon ? ICON_SIZE_CLASS[__props.size] : SIZE_CLASS[__props.size];
|
|
271
|
+
});
|
|
272
|
+
const PRESSED_CLASS = {
|
|
273
|
+
ghost: "bg-primary text-white hover:bg-primary/90",
|
|
274
|
+
quiet: "bg-primary text-white hover:bg-primary/90",
|
|
275
|
+
secondary: "bg-primary border-primary text-white hover:bg-primary/90",
|
|
276
|
+
row: "w-full justify-start text-left bg-muted text-ink hover:bg-muted"
|
|
277
|
+
};
|
|
278
|
+
const surface = computed(() => {
|
|
279
|
+
if (__props.pressed === true) return PRESSED_CLASS[__props.variant] ?? VARIANT_CLASS[__props.variant];
|
|
280
|
+
if (__props.variant === "destructive") return `${VARIANT_CLASS.destructive} hover:bg-negative/10`;
|
|
281
|
+
return VARIANT_CLASS[__props.variant];
|
|
282
|
+
});
|
|
283
|
+
const shell = computed(() => {
|
|
284
|
+
if (__props.variant === "unstyled") return "";
|
|
285
|
+
const feel = "transition-[transform,color,background-color,border-color] duration-100 select-none";
|
|
286
|
+
if (__props.variant === "row") return `inline-flex items-center gap-2 font-medium ${feel}`;
|
|
287
|
+
return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-95`;
|
|
288
|
+
});
|
|
289
|
+
const radius = computed(() => {
|
|
290
|
+
if (__props.variant === "unstyled") return "";
|
|
291
|
+
if (__props.variant === "link") return "rounded-xs";
|
|
292
|
+
return __props.pill ? "rounded-full" : "rounded-card";
|
|
293
|
+
});
|
|
294
|
+
/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */
|
|
295
|
+
const inactive = computed(() => __props.disabled || __props.loading);
|
|
296
|
+
const linkProps = computed(() => {
|
|
297
|
+
if (__props.as === "router-link") return { to: __props.to };
|
|
298
|
+
if (__props.as === "a") return inactive.value ? {} : { href: __props.href };
|
|
299
|
+
return {};
|
|
300
|
+
});
|
|
301
|
+
return (_ctx, _cache) => {
|
|
302
|
+
return openBlock(), createBlock(resolveDynamicComponent(__props.as), mergeProps(linkProps.value, {
|
|
303
|
+
type: __props.as === "button" ? __props.type : void 0,
|
|
304
|
+
disabled: __props.as === "button" ? inactive.value : void 0,
|
|
305
|
+
"aria-disabled": __props.as !== "button" && inactive.value ? "true" : void 0,
|
|
306
|
+
"aria-busy": __props.loading,
|
|
307
|
+
"aria-pressed": __props.pressed === void 0 ? void 0 : String(__props.pressed),
|
|
308
|
+
class: ["focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", [
|
|
309
|
+
shell.value,
|
|
310
|
+
surface.value,
|
|
311
|
+
sizing.value,
|
|
312
|
+
radius.value,
|
|
313
|
+
__props.block ? "w-full" : ""
|
|
314
|
+
]]
|
|
315
|
+
}), {
|
|
316
|
+
default: withCtx(() => [__props.loading ? (openBlock(), createElementBlock("span", _hoisted_1)) : createCommentVNode("", true), renderSlot(_ctx.$slots, "default")]),
|
|
317
|
+
_: 3
|
|
318
|
+
}, 16, [
|
|
319
|
+
"type",
|
|
320
|
+
"disabled",
|
|
321
|
+
"aria-disabled",
|
|
322
|
+
"aria-busy",
|
|
323
|
+
"aria-pressed",
|
|
324
|
+
"class"
|
|
325
|
+
]);
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region \0plugin-vue:export-helper
|
|
331
|
+
var _plugin_vue_export_helper_default = (sfc, props) => {
|
|
332
|
+
const target = sfc.__vccOpts || sfc;
|
|
333
|
+
for (const [key, val] of props) target[key] = val;
|
|
334
|
+
return target;
|
|
335
|
+
};
|
|
336
|
+
//#endregion
|
|
337
|
+
export { needsIosInstall as a, fromDateKey as c, startOfWeek as d, toDateKey as f, isInstalled as i, lastNDays as l, BaseButton_default as n, addDays as o, todayKey as p, isApplePortable as r, eachDayOfYear as s, _plugin_vue_export_helper_default as t, leadingBlanks as u };
|
|
338
|
+
|
|
339
|
+
//# sourceMappingURL=_plugin-vue_export-helper-3AcMDTtW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"_plugin-vue_export-helper-3AcMDTtW.js","names":[],"sources":["../src/utils/date.ts","../src/utils/platform.ts","../src/components/BaseButton.vue","../src/components/BaseButton.vue"],"sourcesContent":["/**\n * Local calendar-day helpers.\n *\n * Every function is pure and works on `YYYY-MM-DD` keys, the same shape as the\n * `date` columns in Postgres. Nothing here calls `toISOString`: that converts to\n * UTC, so in a UTC+9 timezone every entry made between midnight and 09:00 would\n * be written to the previous day.\n */\n\n/**\n * Formats a `Date` as a local `YYYY-MM-DD` key.\n *\n * @param date - Any `Date`; only its local year, month and day are read.\n * @returns The calendar day in the runtime's own timezone.\n *\n * @example\n * ```ts\n * // 2026-08-23 01:30 in Tokyo\n * toDateKey(new Date()) // '2026-08-23'\n * new Date().toISOString() // '2026-08-22T16:30…' ← the bug\n * ```\n */\nexport function toDateKey(date: Date): string {\n const year = String(date.getFullYear()).padStart(4, '0')\n const month = String(date.getMonth() + 1).padStart(2, '0')\n const day = String(date.getDate()).padStart(2, '0')\n\n return `${year}-${month}-${day}`\n}\n\n/** Today's key in the user's own timezone. */\nexport function todayKey(): string {\n return toDateKey(new Date())\n}\n\n/**\n * Parses a `YYYY-MM-DD` key into a `Date` at local midnight.\n *\n * @param key - A key produced by {@link toDateKey}.\n * @returns Local midnight of that calendar day.\n * @throws If the key is not three numeric parts.\n *\n * @example\n * ```ts\n * fromDateKey('2026-08-23') // local midnight, correct\n * new Date('2026-08-23') // UTC midnight — shifts a day in some zones\n * ```\n */\nexport function fromDateKey(key: string): Date {\n const [year, month, day] = key.split('-').map(Number)\n\n if (year === undefined || month === undefined || day === undefined) {\n throw new Error(`Invalid date key: ${key}`)\n }\n\n return new Date(year, month - 1, day)\n}\n\n/**\n * Shifts a date key by whole calendar days.\n *\n * Uses `setDate`, which is calendar-aware: it rolls over month and year ends,\n * and stays correct across daylight-saving transitions. Adding\n * `days * 86_400_000` milliseconds would not — a DST day is 23 or 25 hours long.\n *\n * @param key - Starting `YYYY-MM-DD` key.\n * @param days - Days to add; negative goes back.\n * @returns The resulting key.\n *\n * @example\n * ```ts\n * addDays('2026-01-31', 1) // '2026-02-01'\n * addDays('2026-01-01', -1) // '2025-12-31'\n * addDays('2028-02-28', 1) // '2028-02-29' — leap year\n * ```\n */\nexport function addDays(key: string, days: number): string {\n const date = fromDateKey(key)\n date.setDate(date.getDate() + days)\n\n return toDateKey(date)\n}\n\n/**\n * The last `count` days ending today, oldest first.\n *\n * `today` is a parameter so the function stays pure and testable; call sites\n * normally omit it.\n *\n * @param count - How many days to return, including `today`.\n * @param today - End of the range. Defaults to the real today.\n * @returns Keys in ascending order.\n *\n * @example\n * ```ts\n * lastNDays(3, '2026-08-23') // ['2026-08-21', '2026-08-22', '2026-08-23']\n * ```\n */\nexport function lastNDays(count: number, today: string = todayKey()): string[] {\n const keys: string[] = []\n\n for (let offset = count - 1; offset >= 0; offset -= 1) {\n keys.push(addDays(today, -offset))\n }\n\n return keys\n}\n\n/** 0 = week starts on Sunday, 1 = on Monday. Mirrors `profiles.week_starts_on`. */\nexport type WeekStart = 0 | 1\n\n/**\n * The first day of the week containing `key`.\n *\n * The user's preference is a parameter, not a module-level setting: changing it\n * in Profile has to re-render the week grid and the year heatmap immediately,\n * and a global would make that a hidden dependency.\n *\n * @param key - Any day in the week.\n * @param weekStartsOn - 0 for Sunday, 1 for Monday.\n * @returns Key of that week's first day.\n *\n * @example\n * ```ts\n * // 2026-08-23 is a Sunday\n * startOfWeek('2026-08-23', 1) // '2026-08-17' — previous Monday\n * startOfWeek('2026-08-23', 0) // '2026-08-23' — already Sunday\n * ```\n */\nexport function startOfWeek(key: string, weekStartsOn: WeekStart): string {\n const weekday = fromDateKey(key).getDay()\n const offset = (weekday - weekStartsOn + 7) % 7\n\n return addDays(key, -offset)\n}\n\n/**\n * Every day of a calendar year, in order.\n *\n * Leap years fall out of the loop for free: it walks day by day until the year\n * rolls over, so February 29 is included when it exists.\n *\n * @param year - Four-digit year.\n * @returns 365 or 366 keys, oldest first.\n */\nexport function eachDayOfYear(year: number): string[] {\n const keys: string[] = []\n const date = new Date(year, 0, 1)\n\n while (date.getFullYear() === year) {\n keys.push(toDateKey(date))\n date.setDate(date.getDate() + 1)\n }\n\n return keys\n}\n\n/**\n * Empty cells before a block's first day in a seven-row column grid.\n *\n * The grid fills column by column, so the first column is only partly used\n * unless the block starts exactly on the week's first day. An off-by-one here\n * shifts the whole block by a row, so this is unit tested.\n *\n * @param firstDayKey - First day of the block, e.g. `'2026-02-01'`.\n * @param weekStartsOn - 0 for Sunday, 1 for Monday.\n * @returns 0-6 blank cells.\n *\n * @example\n * ```ts\n * leadingBlanks('2026-01-01', 1) // 3 — a Thursday, Mon-Wed are blank\n * leadingBlanks('2024-01-01', 1) // 0 — a Monday\n * ```\n */\nexport function leadingBlanks(firstDayKey: string, weekStartsOn: WeekStart): number {\n return (fromDateKey(firstDayKey).getDay() - weekStartsOn + 7) % 7\n}\n","/**\n * Whether the app is running from the Home Screen rather than a browser tab.\n *\n * Two checks because iOS predates the standard one: `display-mode: standalone`\n * is the modern signal, `navigator.standalone` is Safari's own.\n */\nexport function isInstalled(): boolean {\n if (typeof window === 'undefined') return false\n\n return (\n window.matchMedia('(display-mode: standalone)').matches ||\n (navigator as Navigator & { standalone?: boolean }).standalone === true\n )\n}\n\n/** iPhone and iPad, including iPadOS reporting itself as a Mac. */\nexport function isApplePortable(): boolean {\n if (typeof window === 'undefined') return false\n\n return (\n /iPad|iPhone|iPod/.test(navigator.userAgent) ||\n (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)\n )\n}\n\n/**\n * Whether this device can only receive notifications once the app is installed.\n *\n * Safari on iOS grants notification permission to an installed web app and to\n * nothing else — in a normal tab the request does not even prompt. Telling the\n * user to allow notifications there is asking for something the browser will\n * not offer, so the UI has to say \"add to Home Screen\" instead.\n *\n * @example\n * ```ts\n * if (needsIosInstall()) // show the Home Screen instruction, not the button\n * ```\n */\nexport function needsIosInstall(): boolean {\n return isApplePortable() && !isInstalled()\n}\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<{\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n}>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-white hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'border-hair bg-surface text-ink border hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-white hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-white hover:bg-positive/90',\n warning: 'bg-warning text-white hover:bg-warning/90',\n accent: 'bg-accent text-white hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-white hover:bg-primary/90',\n quiet: 'bg-primary text-white hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-white hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel = 'transition-[transform,color,background-color,border-color] duration-100 select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-95`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-xs'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<{\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n}>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-white hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'border-hair bg-surface text-ink border hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-white hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-white hover:bg-positive/90',\n warning: 'bg-warning text-white hover:bg-warning/90',\n accent: 'bg-accent text-white hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-white hover:bg-primary/90',\n quiet: 'bg-primary text-white hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-white hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel = 'transition-[transform,color,background-color,border-color] duration-100 select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-95`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-xs'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n"],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame every sign-in screen sits in.
|
|
3
|
+
*
|
|
4
|
+
* A brand mark, a narrow column, and the language links pinned to the bottom.
|
|
5
|
+
* The two phone apps had this file character for character — thirty-seven
|
|
6
|
+
* lines, no difference at all — and the only thing either would want to change
|
|
7
|
+
* is what goes in the slots.
|
|
8
|
+
*
|
|
9
|
+
* The language links matter more than they look. Sign-in is the first screen a
|
|
10
|
+
* new user sees and Settings is behind it, so without a way to switch here,
|
|
11
|
+
* somebody who does not read the browser's language cannot get to one.
|
|
12
|
+
*/
|
|
13
|
+
type __VLS_Slots = {
|
|
14
|
+
/** The brand mark. */
|
|
15
|
+
brand?: () => unknown;
|
|
16
|
+
/** The form. */
|
|
17
|
+
default: () => unknown;
|
|
18
|
+
/** The language links, or anything else that belongs at the foot. */
|
|
19
|
+
foot?: () => unknown;
|
|
20
|
+
};
|
|
21
|
+
declare const __VLS_base: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
|
|
22
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
23
|
+
declare const _default: typeof __VLS_export;
|
|
24
|
+
export default _default;
|
|
25
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
26
|
+
new (): {
|
|
27
|
+
$slots: S;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rei-kit/app — the parts a phone app is made of.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the main entry because these are not primitives: they assume
|
|
5
|
+
* an app with tabs, an account and a sign-in screen. A wide site importing the
|
|
6
|
+
* kit should not have to know they exist.
|
|
7
|
+
*
|
|
8
|
+
* Everything here came out of two apps that had written it identically —
|
|
9
|
+
* `AuthShell` was thirty-seven lines with no difference at all between them,
|
|
10
|
+
* the tab transition thirty-four. What differs between two phone apps is the
|
|
11
|
+
* product; this is the part underneath it.
|
|
12
|
+
*/
|
|
13
|
+
export { default as AuthShell } from './AuthShell.vue';
|
|
14
|
+
export { createTabTransition } from './use-tab-transition';
|
|
15
|
+
export type { SlideDirection } from './use-tab-transition';
|
|
16
|
+
export { useThemeSync } from './use-theme-sync';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Which way the screens slide during a tab change. */
|
|
2
|
+
export type SlideDirection = 'forward' | 'backward' | 'none';
|
|
3
|
+
/**
|
|
4
|
+
* Which way a tabbed app is moving.
|
|
5
|
+
*
|
|
6
|
+
* A phone app slides sideways between its tabs, and the direction has to come
|
|
7
|
+
* from somewhere: going from the second tab to the fourth is forward, the
|
|
8
|
+
* other way is back, and arriving from nowhere is neither. That is index
|
|
9
|
+
* arithmetic over the tab order, and it was written twice, identically, in the
|
|
10
|
+
* two phone apps this kit came from — thirty-four lines each, byte for byte
|
|
11
|
+
* the same.
|
|
12
|
+
*
|
|
13
|
+
* Generic over the tab key, so the app keeps its own union and the kit never
|
|
14
|
+
* learns what a tab is called.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* // shared/lib/tabs.ts
|
|
19
|
+
* export const tabs = createTabTransition(['today', 'week', 'year', 'profile'] as const)
|
|
20
|
+
*
|
|
21
|
+
* // the router guard
|
|
22
|
+
* router.afterEach((to, from) => tabs.resolve(to.meta.tab, from.meta.tab))
|
|
23
|
+
*
|
|
24
|
+
* // App.vue
|
|
25
|
+
* const name = computed(() =>
|
|
26
|
+
* tabs.direction.value === 'none' ? '' : `slide-${tabs.direction.value}`,
|
|
27
|
+
* )
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* The `slide-forward-*` and `slide-backward-*` classes those names refer to
|
|
31
|
+
* ship in `rei-kit/shell/mobile.css`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createTabTransition<K extends string>(order: readonly K[]): {
|
|
34
|
+
/** Direction of the current tab change. Read by the route transition. */
|
|
35
|
+
direction: Readonly<import('vue').Ref<SlideDirection, SlideDirection>>;
|
|
36
|
+
/**
|
|
37
|
+
* Resolves the direction for a navigation. Call once per route change.
|
|
38
|
+
*
|
|
39
|
+
* @param to - Tab being entered, if the route has one.
|
|
40
|
+
* @param from - Tab being left, if the route had one.
|
|
41
|
+
*/
|
|
42
|
+
resolve(to: K | undefined, from: K | undefined): void;
|
|
43
|
+
/**
|
|
44
|
+
* Forces the next navigation's direction, whatever the indices say.
|
|
45
|
+
*
|
|
46
|
+
* For the navigations that are not a tab change at heart: going back from
|
|
47
|
+
* a detail screen, or being sent to sign-in. Without it, leaving a detail
|
|
48
|
+
* page under the fourth tab for the first tab slides backward, which is
|
|
49
|
+
* right, and arriving there slides forward, which is not.
|
|
50
|
+
*/
|
|
51
|
+
force(next: SlideDirection): void;
|
|
52
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
/**
|
|
3
|
+
* Adopts the theme stored on the account, once, as soon as it arrives.
|
|
4
|
+
*
|
|
5
|
+
* Two things make this worth a component rather than four lines at a call
|
|
6
|
+
* site, and both are about *once*.
|
|
7
|
+
*
|
|
8
|
+
* It has to run at the app root rather than on the settings screen, or a user
|
|
9
|
+
* on a fresh device keeps the system theme until they happen to open Profile.
|
|
10
|
+
* And it has to run once and never again, or a later refetch of the profile
|
|
11
|
+
* undoes a choice the user has just made locally — the theme flips back under
|
|
12
|
+
* them a second after they set it, which reads as the app fighting them.
|
|
13
|
+
*
|
|
14
|
+
* The source is a ref rather than a query, so the kit never learns what a
|
|
15
|
+
* profile is or where it came from.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const { data: profile } = useProfile()
|
|
20
|
+
* useThemeSync(computed(() => profile.value?.theme))
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function useThemeSync(stored: Ref<string | null | undefined>): void;
|