rei-kit 0.14.0 → 0.15.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/README.md +8 -1
- package/dist/GoogleButton-BQSsm0hp.js +507 -0
- package/dist/GoogleButton-BQSsm0hp.js.map +1 -0
- package/dist/app/AuthForm.vue.d.ts +78 -0
- package/dist/app/auth-form.d.ts +36 -0
- package/dist/app/field-errors.d.ts +39 -0
- package/dist/app/index.d.ts +4 -0
- package/dist/app.js +185 -4
- package/dist/app.js.map +1 -1
- package/dist/components/GoogleButton.vue.d.ts +8 -0
- package/dist/index.js +3 -247
- package/dist/index.js.map +1 -1
- package/dist/shell/mobile.css +49 -0
- package/dist/styles.css +8 -40
- package/dist/supabase/auth-errors.d.ts +40 -0
- package/dist/supabase/index.d.ts +2 -0
- package/dist/supabase.js +46 -2
- package/dist/supabase.js.map +1 -1
- package/package.json +1 -1
- package/dist/BaseSheet-CLIhXDwe.js +0 -253
- package/dist/BaseSheet-CLIhXDwe.js.map +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { AuthFormLabels, AuthFormValues } from './auth-form';
|
|
2
|
+
/**
|
|
3
|
+
* The sign-in and sign-up forms, which are one component.
|
|
4
|
+
*
|
|
5
|
+
* All three consumers had written this, and two of them had written it twice —
|
|
6
|
+
* a login view and a signup view differing by one input and an autocomplete
|
|
7
|
+
* hint. Two components that differ by one field drift apart: the kit's copies
|
|
8
|
+
* had already diverged on the error colour, the placeholder and whether the
|
|
9
|
+
* submit button changed its wording while busy.
|
|
10
|
+
*
|
|
11
|
+
* Keeping the two modes together also means moving between the routes replaces
|
|
12
|
+
* a heading and adds an input, rather than tearing down a form and building
|
|
13
|
+
* another one over the top of it.
|
|
14
|
+
*
|
|
15
|
+
* ## What it does not do
|
|
16
|
+
*
|
|
17
|
+
* It does not sign anybody in. It emits `submit` with the values and takes
|
|
18
|
+
* `busy` and `error` back, because the store, the redirect and the wording of
|
|
19
|
+
* a failure are the app's, and a form that reached for them would need to know
|
|
20
|
+
* what the app is about. Validation is a function the caller passes for the
|
|
21
|
+
* same reason: the password minimum is a product decision, and the kit has no
|
|
22
|
+
* opinion on whether it is eight characters or ten.
|
|
23
|
+
*/
|
|
24
|
+
type __VLS_Props = {
|
|
25
|
+
mode: 'signIn' | 'signUp';
|
|
26
|
+
/**
|
|
27
|
+
* Every string on screen. No defaults, in any language: a label the kit
|
|
28
|
+
* invented would ship English into an app that has none, and it would do
|
|
29
|
+
* it silently.
|
|
30
|
+
*/
|
|
31
|
+
labels: AuthFormLabels;
|
|
32
|
+
/** Disables the controls and spins the submit button. */
|
|
33
|
+
busy?: boolean | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* A failure to show above the submit button, already translated.
|
|
36
|
+
*
|
|
37
|
+
* A sentence rather than a key, because the kit cannot translate and
|
|
38
|
+
* `toAuthMessageKey` hands the caller a key precisely so the caller can.
|
|
39
|
+
*/
|
|
40
|
+
error?: string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Returns one message per invalid field, or null when the values are good.
|
|
43
|
+
*
|
|
44
|
+
* Omitted, the form submits whatever is typed and lets the server decide —
|
|
45
|
+
* which is a legitimate choice, not a broken one. `fieldErrors` turns a Zod
|
|
46
|
+
* schema into this in one line.
|
|
47
|
+
*/
|
|
48
|
+
validate?: ((values: AuthFormValues) => Record<string, string> | null) | undefined;
|
|
49
|
+
};
|
|
50
|
+
type __VLS_Slots = {
|
|
51
|
+
/** Above everything — a heading and a line under it. */
|
|
52
|
+
header?: () => unknown;
|
|
53
|
+
/** Replaces the Google button entirely, for a different provider or several. */
|
|
54
|
+
oauth?: () => unknown;
|
|
55
|
+
/** Under the form — the link to the other mode, terms, anything. */
|
|
56
|
+
foot?: () => unknown;
|
|
57
|
+
};
|
|
58
|
+
type __VLS_ModelProps = {
|
|
59
|
+
'remember'?: boolean;
|
|
60
|
+
};
|
|
61
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
62
|
+
declare const __VLS_base: import('vue').DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
63
|
+
submit: (values: AuthFormValues) => any;
|
|
64
|
+
google: () => any;
|
|
65
|
+
"update:remember": (value: boolean) => any;
|
|
66
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
67
|
+
onSubmit?: (values: AuthFormValues) => any;
|
|
68
|
+
onGoogle?: () => any;
|
|
69
|
+
"onUpdate:remember"?: (value: boolean) => any;
|
|
70
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
|
|
71
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
72
|
+
declare const _default: typeof __VLS_export;
|
|
73
|
+
export default _default;
|
|
74
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
75
|
+
new (): {
|
|
76
|
+
$slots: S;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes `AuthForm` speaks in.
|
|
3
|
+
*
|
|
4
|
+
* Beside the component rather than inside it, because a `<script setup>` block
|
|
5
|
+
* cannot export a type and a consumer typing its own labels object needs one.
|
|
6
|
+
*/
|
|
7
|
+
export type AuthFormValues = {
|
|
8
|
+
email: string;
|
|
9
|
+
password: string;
|
|
10
|
+
/** Empty on sign-in; the form does not collect it there. */
|
|
11
|
+
confirmPassword: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Every string `AuthForm` puts on screen.
|
|
15
|
+
*
|
|
16
|
+
* No defaults, in any language. A label the kit invented would ship English
|
|
17
|
+
* into an app that has none, and it would do it silently — the form would look
|
|
18
|
+
* finished and read wrong.
|
|
19
|
+
*/
|
|
20
|
+
export type AuthFormLabels = {
|
|
21
|
+
email: string;
|
|
22
|
+
password: string;
|
|
23
|
+
confirmPassword: string;
|
|
24
|
+
/** The submit button. */
|
|
25
|
+
submit: string;
|
|
26
|
+
/** The submit button while busy. Falls back to `submit`. */
|
|
27
|
+
submitBusy?: string | undefined;
|
|
28
|
+
/** The Google button. Omitted, no Google button is rendered. */
|
|
29
|
+
google?: string | undefined;
|
|
30
|
+
/** The word between the OAuth button and the form. Usually "or". */
|
|
31
|
+
or?: string | undefined;
|
|
32
|
+
rememberMe?: string | undefined;
|
|
33
|
+
/** Under the password field on sign-up — the policy, stated before it is broken. */
|
|
34
|
+
passwordHint?: string | undefined;
|
|
35
|
+
emailPlaceholder?: string | undefined;
|
|
36
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anything that validates like a Zod schema.
|
|
3
|
+
*
|
|
4
|
+
* Structural rather than `z.ZodType`, and that is the point: the kit never
|
|
5
|
+
* imports Zod, so an app that validates with something else — or with nothing —
|
|
6
|
+
* does not download a validator to use a form. Zod satisfies this shape as it
|
|
7
|
+
* is, so a consumer passes `signupSchema()` and nothing else changes.
|
|
8
|
+
*/
|
|
9
|
+
export type SafeParsable = {
|
|
10
|
+
safeParse: (values: unknown) => {
|
|
11
|
+
success: true;
|
|
12
|
+
} | {
|
|
13
|
+
success: false;
|
|
14
|
+
error: {
|
|
15
|
+
issues: readonly {
|
|
16
|
+
path: readonly PropertyKey[];
|
|
17
|
+
message: string;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Field errors keyed by field name, or null when the values are valid.
|
|
24
|
+
*
|
|
25
|
+
* Zod's own error shape is a tree; a form needs one message per input, and
|
|
26
|
+
* flattening it here keeps that reshaping out of every component that has a
|
|
27
|
+
* form in it. The first issue per field wins — a field shows one message, and
|
|
28
|
+
* the first is the one the reader can act on.
|
|
29
|
+
*
|
|
30
|
+
* Null rather than an empty object, so `if (errors)` is the guard and a caller
|
|
31
|
+
* cannot accidentally treat "no errors" as a failure.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const found = fieldErrors(signupSchema(), values)
|
|
36
|
+
* if (found) return (errors.value = found)
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function fieldErrors(schema: SafeParsable, values: unknown): Record<string, string> | null;
|
package/dist/app/index.d.ts
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
* the tab transition thirty-four. What differs between two phone apps is the
|
|
11
11
|
* product; this is the part underneath it.
|
|
12
12
|
*/
|
|
13
|
+
export { default as AuthForm } from './AuthForm.vue';
|
|
14
|
+
export type { AuthFormLabels, AuthFormValues } from './auth-form';
|
|
13
15
|
export { default as AuthShell } from './AuthShell.vue';
|
|
14
16
|
export { default as LocaleSheet } from './LocaleSheet.vue';
|
|
15
17
|
export { default as TourShell } from './TourShell.vue';
|
|
18
|
+
export { fieldErrors } from './field-errors';
|
|
19
|
+
export type { SafeParsable } from './field-errors';
|
|
16
20
|
export { createTabTransition } from './use-tab-transition';
|
|
17
21
|
export type { SlideDirection } from './use-tab-transition';
|
|
18
22
|
export { useThemeSync } from './use-theme-sync';
|
package/dist/app.js
CHANGED
|
@@ -1,7 +1,159 @@
|
|
|
1
|
-
import { i as isThemePreference,
|
|
1
|
+
import { f as useTheme, i as BaseInput_default, l as isThemePreference, n as BaseCheckbox_default, o as BaseAlert_default, r as BaseSheet_default, t as GoogleButton_default } from "./GoogleButton-BQSsm0hp.js";
|
|
2
2
|
import { n as _plugin_vue_export_helper_default, r as BaseButton_default, t as SettingsRow_default } from "./SettingsRow-MnVleeTR.js";
|
|
3
|
-
import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, onUnmounted, openBlock, readonly, ref, renderList, renderSlot, toDisplayString, unref, useModel, watch, withCtx } from "vue";
|
|
3
|
+
import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, onUnmounted, openBlock, reactive, readonly, ref, renderList, renderSlot, toDisplayString, unref, useModel, watch, withCtx, withModifiers } from "vue";
|
|
4
4
|
import { Check, Languages } from "lucide-vue-next";
|
|
5
|
+
//#region src/app/AuthForm.vue?vue&type=script&setup=true&lang.ts
|
|
6
|
+
var _hoisted_1$3 = { class: "flex flex-col gap-5" };
|
|
7
|
+
var _hoisted_2$3 = {
|
|
8
|
+
key: 0,
|
|
9
|
+
class: "flex items-center gap-3"
|
|
10
|
+
};
|
|
11
|
+
var _hoisted_3$3 = { class: "text-ink-soft text-xs" };
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/app/AuthForm.vue
|
|
14
|
+
var AuthForm_default = /* @__PURE__ */ defineComponent({
|
|
15
|
+
__name: "AuthForm",
|
|
16
|
+
props: /*@__PURE__*/ mergeModels({
|
|
17
|
+
mode: {},
|
|
18
|
+
labels: {},
|
|
19
|
+
busy: {
|
|
20
|
+
type: Boolean,
|
|
21
|
+
default: false
|
|
22
|
+
},
|
|
23
|
+
error: { default: "" },
|
|
24
|
+
validate: { type: Function }
|
|
25
|
+
}, {
|
|
26
|
+
"remember": {
|
|
27
|
+
type: Boolean,
|
|
28
|
+
default: true
|
|
29
|
+
},
|
|
30
|
+
"rememberModifiers": {}
|
|
31
|
+
}),
|
|
32
|
+
emits: /*@__PURE__*/ mergeModels(["submit", "google"], ["update:remember"]),
|
|
33
|
+
setup(__props, { emit: __emit }) {
|
|
34
|
+
const emit = __emit;
|
|
35
|
+
const remember = useModel(__props, "remember");
|
|
36
|
+
/**
|
|
37
|
+
* Remember-me belongs on sign-in, and only where the app has words for it.
|
|
38
|
+
*
|
|
39
|
+
* A brand-new account has nothing to remember, so offering the choice on
|
|
40
|
+
* sign-up is a question with one answer. Keyed off the label rather than a
|
|
41
|
+
* `showRemember` flag because Vue casts an absent Boolean prop to `false`,
|
|
42
|
+
* which makes "not passed" and "passed false" the same value — a flag that
|
|
43
|
+
* cannot express its own default is worse than no flag.
|
|
44
|
+
*/
|
|
45
|
+
const wantsRemember = computed(() => __props.mode === "signIn" && Boolean(__props.labels.rememberMe));
|
|
46
|
+
const values = reactive({
|
|
47
|
+
email: "",
|
|
48
|
+
password: "",
|
|
49
|
+
confirmPassword: ""
|
|
50
|
+
});
|
|
51
|
+
const errors = ref({});
|
|
52
|
+
watch(() => __props.mode, () => {
|
|
53
|
+
errors.value = {};
|
|
54
|
+
});
|
|
55
|
+
function submit() {
|
|
56
|
+
const payload = {
|
|
57
|
+
email: values.email,
|
|
58
|
+
password: values.password,
|
|
59
|
+
confirmPassword: __props.mode === "signUp" ? values.confirmPassword : ""
|
|
60
|
+
};
|
|
61
|
+
const found = __props.validate?.(payload) ?? null;
|
|
62
|
+
errors.value = found ?? {};
|
|
63
|
+
if (found) return;
|
|
64
|
+
emit("submit", payload);
|
|
65
|
+
}
|
|
66
|
+
return (_ctx, _cache) => {
|
|
67
|
+
return openBlock(), createElementBlock("div", _hoisted_1$3, [
|
|
68
|
+
renderSlot(_ctx.$slots, "header"),
|
|
69
|
+
renderSlot(_ctx.$slots, "oauth", {}, () => [__props.labels.google ? (openBlock(), createBlock(GoogleButton_default, {
|
|
70
|
+
key: 0,
|
|
71
|
+
label: __props.labels.google,
|
|
72
|
+
disabled: __props.busy,
|
|
73
|
+
onClick: _cache[0] || (_cache[0] = ($event) => emit("google"))
|
|
74
|
+
}, null, 8, ["label", "disabled"])) : createCommentVNode("", true)]),
|
|
75
|
+
__props.labels.or ? (openBlock(), createElementBlock("div", _hoisted_2$3, [
|
|
76
|
+
_cache[5] || (_cache[5] = createElementVNode("span", { class: "bg-hair h-px flex-1" }, null, -1)),
|
|
77
|
+
createElementVNode("span", _hoisted_3$3, toDisplayString(__props.labels.or), 1),
|
|
78
|
+
_cache[6] || (_cache[6] = createElementVNode("span", { class: "bg-hair h-px flex-1" }, null, -1))
|
|
79
|
+
])) : createCommentVNode("", true),
|
|
80
|
+
createElementVNode("form", {
|
|
81
|
+
novalidate: "",
|
|
82
|
+
class: "flex flex-col gap-4",
|
|
83
|
+
onSubmit: withModifiers(submit, ["prevent"])
|
|
84
|
+
}, [
|
|
85
|
+
createVNode(BaseInput_default, {
|
|
86
|
+
modelValue: values.email,
|
|
87
|
+
"onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => values.email = $event),
|
|
88
|
+
type: "email",
|
|
89
|
+
label: __props.labels.email,
|
|
90
|
+
error: errors.value["email"],
|
|
91
|
+
placeholder: __props.labels.emailPlaceholder,
|
|
92
|
+
autocomplete: "email"
|
|
93
|
+
}, null, 8, [
|
|
94
|
+
"modelValue",
|
|
95
|
+
"label",
|
|
96
|
+
"error",
|
|
97
|
+
"placeholder"
|
|
98
|
+
]),
|
|
99
|
+
createVNode(BaseInput_default, {
|
|
100
|
+
modelValue: values.password,
|
|
101
|
+
"onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => values.password = $event),
|
|
102
|
+
type: "password",
|
|
103
|
+
label: __props.labels.password,
|
|
104
|
+
error: errors.value["password"],
|
|
105
|
+
hint: __props.mode === "signUp" ? __props.labels.passwordHint : void 0,
|
|
106
|
+
autocomplete: __props.mode === "signUp" ? "new-password" : "current-password"
|
|
107
|
+
}, null, 8, [
|
|
108
|
+
"modelValue",
|
|
109
|
+
"label",
|
|
110
|
+
"error",
|
|
111
|
+
"hint",
|
|
112
|
+
"autocomplete"
|
|
113
|
+
]),
|
|
114
|
+
__props.mode === "signUp" ? (openBlock(), createBlock(BaseInput_default, {
|
|
115
|
+
key: 0,
|
|
116
|
+
modelValue: values.confirmPassword,
|
|
117
|
+
"onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => values.confirmPassword = $event),
|
|
118
|
+
type: "password",
|
|
119
|
+
label: __props.labels.confirmPassword,
|
|
120
|
+
error: errors.value["confirmPassword"],
|
|
121
|
+
autocomplete: "new-password"
|
|
122
|
+
}, null, 8, [
|
|
123
|
+
"modelValue",
|
|
124
|
+
"label",
|
|
125
|
+
"error"
|
|
126
|
+
])) : createCommentVNode("", true),
|
|
127
|
+
wantsRemember.value ? (openBlock(), createBlock(BaseCheckbox_default, {
|
|
128
|
+
key: 1,
|
|
129
|
+
modelValue: remember.value,
|
|
130
|
+
"onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => remember.value = $event),
|
|
131
|
+
size: "sm",
|
|
132
|
+
label: __props.labels.rememberMe ?? ""
|
|
133
|
+
}, null, 8, ["modelValue", "label"])) : createCommentVNode("", true),
|
|
134
|
+
__props.error ? (openBlock(), createBlock(BaseAlert_default, {
|
|
135
|
+
key: 2,
|
|
136
|
+
tone: "danger",
|
|
137
|
+
assertive: ""
|
|
138
|
+
}, {
|
|
139
|
+
default: withCtx(() => [createTextVNode(toDisplayString(__props.error), 1)]),
|
|
140
|
+
_: 1
|
|
141
|
+
})) : createCommentVNode("", true),
|
|
142
|
+
createVNode(BaseButton_default, {
|
|
143
|
+
type: "submit",
|
|
144
|
+
class: "w-full",
|
|
145
|
+
loading: __props.busy
|
|
146
|
+
}, {
|
|
147
|
+
default: withCtx(() => [createTextVNode(toDisplayString(__props.busy && __props.labels.submitBusy || __props.labels.submit), 1)]),
|
|
148
|
+
_: 1
|
|
149
|
+
}, 8, ["loading"])
|
|
150
|
+
], 32),
|
|
151
|
+
renderSlot(_ctx.$slots, "foot")
|
|
152
|
+
]);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
//#endregion
|
|
5
157
|
//#region src/app/AuthShell.vue?vue&type=script&setup=true&lang.ts
|
|
6
158
|
var _hoisted_1$2 = { class: "flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10" };
|
|
7
159
|
var _hoisted_2$2 = { class: "w-full max-w-[22rem]" };
|
|
@@ -249,7 +401,36 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
|
|
|
249
401
|
})], 8, ["to"]);
|
|
250
402
|
};
|
|
251
403
|
}
|
|
252
|
-
}), [["__scopeId", "data-v-
|
|
404
|
+
}), [["__scopeId", "data-v-1b9e7f4d"]]);
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/app/field-errors.ts
|
|
407
|
+
/**
|
|
408
|
+
* Field errors keyed by field name, or null when the values are valid.
|
|
409
|
+
*
|
|
410
|
+
* Zod's own error shape is a tree; a form needs one message per input, and
|
|
411
|
+
* flattening it here keeps that reshaping out of every component that has a
|
|
412
|
+
* form in it. The first issue per field wins — a field shows one message, and
|
|
413
|
+
* the first is the one the reader can act on.
|
|
414
|
+
*
|
|
415
|
+
* Null rather than an empty object, so `if (errors)` is the guard and a caller
|
|
416
|
+
* cannot accidentally treat "no errors" as a failure.
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```ts
|
|
420
|
+
* const found = fieldErrors(signupSchema(), values)
|
|
421
|
+
* if (found) return (errors.value = found)
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
function fieldErrors(schema, values) {
|
|
425
|
+
const result = schema.safeParse(values);
|
|
426
|
+
if (result.success) return null;
|
|
427
|
+
const errors = {};
|
|
428
|
+
for (const issue of result.error.issues) {
|
|
429
|
+
const field = String(issue.path[0] ?? "");
|
|
430
|
+
if (field && !errors[field]) errors[field] = issue.message;
|
|
431
|
+
}
|
|
432
|
+
return errors;
|
|
433
|
+
}
|
|
253
434
|
//#endregion
|
|
254
435
|
//#region src/app/use-tab-transition.ts
|
|
255
436
|
/**
|
|
@@ -352,6 +533,6 @@ function useThemeSync(stored) {
|
|
|
352
533
|
}, { immediate: true });
|
|
353
534
|
}
|
|
354
535
|
//#endregion
|
|
355
|
-
export { AuthShell_default as AuthShell, LocaleSheet_default as LocaleSheet, TourShell_default as TourShell, createTabTransition, useThemeSync };
|
|
536
|
+
export { AuthForm_default as AuthForm, AuthShell_default as AuthShell, LocaleSheet_default as LocaleSheet, TourShell_default as TourShell, createTabTransition, fieldErrors, useThemeSync };
|
|
356
537
|
|
|
357
538
|
//# sourceMappingURL=app.js.map
|
package/dist/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","names":["$slots","$emit"],"sources":["../src/app/AuthShell.vue","../src/app/AuthShell.vue","../src/app/LocaleSheet.vue","../src/app/LocaleSheet.vue","../src/app/TourShell.vue","../src/app/TourShell.vue","../src/app/use-tab-transition.ts","../src/app/use-theme-sync.ts"],"sourcesContent":["<script setup lang=\"ts\">\n/**\n * The frame every sign-in screen sits in.\n *\n * A brand mark, a narrow column, and the language links pinned to the bottom.\n * The two phone apps had this file character for character — thirty-seven\n * lines, no difference at all — and the only thing either would want to change\n * is what goes in the slots.\n *\n * The language links matter more than they look. Sign-in is the first screen a\n * new user sees and Settings is behind it, so without a way to switch here,\n * somebody who does not read the browser's language cannot get to one.\n */\ndefineSlots<{\n /** The brand mark. */\n brand?: () => unknown\n /** The form. */\n default: () => unknown\n /** The language links, or anything else that belongs at the foot. */\n foot?: () => unknown\n}>()\n</script>\n\n<template>\n <div\n class=\"flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10\"\n >\n <slot name=\"brand\" />\n\n <main class=\"w-full max-w-[22rem]\">\n <slot />\n </main>\n\n <div v-if=\"$slots.foot\" class=\"mt-auto\">\n <slot name=\"foot\" />\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\n/**\n * The frame every sign-in screen sits in.\n *\n * A brand mark, a narrow column, and the language links pinned to the bottom.\n * The two phone apps had this file character for character — thirty-seven\n * lines, no difference at all — and the only thing either would want to change\n * is what goes in the slots.\n *\n * The language links matter more than they look. Sign-in is the first screen a\n * new user sees and Settings is behind it, so without a way to switch here,\n * somebody who does not read the browser's language cannot get to one.\n */\ndefineSlots<{\n /** The brand mark. */\n brand?: () => unknown\n /** The form. */\n default: () => unknown\n /** The language links, or anything else that belongs at the foot. */\n foot?: () => unknown\n}>()\n</script>\n\n<template>\n <div\n class=\"flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10\"\n >\n <slot name=\"brand\" />\n\n <main class=\"w-full max-w-[22rem]\">\n <slot />\n </main>\n\n <div v-if=\"$slots.foot\" class=\"mt-auto\">\n <slot name=\"foot\" />\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { Check, Languages } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport BaseSheet from '../components/BaseSheet.vue'\nimport SettingsRow from '../components/SettingsRow.vue'\n\n/**\n * Choosing the interface language, from a settings row.\n *\n * A sheet rather than a segmented control: past four options a row of pills\n * stops being readable, and the list of languages only grows.\n *\n * Both phone apps had this, 96% identical. What differed was one colour class.\n *\n * The labels are the caller's, and they should be **endonyms** — a language is\n * always listed in its own language, so someone who cannot read the current\n * interface can still find theirs. The kit cannot know them.\n */\nconst {\n label,\n hint = '',\n systemLabel,\n options,\n closeLabel = '',\n} = defineProps<{\n /** The settings row's label, and the sheet's title. */\n label: string\n hint?: string | undefined\n /** What \"follow the device\" is called. Its value is `system`. */\n systemLabel: string\n /** In the order they should be listed. Labels are endonyms. */\n options: readonly { value: string; label: string }[]\n closeLabel?: string | undefined\n}>()\n\nconst model = defineModel<string>({ required: true })\n\nconst open = ref(false)\n\nconst rows = computed(() => [{ value: 'system', label: systemLabel }, ...options])\n\nconst current = computed(\n () => rows.value.find((row) => row.value === model.value)?.label ?? systemLabel,\n)\n\nfunction select(value: string) {\n model.value = value\n open.value = false\n}\n</script>\n\n<template>\n <SettingsRow\n :label=\"label\"\n :description=\"hint\"\n :icon=\"Languages\"\n interactive\n @click=\"open = true\"\n >\n <span class=\"text-ink-soft text-sm\">{{ current }}</span>\n </SettingsRow>\n\n <BaseSheet v-model=\"open\" :title=\"label\" :subtitle=\"hint\" :close-label=\"closeLabel\">\n <ul class=\"flex flex-col\">\n <li v-for=\"row in rows\" :key=\"row.value\">\n <BaseButton\n variant=\"row\"\n class=\"rounded-xl\"\n :pressed=\"model === row.value\"\n @click=\"select(row.value)\"\n >\n <span class=\"flex-1 text-base\">{{ row.label }}</span>\n <Check\n v-if=\"model === row.value\"\n class=\"text-primary size-5 shrink-0\"\n aria-hidden=\"true\"\n />\n </BaseButton>\n </li>\n </ul>\n </BaseSheet>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { Check, Languages } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport BaseSheet from '../components/BaseSheet.vue'\nimport SettingsRow from '../components/SettingsRow.vue'\n\n/**\n * Choosing the interface language, from a settings row.\n *\n * A sheet rather than a segmented control: past four options a row of pills\n * stops being readable, and the list of languages only grows.\n *\n * Both phone apps had this, 96% identical. What differed was one colour class.\n *\n * The labels are the caller's, and they should be **endonyms** — a language is\n * always listed in its own language, so someone who cannot read the current\n * interface can still find theirs. The kit cannot know them.\n */\nconst {\n label,\n hint = '',\n systemLabel,\n options,\n closeLabel = '',\n} = defineProps<{\n /** The settings row's label, and the sheet's title. */\n label: string\n hint?: string | undefined\n /** What \"follow the device\" is called. Its value is `system`. */\n systemLabel: string\n /** In the order they should be listed. Labels are endonyms. */\n options: readonly { value: string; label: string }[]\n closeLabel?: string | undefined\n}>()\n\nconst model = defineModel<string>({ required: true })\n\nconst open = ref(false)\n\nconst rows = computed(() => [{ value: 'system', label: systemLabel }, ...options])\n\nconst current = computed(\n () => rows.value.find((row) => row.value === model.value)?.label ?? systemLabel,\n)\n\nfunction select(value: string) {\n model.value = value\n open.value = false\n}\n</script>\n\n<template>\n <SettingsRow\n :label=\"label\"\n :description=\"hint\"\n :icon=\"Languages\"\n interactive\n @click=\"open = true\"\n >\n <span class=\"text-ink-soft text-sm\">{{ current }}</span>\n </SettingsRow>\n\n <BaseSheet v-model=\"open\" :title=\"label\" :subtitle=\"hint\" :close-label=\"closeLabel\">\n <ul class=\"flex flex-col\">\n <li v-for=\"row in rows\" :key=\"row.value\">\n <BaseButton\n variant=\"row\"\n class=\"rounded-xl\"\n :pressed=\"model === row.value\"\n @click=\"select(row.value)\"\n >\n <span class=\"flex-1 text-base\">{{ row.label }}</span>\n <Check\n v-if=\"model === row.value\"\n class=\"text-primary size-5 shrink-0\"\n aria-hidden=\"true\"\n />\n </BaseButton>\n </li>\n </ul>\n </BaseSheet>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-300\"\n :class=\"\n position - 1 <= index ? 'bg-primary' : 'bg-hair group-hover:bg-ink-soft/40'\n \"\n />\n </BaseButton>\n </div>\n\n <div class=\"flex gap-2\">\n <BaseButton\n v-if=\"index > 0\"\n variant=\"ghost\"\n class=\"shrink-0 px-5\"\n @click=\"$emit('back')\"\n >\n {{ backLabel }}\n </BaseButton>\n\n <BaseButton class=\"flex-1\" @click=\"$emit('next')\">\n {{ isLast ? lastLabel : nextLabel }}\n </BaseButton>\n </div>\n </footer>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n.tour-enter-active,\n.tour-leave-active {\n transition: opacity 220ms ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n\n.tour-forward-enter-active,\n.tour-backward-enter-active {\n transition:\n opacity 260ms ease-out,\n transform 260ms cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.tour-forward-leave-active,\n.tour-backward-leave-active {\n transition:\n opacity 130ms ease-in,\n transform 130ms ease-in;\n}\n\n.tour-forward-enter-from {\n opacity: 0;\n transform: translateX(1.25rem);\n}\n\n.tour-forward-leave-to {\n opacity: 0;\n transform: translateX(-0.75rem);\n}\n\n.tour-backward-enter-from {\n opacity: 0;\n transform: translateX(-1.25rem);\n}\n\n.tour-backward-leave-to {\n opacity: 0;\n transform: translateX(0.75rem);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .tour-forward-enter-from,\n .tour-forward-leave-to,\n .tour-backward-enter-from,\n .tour-backward-leave-to {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-300\"\n :class=\"\n position - 1 <= index ? 'bg-primary' : 'bg-hair group-hover:bg-ink-soft/40'\n \"\n />\n </BaseButton>\n </div>\n\n <div class=\"flex gap-2\">\n <BaseButton\n v-if=\"index > 0\"\n variant=\"ghost\"\n class=\"shrink-0 px-5\"\n @click=\"$emit('back')\"\n >\n {{ backLabel }}\n </BaseButton>\n\n <BaseButton class=\"flex-1\" @click=\"$emit('next')\">\n {{ isLast ? lastLabel : nextLabel }}\n </BaseButton>\n </div>\n </footer>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n.tour-enter-active,\n.tour-leave-active {\n transition: opacity 220ms ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n\n.tour-forward-enter-active,\n.tour-backward-enter-active {\n transition:\n opacity 260ms ease-out,\n transform 260ms cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.tour-forward-leave-active,\n.tour-backward-leave-active {\n transition:\n opacity 130ms ease-in,\n transform 130ms ease-in;\n}\n\n.tour-forward-enter-from {\n opacity: 0;\n transform: translateX(1.25rem);\n}\n\n.tour-forward-leave-to {\n opacity: 0;\n transform: translateX(-0.75rem);\n}\n\n.tour-backward-enter-from {\n opacity: 0;\n transform: translateX(-1.25rem);\n}\n\n.tour-backward-leave-to {\n opacity: 0;\n transform: translateX(0.75rem);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .tour-forward-enter-from,\n .tour-forward-leave-to,\n .tour-backward-enter-from,\n .tour-backward-leave-to {\n transform: none;\n }\n}\n</style>\n","import { readonly, ref } from 'vue'\n\n/** Which way the screens slide during a tab change. */\nexport type SlideDirection = 'forward' | 'backward' | 'none'\n\n/**\n * Which way a tabbed app is moving.\n *\n * A phone app slides sideways between its tabs, and the direction has to come\n * from somewhere: going from the second tab to the fourth is forward, the\n * other way is back, and arriving from nowhere is neither. That is index\n * arithmetic over the tab order, and it was written twice, identically, in the\n * two phone apps this kit came from — thirty-four lines each, byte for byte\n * the same.\n *\n * Generic over the tab key, so the app keeps its own union and the kit never\n * learns what a tab is called.\n *\n * @example\n * ```ts\n * // shared/lib/tabs.ts\n * export const tabs = createTabTransition(['today', 'week', 'year', 'profile'] as const)\n *\n * // the router guard\n * router.afterEach((to, from) => tabs.resolve(to.meta.tab, from.meta.tab))\n *\n * // App.vue\n * const name = computed(() =>\n * tabs.direction.value === 'none' ? '' : `slide-${tabs.direction.value}`,\n * )\n * ```\n *\n * The `slide-forward-*` and `slide-backward-*` classes those names refer to\n * ship in `rei-kit/shell/mobile.css`.\n */\nexport function createTabTransition<K extends string>(order: readonly K[]) {\n const direction = ref<SlideDirection>('none')\n let override: SlideDirection | null = null\n\n return {\n /** Direction of the current tab change. Read by the route transition. */\n direction: readonly(direction),\n\n /**\n * Resolves the direction for a navigation. Call once per route change.\n *\n * @param to - Tab being entered, if the route has one.\n * @param from - Tab being left, if the route had one.\n */\n resolve(to: K | undefined, from: K | undefined): void {\n if (override) {\n direction.value = override\n override = null\n\n return\n }\n\n if (!to || !from || to === from) {\n direction.value = 'none'\n\n return\n }\n\n direction.value = order.indexOf(to) > order.indexOf(from) ? 'forward' : 'backward'\n },\n\n /**\n * Forces the next navigation's direction, whatever the indices say.\n *\n * For the navigations that are not a tab change at heart: going back from\n * a detail screen, or being sent to sign-in. Without it, leaving a detail\n * page under the fourth tab for the first tab slides backward, which is\n * right, and arriving there slides forward, which is not.\n */\n force(next: SlideDirection): void {\n override = next\n },\n }\n}\n","import { watch } from 'vue'\nimport type { Ref } from 'vue'\n\nimport { isThemePreference, useTheme } from '../composables/use-theme'\n\n/**\n * Adopts the theme stored on the account, once, as soon as it arrives.\n *\n * Two things make this worth a component rather than four lines at a call\n * site, and both are about *once*.\n *\n * It has to run at the app root rather than on the settings screen, or a user\n * on a fresh device keeps the system theme until they happen to open Profile.\n * And it has to run once and never again, or a later refetch of the profile\n * undoes a choice the user has just made locally — the theme flips back under\n * them a second after they set it, which reads as the app fighting them.\n *\n * The source is a ref rather than a query, so the kit never learns what a\n * profile is or where it came from.\n *\n * @example\n * ```ts\n * const { data: profile } = useProfile()\n * useThemeSync(computed(() => profile.value?.theme))\n * ```\n */\nexport function useThemeSync(stored: Ref<string | null | undefined>): void {\n const theme = useTheme()\n\n let adopted = false\n\n watch(\n stored,\n (next) => {\n if (adopted || next === null || next === undefined) return\n\n adopted = true\n\n if (isThemePreference(next) && next !== theme.value) {\n theme.value = next\n }\n },\n { immediate: true },\n )\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"app.js","names":["$slots","$emit"],"sources":["../src/app/AuthForm.vue","../src/app/AuthForm.vue","../src/app/AuthShell.vue","../src/app/AuthShell.vue","../src/app/LocaleSheet.vue","../src/app/LocaleSheet.vue","../src/app/TourShell.vue","../src/app/TourShell.vue","../src/app/field-errors.ts","../src/app/use-tab-transition.ts","../src/app/use-theme-sync.ts"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed, reactive, ref, watch } from 'vue'\n\nimport type { AuthFormLabels, AuthFormValues } from './auth-form'\n\nimport BaseAlert from '../components/BaseAlert.vue'\nimport BaseButton from '../components/BaseButton.vue'\nimport BaseCheckbox from '../components/BaseCheckbox.vue'\nimport BaseInput from '../components/BaseInput.vue'\nimport GoogleButton from '../components/GoogleButton.vue'\n\n/**\n * The sign-in and sign-up forms, which are one component.\n *\n * All three consumers had written this, and two of them had written it twice —\n * a login view and a signup view differing by one input and an autocomplete\n * hint. Two components that differ by one field drift apart: the kit's copies\n * had already diverged on the error colour, the placeholder and whether the\n * submit button changed its wording while busy.\n *\n * Keeping the two modes together also means moving between the routes replaces\n * a heading and adds an input, rather than tearing down a form and building\n * another one over the top of it.\n *\n * ## What it does not do\n *\n * It does not sign anybody in. It emits `submit` with the values and takes\n * `busy` and `error` back, because the store, the redirect and the wording of\n * a failure are the app's, and a form that reached for them would need to know\n * what the app is about. Validation is a function the caller passes for the\n * same reason: the password minimum is a product decision, and the kit has no\n * opinion on whether it is eight characters or ten.\n */\nconst {\n mode,\n labels,\n busy = false,\n error = '',\n validate,\n} = defineProps<{\n mode: 'signIn' | 'signUp'\n /**\n * Every string on screen. No defaults, in any language: a label the kit\n * invented would ship English into an app that has none, and it would do\n * it silently.\n */\n labels: AuthFormLabels\n /** Disables the controls and spins the submit button. */\n busy?: boolean | undefined\n /**\n * A failure to show above the submit button, already translated.\n *\n * A sentence rather than a key, because the kit cannot translate and\n * `toAuthMessageKey` hands the caller a key precisely so the caller can.\n */\n error?: string | undefined\n /**\n * Returns one message per invalid field, or null when the values are good.\n *\n * Omitted, the form submits whatever is typed and lets the server decide —\n * which is a legitimate choice, not a broken one. `fieldErrors` turns a Zod\n * schema into this in one line.\n */\n validate?: ((values: AuthFormValues) => Record<string, string> | null) | undefined\n}>()\n\nconst emit = defineEmits<{\n submit: [values: AuthFormValues]\n google: []\n}>()\n\ndefineSlots<{\n /** Above everything — a heading and a line under it. */\n header?: () => unknown\n /** Replaces the Google button entirely, for a different provider or several. */\n oauth?: () => unknown\n /** Under the form — the link to the other mode, terms, anything. */\n foot?: () => unknown\n}>()\n\nconst remember = defineModel<boolean>('remember', { default: true })\n\n/**\n * Remember-me belongs on sign-in, and only where the app has words for it.\n *\n * A brand-new account has nothing to remember, so offering the choice on\n * sign-up is a question with one answer. Keyed off the label rather than a\n * `showRemember` flag because Vue casts an absent Boolean prop to `false`,\n * which makes \"not passed\" and \"passed false\" the same value — a flag that\n * cannot express its own default is worse than no flag.\n */\nconst wantsRemember = computed(() => mode === 'signIn' && Boolean(labels.rememberMe))\n\nconst values = reactive<AuthFormValues>({ email: '', password: '', confirmPassword: '' })\nconst errors = ref<Record<string, string>>({})\n\n// Switching between the two modes keeps what has been typed — the email is the\n// same email — but a stale error about the other form is a message about a\n// field that is no longer on screen.\nwatch(\n () => mode,\n () => {\n errors.value = {}\n },\n)\n\nfunction submit() {\n // Sign-in has no confirm field, so whatever a mode switch left in it is not\n // part of this submission and must not be validated as if it were.\n const payload: AuthFormValues = {\n email: values.email,\n password: values.password,\n confirmPassword: mode === 'signUp' ? values.confirmPassword : '',\n }\n\n const found = validate?.(payload) ?? null\n errors.value = found ?? {}\n if (found) return\n\n emit('submit', payload)\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-5\">\n <slot name=\"header\" />\n\n <!-- OAuth first. Most people will use it, and burying it under a form they\n are not going to fill in is a form they have to look past. -->\n <slot name=\"oauth\">\n <GoogleButton\n v-if=\"labels.google\"\n :label=\"labels.google\"\n :disabled=\"busy\"\n @click=\"emit('google')\"\n />\n </slot>\n\n <div v-if=\"labels.or\" class=\"flex items-center gap-3\">\n <span class=\"bg-hair h-px flex-1\" />\n <span class=\"text-ink-soft text-xs\">{{ labels.or }}</span>\n <span class=\"bg-hair h-px flex-1\" />\n </div>\n\n <form novalidate class=\"flex flex-col gap-4\" @submit.prevent=\"submit\">\n <BaseInput\n v-model=\"values.email\"\n type=\"email\"\n :label=\"labels.email\"\n :error=\"errors['email']\"\n :placeholder=\"labels.emailPlaceholder\"\n autocomplete=\"email\"\n />\n\n <BaseInput\n v-model=\"values.password\"\n type=\"password\"\n :label=\"labels.password\"\n :error=\"errors['password']\"\n :hint=\"mode === 'signUp' ? labels.passwordHint : undefined\"\n :autocomplete=\"mode === 'signUp' ? 'new-password' : 'current-password'\"\n />\n\n <BaseInput\n v-if=\"mode === 'signUp'\"\n v-model=\"values.confirmPassword\"\n type=\"password\"\n :label=\"labels.confirmPassword\"\n :error=\"errors['confirmPassword']\"\n autocomplete=\"new-password\"\n />\n\n <BaseCheckbox\n v-if=\"wantsRemember\"\n v-model=\"remember\"\n size=\"sm\"\n :label=\"labels.rememberMe ?? ''\"\n />\n\n <!-- `assertive`, because it is the answer to something the reader just\n did and they cannot carry on without it. -->\n <BaseAlert v-if=\"error\" tone=\"danger\" assertive>{{ error }}</BaseAlert>\n\n <BaseButton type=\"submit\" class=\"w-full\" :loading=\"busy\">\n {{ (busy && labels.submitBusy) || labels.submit }}\n </BaseButton>\n </form>\n\n <slot name=\"foot\" />\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, reactive, ref, watch } from 'vue'\n\nimport type { AuthFormLabels, AuthFormValues } from './auth-form'\n\nimport BaseAlert from '../components/BaseAlert.vue'\nimport BaseButton from '../components/BaseButton.vue'\nimport BaseCheckbox from '../components/BaseCheckbox.vue'\nimport BaseInput from '../components/BaseInput.vue'\nimport GoogleButton from '../components/GoogleButton.vue'\n\n/**\n * The sign-in and sign-up forms, which are one component.\n *\n * All three consumers had written this, and two of them had written it twice —\n * a login view and a signup view differing by one input and an autocomplete\n * hint. Two components that differ by one field drift apart: the kit's copies\n * had already diverged on the error colour, the placeholder and whether the\n * submit button changed its wording while busy.\n *\n * Keeping the two modes together also means moving between the routes replaces\n * a heading and adds an input, rather than tearing down a form and building\n * another one over the top of it.\n *\n * ## What it does not do\n *\n * It does not sign anybody in. It emits `submit` with the values and takes\n * `busy` and `error` back, because the store, the redirect and the wording of\n * a failure are the app's, and a form that reached for them would need to know\n * what the app is about. Validation is a function the caller passes for the\n * same reason: the password minimum is a product decision, and the kit has no\n * opinion on whether it is eight characters or ten.\n */\nconst {\n mode,\n labels,\n busy = false,\n error = '',\n validate,\n} = defineProps<{\n mode: 'signIn' | 'signUp'\n /**\n * Every string on screen. No defaults, in any language: a label the kit\n * invented would ship English into an app that has none, and it would do\n * it silently.\n */\n labels: AuthFormLabels\n /** Disables the controls and spins the submit button. */\n busy?: boolean | undefined\n /**\n * A failure to show above the submit button, already translated.\n *\n * A sentence rather than a key, because the kit cannot translate and\n * `toAuthMessageKey` hands the caller a key precisely so the caller can.\n */\n error?: string | undefined\n /**\n * Returns one message per invalid field, or null when the values are good.\n *\n * Omitted, the form submits whatever is typed and lets the server decide —\n * which is a legitimate choice, not a broken one. `fieldErrors` turns a Zod\n * schema into this in one line.\n */\n validate?: ((values: AuthFormValues) => Record<string, string> | null) | undefined\n}>()\n\nconst emit = defineEmits<{\n submit: [values: AuthFormValues]\n google: []\n}>()\n\ndefineSlots<{\n /** Above everything — a heading and a line under it. */\n header?: () => unknown\n /** Replaces the Google button entirely, for a different provider or several. */\n oauth?: () => unknown\n /** Under the form — the link to the other mode, terms, anything. */\n foot?: () => unknown\n}>()\n\nconst remember = defineModel<boolean>('remember', { default: true })\n\n/**\n * Remember-me belongs on sign-in, and only where the app has words for it.\n *\n * A brand-new account has nothing to remember, so offering the choice on\n * sign-up is a question with one answer. Keyed off the label rather than a\n * `showRemember` flag because Vue casts an absent Boolean prop to `false`,\n * which makes \"not passed\" and \"passed false\" the same value — a flag that\n * cannot express its own default is worse than no flag.\n */\nconst wantsRemember = computed(() => mode === 'signIn' && Boolean(labels.rememberMe))\n\nconst values = reactive<AuthFormValues>({ email: '', password: '', confirmPassword: '' })\nconst errors = ref<Record<string, string>>({})\n\n// Switching between the two modes keeps what has been typed — the email is the\n// same email — but a stale error about the other form is a message about a\n// field that is no longer on screen.\nwatch(\n () => mode,\n () => {\n errors.value = {}\n },\n)\n\nfunction submit() {\n // Sign-in has no confirm field, so whatever a mode switch left in it is not\n // part of this submission and must not be validated as if it were.\n const payload: AuthFormValues = {\n email: values.email,\n password: values.password,\n confirmPassword: mode === 'signUp' ? values.confirmPassword : '',\n }\n\n const found = validate?.(payload) ?? null\n errors.value = found ?? {}\n if (found) return\n\n emit('submit', payload)\n}\n</script>\n\n<template>\n <div class=\"flex flex-col gap-5\">\n <slot name=\"header\" />\n\n <!-- OAuth first. Most people will use it, and burying it under a form they\n are not going to fill in is a form they have to look past. -->\n <slot name=\"oauth\">\n <GoogleButton\n v-if=\"labels.google\"\n :label=\"labels.google\"\n :disabled=\"busy\"\n @click=\"emit('google')\"\n />\n </slot>\n\n <div v-if=\"labels.or\" class=\"flex items-center gap-3\">\n <span class=\"bg-hair h-px flex-1\" />\n <span class=\"text-ink-soft text-xs\">{{ labels.or }}</span>\n <span class=\"bg-hair h-px flex-1\" />\n </div>\n\n <form novalidate class=\"flex flex-col gap-4\" @submit.prevent=\"submit\">\n <BaseInput\n v-model=\"values.email\"\n type=\"email\"\n :label=\"labels.email\"\n :error=\"errors['email']\"\n :placeholder=\"labels.emailPlaceholder\"\n autocomplete=\"email\"\n />\n\n <BaseInput\n v-model=\"values.password\"\n type=\"password\"\n :label=\"labels.password\"\n :error=\"errors['password']\"\n :hint=\"mode === 'signUp' ? labels.passwordHint : undefined\"\n :autocomplete=\"mode === 'signUp' ? 'new-password' : 'current-password'\"\n />\n\n <BaseInput\n v-if=\"mode === 'signUp'\"\n v-model=\"values.confirmPassword\"\n type=\"password\"\n :label=\"labels.confirmPassword\"\n :error=\"errors['confirmPassword']\"\n autocomplete=\"new-password\"\n />\n\n <BaseCheckbox\n v-if=\"wantsRemember\"\n v-model=\"remember\"\n size=\"sm\"\n :label=\"labels.rememberMe ?? ''\"\n />\n\n <!-- `assertive`, because it is the answer to something the reader just\n did and they cannot carry on without it. -->\n <BaseAlert v-if=\"error\" tone=\"danger\" assertive>{{ error }}</BaseAlert>\n\n <BaseButton type=\"submit\" class=\"w-full\" :loading=\"busy\">\n {{ (busy && labels.submitBusy) || labels.submit }}\n </BaseButton>\n </form>\n\n <slot name=\"foot\" />\n </div>\n</template>\n","<script setup lang=\"ts\">\n/**\n * The frame every sign-in screen sits in.\n *\n * A brand mark, a narrow column, and the language links pinned to the bottom.\n * The two phone apps had this file character for character — thirty-seven\n * lines, no difference at all — and the only thing either would want to change\n * is what goes in the slots.\n *\n * The language links matter more than they look. Sign-in is the first screen a\n * new user sees and Settings is behind it, so without a way to switch here,\n * somebody who does not read the browser's language cannot get to one.\n */\ndefineSlots<{\n /** The brand mark. */\n brand?: () => unknown\n /** The form. */\n default: () => unknown\n /** The language links, or anything else that belongs at the foot. */\n foot?: () => unknown\n}>()\n</script>\n\n<template>\n <div\n class=\"flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10\"\n >\n <slot name=\"brand\" />\n\n <main class=\"w-full max-w-[22rem]\">\n <slot />\n </main>\n\n <div v-if=\"$slots.foot\" class=\"mt-auto\">\n <slot name=\"foot\" />\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\n/**\n * The frame every sign-in screen sits in.\n *\n * A brand mark, a narrow column, and the language links pinned to the bottom.\n * The two phone apps had this file character for character — thirty-seven\n * lines, no difference at all — and the only thing either would want to change\n * is what goes in the slots.\n *\n * The language links matter more than they look. Sign-in is the first screen a\n * new user sees and Settings is behind it, so without a way to switch here,\n * somebody who does not read the browser's language cannot get to one.\n */\ndefineSlots<{\n /** The brand mark. */\n brand?: () => unknown\n /** The form. */\n default: () => unknown\n /** The language links, or anything else that belongs at the foot. */\n foot?: () => unknown\n}>()\n</script>\n\n<template>\n <div\n class=\"flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10\"\n >\n <slot name=\"brand\" />\n\n <main class=\"w-full max-w-[22rem]\">\n <slot />\n </main>\n\n <div v-if=\"$slots.foot\" class=\"mt-auto\">\n <slot name=\"foot\" />\n </div>\n </div>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { Check, Languages } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport BaseSheet from '../components/BaseSheet.vue'\nimport SettingsRow from '../components/SettingsRow.vue'\n\n/**\n * Choosing the interface language, from a settings row.\n *\n * A sheet rather than a segmented control: past four options a row of pills\n * stops being readable, and the list of languages only grows.\n *\n * Both phone apps had this, 96% identical. What differed was one colour class.\n *\n * The labels are the caller's, and they should be **endonyms** — a language is\n * always listed in its own language, so someone who cannot read the current\n * interface can still find theirs. The kit cannot know them.\n */\nconst {\n label,\n hint = '',\n systemLabel,\n options,\n closeLabel = '',\n} = defineProps<{\n /** The settings row's label, and the sheet's title. */\n label: string\n hint?: string | undefined\n /** What \"follow the device\" is called. Its value is `system`. */\n systemLabel: string\n /** In the order they should be listed. Labels are endonyms. */\n options: readonly { value: string; label: string }[]\n closeLabel?: string | undefined\n}>()\n\nconst model = defineModel<string>({ required: true })\n\nconst open = ref(false)\n\nconst rows = computed(() => [{ value: 'system', label: systemLabel }, ...options])\n\nconst current = computed(\n () => rows.value.find((row) => row.value === model.value)?.label ?? systemLabel,\n)\n\nfunction select(value: string) {\n model.value = value\n open.value = false\n}\n</script>\n\n<template>\n <SettingsRow\n :label=\"label\"\n :description=\"hint\"\n :icon=\"Languages\"\n interactive\n @click=\"open = true\"\n >\n <span class=\"text-ink-soft text-sm\">{{ current }}</span>\n </SettingsRow>\n\n <BaseSheet v-model=\"open\" :title=\"label\" :subtitle=\"hint\" :close-label=\"closeLabel\">\n <ul class=\"flex flex-col\">\n <li v-for=\"row in rows\" :key=\"row.value\">\n <BaseButton\n variant=\"row\"\n class=\"rounded-xl\"\n :pressed=\"model === row.value\"\n @click=\"select(row.value)\"\n >\n <span class=\"flex-1 text-base\">{{ row.label }}</span>\n <Check\n v-if=\"model === row.value\"\n class=\"text-primary size-5 shrink-0\"\n aria-hidden=\"true\"\n />\n </BaseButton>\n </li>\n </ul>\n </BaseSheet>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport { Check, Languages } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport BaseSheet from '../components/BaseSheet.vue'\nimport SettingsRow from '../components/SettingsRow.vue'\n\n/**\n * Choosing the interface language, from a settings row.\n *\n * A sheet rather than a segmented control: past four options a row of pills\n * stops being readable, and the list of languages only grows.\n *\n * Both phone apps had this, 96% identical. What differed was one colour class.\n *\n * The labels are the caller's, and they should be **endonyms** — a language is\n * always listed in its own language, so someone who cannot read the current\n * interface can still find theirs. The kit cannot know them.\n */\nconst {\n label,\n hint = '',\n systemLabel,\n options,\n closeLabel = '',\n} = defineProps<{\n /** The settings row's label, and the sheet's title. */\n label: string\n hint?: string | undefined\n /** What \"follow the device\" is called. Its value is `system`. */\n systemLabel: string\n /** In the order they should be listed. Labels are endonyms. */\n options: readonly { value: string; label: string }[]\n closeLabel?: string | undefined\n}>()\n\nconst model = defineModel<string>({ required: true })\n\nconst open = ref(false)\n\nconst rows = computed(() => [{ value: 'system', label: systemLabel }, ...options])\n\nconst current = computed(\n () => rows.value.find((row) => row.value === model.value)?.label ?? systemLabel,\n)\n\nfunction select(value: string) {\n model.value = value\n open.value = false\n}\n</script>\n\n<template>\n <SettingsRow\n :label=\"label\"\n :description=\"hint\"\n :icon=\"Languages\"\n interactive\n @click=\"open = true\"\n >\n <span class=\"text-ink-soft text-sm\">{{ current }}</span>\n </SettingsRow>\n\n <BaseSheet v-model=\"open\" :title=\"label\" :subtitle=\"hint\" :close-label=\"closeLabel\">\n <ul class=\"flex flex-col\">\n <li v-for=\"row in rows\" :key=\"row.value\">\n <BaseButton\n variant=\"row\"\n class=\"rounded-xl\"\n :pressed=\"model === row.value\"\n @click=\"select(row.value)\"\n >\n <span class=\"flex-1 text-base\">{{ row.label }}</span>\n <Check\n v-if=\"model === row.value\"\n class=\"text-primary size-5 shrink-0\"\n aria-hidden=\"true\"\n />\n </BaseButton>\n </li>\n </ul>\n </BaseSheet>\n</template>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-300\"\n :class=\"\n position - 1 <= index ? 'bg-primary' : 'bg-hair group-hover:bg-ink-soft/40'\n \"\n />\n </BaseButton>\n </div>\n\n <div class=\"flex gap-2\">\n <BaseButton\n v-if=\"index > 0\"\n variant=\"ghost\"\n class=\"shrink-0 px-5\"\n @click=\"$emit('back')\"\n >\n {{ backLabel }}\n </BaseButton>\n\n <BaseButton class=\"flex-1\" @click=\"$emit('next')\">\n {{ isLast ? lastLabel : nextLabel }}\n </BaseButton>\n </div>\n </footer>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n/* The dialog's own fade. The *slide* transition is not here: a scoped rule\n cannot reach slot content, which belongs to the caller's scope, so\n `.tour-forward-*` and `.tour-backward-*` ship unscoped in\n `rei-kit/shell/mobile.css` where the caller's slide can actually see them. */\n.tour-enter-active,\n.tour-leave-active {\n transition: opacity 220ms ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-300\"\n :class=\"\n position - 1 <= index ? 'bg-primary' : 'bg-hair group-hover:bg-ink-soft/40'\n \"\n />\n </BaseButton>\n </div>\n\n <div class=\"flex gap-2\">\n <BaseButton\n v-if=\"index > 0\"\n variant=\"ghost\"\n class=\"shrink-0 px-5\"\n @click=\"$emit('back')\"\n >\n {{ backLabel }}\n </BaseButton>\n\n <BaseButton class=\"flex-1\" @click=\"$emit('next')\">\n {{ isLast ? lastLabel : nextLabel }}\n </BaseButton>\n </div>\n </footer>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n/* The dialog's own fade. The *slide* transition is not here: a scoped rule\n cannot reach slot content, which belongs to the caller's scope, so\n `.tour-forward-*` and `.tour-backward-*` ship unscoped in\n `rei-kit/shell/mobile.css` where the caller's slide can actually see them. */\n.tour-enter-active,\n.tour-leave-active {\n transition: opacity 220ms ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n</style>\n","/**\n * Anything that validates like a Zod schema.\n *\n * Structural rather than `z.ZodType`, and that is the point: the kit never\n * imports Zod, so an app that validates with something else — or with nothing —\n * does not download a validator to use a form. Zod satisfies this shape as it\n * is, so a consumer passes `signupSchema()` and nothing else changes.\n */\nexport type SafeParsable = {\n safeParse: (values: unknown) =>\n | { success: true }\n | {\n success: false\n error: { issues: readonly { path: readonly PropertyKey[]; message: string }[] }\n }\n}\n\n/**\n * Field errors keyed by field name, or null when the values are valid.\n *\n * Zod's own error shape is a tree; a form needs one message per input, and\n * flattening it here keeps that reshaping out of every component that has a\n * form in it. The first issue per field wins — a field shows one message, and\n * the first is the one the reader can act on.\n *\n * Null rather than an empty object, so `if (errors)` is the guard and a caller\n * cannot accidentally treat \"no errors\" as a failure.\n *\n * @example\n * ```ts\n * const found = fieldErrors(signupSchema(), values)\n * if (found) return (errors.value = found)\n * ```\n */\nexport function fieldErrors(schema: SafeParsable, values: unknown): Record<string, string> | null {\n const result = schema.safeParse(values)\n if (result.success) return null\n\n const errors: Record<string, string> = {}\n for (const issue of result.error.issues) {\n const field = String(issue.path[0] ?? '')\n if (field && !errors[field]) errors[field] = issue.message\n }\n\n // A schema can fail with every issue at the root -- a `refine` with no\n // `path`. Reporting \"valid\" there would let a broken submission through, so\n // the object is empty but present, and the caller still stops.\n return errors\n}\n","import { readonly, ref } from 'vue'\n\n/** Which way the screens slide during a tab change. */\nexport type SlideDirection = 'forward' | 'backward' | 'none'\n\n/**\n * Which way a tabbed app is moving.\n *\n * A phone app slides sideways between its tabs, and the direction has to come\n * from somewhere: going from the second tab to the fourth is forward, the\n * other way is back, and arriving from nowhere is neither. That is index\n * arithmetic over the tab order, and it was written twice, identically, in the\n * two phone apps this kit came from — thirty-four lines each, byte for byte\n * the same.\n *\n * Generic over the tab key, so the app keeps its own union and the kit never\n * learns what a tab is called.\n *\n * @example\n * ```ts\n * // shared/lib/tabs.ts\n * export const tabs = createTabTransition(['today', 'week', 'year', 'profile'] as const)\n *\n * // the router guard\n * router.afterEach((to, from) => tabs.resolve(to.meta.tab, from.meta.tab))\n *\n * // App.vue\n * const name = computed(() =>\n * tabs.direction.value === 'none' ? '' : `slide-${tabs.direction.value}`,\n * )\n * ```\n *\n * The `slide-forward-*` and `slide-backward-*` classes those names refer to\n * ship in `rei-kit/shell/mobile.css`.\n */\nexport function createTabTransition<K extends string>(order: readonly K[]) {\n const direction = ref<SlideDirection>('none')\n let override: SlideDirection | null = null\n\n return {\n /** Direction of the current tab change. Read by the route transition. */\n direction: readonly(direction),\n\n /**\n * Resolves the direction for a navigation. Call once per route change.\n *\n * @param to - Tab being entered, if the route has one.\n * @param from - Tab being left, if the route had one.\n */\n resolve(to: K | undefined, from: K | undefined): void {\n if (override) {\n direction.value = override\n override = null\n\n return\n }\n\n if (!to || !from || to === from) {\n direction.value = 'none'\n\n return\n }\n\n direction.value = order.indexOf(to) > order.indexOf(from) ? 'forward' : 'backward'\n },\n\n /**\n * Forces the next navigation's direction, whatever the indices say.\n *\n * For the navigations that are not a tab change at heart: going back from\n * a detail screen, or being sent to sign-in. Without it, leaving a detail\n * page under the fourth tab for the first tab slides backward, which is\n * right, and arriving there slides forward, which is not.\n */\n force(next: SlideDirection): void {\n override = next\n },\n }\n}\n","import { watch } from 'vue'\nimport type { Ref } from 'vue'\n\nimport { isThemePreference, useTheme } from '../composables/use-theme'\n\n/**\n * Adopts the theme stored on the account, once, as soon as it arrives.\n *\n * Two things make this worth a component rather than four lines at a call\n * site, and both are about *once*.\n *\n * It has to run at the app root rather than on the settings screen, or a user\n * on a fresh device keeps the system theme until they happen to open Profile.\n * And it has to run once and never again, or a later refetch of the profile\n * undoes a choice the user has just made locally — the theme flips back under\n * them a second after they set it, which reads as the app fighting them.\n *\n * The source is a ref rather than a query, so the kit never learns what a\n * profile is or where it came from.\n *\n * @example\n * ```ts\n * const { data: profile } = useProfile()\n * useThemeSync(computed(() => profile.value?.theme))\n * ```\n */\nexport function useThemeSync(stored: Ref<string | null | undefined>): void {\n const theme = useTheme()\n\n let adopted = false\n\n watch(\n stored,\n (next) => {\n if (adopted || next === null || next === undefined) return\n\n adopted = true\n\n if (isThemePreference(next) && next !== theme.value) {\n theme.value = next\n }\n },\n { immediate: true },\n )\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
type __VLS_Props = {
|
|
2
2
|
label: string;
|
|
3
|
+
/**
|
|
4
|
+
* Greyed out and unclickable while a sign-in is already in flight.
|
|
5
|
+
*
|
|
6
|
+
* A real prop rather than a fallthrough attribute: the button needs to look
|
|
7
|
+
* disabled as well as be it, and a bare `disabled` landing on the element by
|
|
8
|
+
* itself gets the second half only.
|
|
9
|
+
*/
|
|
10
|
+
disabled?: boolean | undefined;
|
|
3
11
|
};
|
|
4
12
|
declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
5
13
|
click: () => any;
|