rei-kit 2.8.0 → 2.9.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/app.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.js","names":["$slots","$slots","$emit"],"sources":["../src/app/AuthForm.vue","../src/app/AuthForm.vue","../src/app/AuthShell.vue","../src/app/AuthShell.vue","../src/app/FabButton.vue","../src/app/FabButton.vue","../src/app/LocaleSheet.vue","../src/app/LocaleSheet.vue","../src/app/OfflineBanner.vue","../src/app/OfflineBanner.vue","../src/app/TabShell.vue","../src/app/TabShell.vue","../src/app/TourShell.vue","../src/app/TourShell.vue","../src/app/guards.ts","../src/app/query-defaults.ts","../src/app/write-report.ts","../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 { tapFeedback } from '../utils/haptics'\n\n/**\n * The one action the app is built around, reachable from every screen.\n *\n * Extended rather than a bare circle: a lone \"+\" says nothing about what it\n * adds, and this is the button the whole product is arranged around. Both phone\n * apps reached the same conclusion and wrote the same forty lines of CSS to get\n * there.\n *\n * It shares the tab bar's column rather than being anchored to the layout, so\n * it lines up with the bar's right edge at every width. Anchored to the layout\n * instead, it sat four hundred pixels away from the shell on a desktop screen —\n * which is how the shared version came to exist.\n */\ndefineProps<{\n /** The words beside the icon. */\n label: string\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n\ndefineSlots<{\n /** The icon. A `lucide-vue-next` component, usually. */\n default: () => unknown\n}>()\n\nfunction press() {\n // The haptic belongs to the press, not to what the press opens: it has to\n // fire even when the sheet it opens is still being fetched.\n tapFeedback()\n emit('click')\n}\n</script>\n\n<template>\n <div class=\"rk-fab-slot\">\n <button type=\"button\" class=\"rk-fab\" @click=\"press\">\n <span class=\"rk-fab-icon\" aria-hidden=\"true\"><slot /></span>\n <span class=\"rk-fab-label\">{{ label }}</span>\n </button>\n </div>\n</template>\n\n<style scoped>\n.rk-fab-slot {\n position: absolute;\n bottom: calc(6rem + env(safe-area-inset-bottom, 0px));\n left: 50%;\n z-index: 40;\n display: flex;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n justify-content: flex-end;\n padding: 0 1rem;\n /* The slot spans the shell so the button can line up with the tab bar; it\n must not swallow taps meant for the page underneath it. */\n pointer-events: none;\n}\n\n.rk-fab {\n pointer-events: auto;\n display: flex;\n height: 3rem;\n align-items: center;\n gap: 0.375rem;\n border-radius: 9999px;\n padding-left: 1rem;\n padding-right: 1.25rem;\n color: var(--color-on-primary);\n background: var(--color-primary);\n transition: transform var(--duration-fast);\n /* A ring in the canvas colour separates it from whatever scrolls behind; the\n depth under it is the material's, so a brutalist app gets a hard offset\n and a soft one gets a puff without this file knowing either. */\n box-shadow:\n 0 0 0 4px var(--color-canvas),\n var(--shadow-raised);\n}\n\n.rk-fab:active {\n transform: translate(var(--press-offset), var(--press-offset)) scale(var(--press-scale));\n}\n\n/* The ring is not decoration. This button was a `BaseButton variant=\"unstyled\"`\n in the app it came from, which meant it inherited a focus ring for free; a\n bare <button> here would have dropped it, and a keyboard user would have had\n no way to see where they were. */\n.rk-fab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-fab-icon {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n}\n\n.rk-fab-icon :deep(svg) {\n width: 1.25rem;\n height: 1.25rem;\n stroke-width: 2.5px;\n}\n\n.rk-fab-label {\n font-size: 0.875rem;\n font-weight: 600;\n white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-fab {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { tapFeedback } from '../utils/haptics'\n\n/**\n * The one action the app is built around, reachable from every screen.\n *\n * Extended rather than a bare circle: a lone \"+\" says nothing about what it\n * adds, and this is the button the whole product is arranged around. Both phone\n * apps reached the same conclusion and wrote the same forty lines of CSS to get\n * there.\n *\n * It shares the tab bar's column rather than being anchored to the layout, so\n * it lines up with the bar's right edge at every width. Anchored to the layout\n * instead, it sat four hundred pixels away from the shell on a desktop screen —\n * which is how the shared version came to exist.\n */\ndefineProps<{\n /** The words beside the icon. */\n label: string\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n\ndefineSlots<{\n /** The icon. A `lucide-vue-next` component, usually. */\n default: () => unknown\n}>()\n\nfunction press() {\n // The haptic belongs to the press, not to what the press opens: it has to\n // fire even when the sheet it opens is still being fetched.\n tapFeedback()\n emit('click')\n}\n</script>\n\n<template>\n <div class=\"rk-fab-slot\">\n <button type=\"button\" class=\"rk-fab\" @click=\"press\">\n <span class=\"rk-fab-icon\" aria-hidden=\"true\"><slot /></span>\n <span class=\"rk-fab-label\">{{ label }}</span>\n </button>\n </div>\n</template>\n\n<style scoped>\n.rk-fab-slot {\n position: absolute;\n bottom: calc(6rem + env(safe-area-inset-bottom, 0px));\n left: 50%;\n z-index: 40;\n display: flex;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n justify-content: flex-end;\n padding: 0 1rem;\n /* The slot spans the shell so the button can line up with the tab bar; it\n must not swallow taps meant for the page underneath it. */\n pointer-events: none;\n}\n\n.rk-fab {\n pointer-events: auto;\n display: flex;\n height: 3rem;\n align-items: center;\n gap: 0.375rem;\n border-radius: 9999px;\n padding-left: 1rem;\n padding-right: 1.25rem;\n color: var(--color-on-primary);\n background: var(--color-primary);\n transition: transform var(--duration-fast);\n /* A ring in the canvas colour separates it from whatever scrolls behind; the\n depth under it is the material's, so a brutalist app gets a hard offset\n and a soft one gets a puff without this file knowing either. */\n box-shadow:\n 0 0 0 4px var(--color-canvas),\n var(--shadow-raised);\n}\n\n.rk-fab:active {\n transform: translate(var(--press-offset), var(--press-offset)) scale(var(--press-scale));\n}\n\n/* The ring is not decoration. This button was a `BaseButton variant=\"unstyled\"`\n in the app it came from, which meant it inherited a focus ring for free; a\n bare <button> here would have dropped it, and a keyboard user would have had\n no way to see where they were. */\n.rk-fab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-fab-icon {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n}\n\n.rk-fab-icon :deep(svg) {\n width: 1.25rem;\n height: 1.25rem;\n stroke-width: 2.5px;\n}\n\n.rk-fab-label {\n font-size: 0.875rem;\n font-weight: 600;\n white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-fab {\n transition: none;\n }\n}\n</style>\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 { useOnline } from '../composables/use-online'\n\n/**\n * A floating note that the connection has gone.\n *\n * Floating, not in flow: connectivity flickers in lifts and tunnels, and a\n * banner that reflows the page on every flicker is worse than the outage it is\n * reporting. It sits under the top bar and above the content, so appearing and\n * disappearing costs no layout at all.\n *\n * `role=\"status\"`, not `alert`: losing signal is a condition to know about, not\n * something to interrupt a reader mid-sentence for.\n *\n * Both phone apps had this, identically, down to the 200ms and the half-rem\n * travel.\n */\nconst { label } = defineProps<{\n /** The message. Required, because the kit has no language of its own. */\n label: string\n}>()\n\nconst isOnline = useOnline()\n</script>\n\n<template>\n <Transition name=\"rk-offline\">\n <p v-if=\"!isOnline\" role=\"status\" class=\"rk-offline-banner\">{{ label }}</p>\n </Transition>\n</template>\n\n<style scoped>\n.rk-offline-banner {\n position: absolute;\n top: 4.75rem;\n left: 50%;\n z-index: 30;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n border-radius: 9999px;\n padding: 0.5rem 1rem;\n text-align: center;\n font-size: 0.75rem;\n font-weight: 500;\n color: var(--color-ink);\n background: color-mix(in srgb, var(--color-warning) 90%, transparent);\n box-shadow: var(--shadow-raised);\n backdrop-filter: blur(4px);\n}\n\n.rk-offline-enter-active,\n.rk-offline-leave-active {\n transition:\n opacity var(--duration-base) ease,\n transform var(--duration-base) var(--ease-sheet);\n}\n\n.rk-offline-enter-from,\n.rk-offline-leave-to {\n opacity: 0;\n transform: translate(-50%, -0.5rem);\n}\n\n/* The travel is the part that causes trouble; the fade is not. */\n@media (prefers-reduced-motion: reduce) {\n .rk-offline-enter-from,\n .rk-offline-leave-to {\n transform: translate(-50%, 0);\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useOnline } from '../composables/use-online'\n\n/**\n * A floating note that the connection has gone.\n *\n * Floating, not in flow: connectivity flickers in lifts and tunnels, and a\n * banner that reflows the page on every flicker is worse than the outage it is\n * reporting. It sits under the top bar and above the content, so appearing and\n * disappearing costs no layout at all.\n *\n * `role=\"status\"`, not `alert`: losing signal is a condition to know about, not\n * something to interrupt a reader mid-sentence for.\n *\n * Both phone apps had this, identically, down to the 200ms and the half-rem\n * travel.\n */\nconst { label } = defineProps<{\n /** The message. Required, because the kit has no language of its own. */\n label: string\n}>()\n\nconst isOnline = useOnline()\n</script>\n\n<template>\n <Transition name=\"rk-offline\">\n <p v-if=\"!isOnline\" role=\"status\" class=\"rk-offline-banner\">{{ label }}</p>\n </Transition>\n</template>\n\n<style scoped>\n.rk-offline-banner {\n position: absolute;\n top: 4.75rem;\n left: 50%;\n z-index: 30;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n border-radius: 9999px;\n padding: 0.5rem 1rem;\n text-align: center;\n font-size: 0.75rem;\n font-weight: 500;\n color: var(--color-ink);\n background: color-mix(in srgb, var(--color-warning) 90%, transparent);\n box-shadow: var(--shadow-raised);\n backdrop-filter: blur(4px);\n}\n\n.rk-offline-enter-active,\n.rk-offline-leave-active {\n transition:\n opacity var(--duration-base) ease,\n transform var(--duration-base) var(--ease-sheet);\n}\n\n.rk-offline-enter-from,\n.rk-offline-leave-to {\n opacity: 0;\n transform: translate(-50%, -0.5rem);\n}\n\n/* The travel is the part that causes trouble; the fade is not. */\n@media (prefers-reduced-motion: reduce) {\n .rk-offline-enter-from,\n .rk-offline-leave-to {\n transform: translate(-50%, 0);\n }\n}\n</style>\n","<script setup lang=\"ts\">\n/**\n * The phone frame the whole app sits inside.\n *\n * On a phone this is invisible — the shell fills the screen and there is\n * nothing around it. On a desktop it is the bordered, rounded card in the\n * middle of a textured field, which is what makes a phone-shaped app look\n * deliberate on a wide screen rather than stretched or abandoned.\n *\n * Both phone apps had this at 120 lines, differing in the product name, two\n * colour variables and one hover colour. None of those is a reason to own a\n * frame, so the colours are custom properties and the name is a slot.\n *\n * ## What stays in the app\n *\n * Which layout a route uses, and the `RouterView` inside it. That is the one\n * part that genuinely differs — an app with no auth screens has no layout\n * switch — and it is also the part that must stay in the app so a page that\n * throws does not take the tab bar with it.\n *\n * @example\n * ```vue\n * <TabShell>\n * <template #aside><AppCredits /></template>\n * <template #chrome><UpdatePrompt /></template>\n *\n * <component :is=\"layoutComponent\">\n * <RouterView v-slot=\"{ Component, route }\">\n * <Transition :name=\"tabTransition.name.value\">\n * <component :is=\"Component\" :key=\"route.path\" :class=\"pageClass\" />\n * </Transition>\n * </RouterView>\n * </component>\n * </TabShell>\n * ```\n */\ndefineSlots<{\n /**\n * Beside the shell, on a desktop only. Credits, a build number, a link home.\n *\n * Hidden below `md` rather than left out: on a phone the shell covers the\n * whole viewport, so anything here would be behind it.\n */\n aside?: () => unknown\n /**\n * Inside the shell and above everything in it — an update prompt, typically.\n *\n * Above the tab bar on purpose, and outside the layout so it also appears on\n * the sign-in screens, which is where somebody who has been away the longest\n * arrives.\n */\n chrome?: () => unknown\n /** The layout and the page. */\n default: () => unknown\n}>()\n</script>\n\n<template>\n <div class=\"rk-screen\">\n <aside v-if=\"$slots.aside\" class=\"rk-screen-aside\">\n <slot name=\"aside\" />\n </aside>\n\n <div class=\"shell-frame rk-shell\">\n <slot name=\"chrome\" />\n <slot />\n </div>\n </div>\n</template>\n\n<style scoped>\n/* A barely-there diamond lattice, so the area around the shell is not a flat\n slab. Both layers are theme colours at very low alpha, so it reads as texture\n rather than decoration and inverts with the theme for free.\n\n The colour is a custom property because it is the one thing each app wants\n different: set `--rk-lattice` on any ancestor. It defaults to the ink colour,\n which is legible against every theme the tokens can produce. */\n.rk-screen {\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: var(--color-canvas);\n /* The lattice sits on top of whatever the material puts behind the page —\n glass needs colour to blur, and it is this layer that supplies it. */\n background-image:\n repeating-linear-gradient(\n 45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n repeating-linear-gradient(\n -45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n var(--canvas-backdrop);\n}\n\n.rk-screen-aside {\n position: absolute;\n bottom: 1.5rem;\n left: 1.5rem;\n display: none;\n flex-direction: column;\n gap: 0.25rem;\n font-size: 11px;\n color: var(--color-ink-soft);\n}\n\n@media (min-width: 48rem) {\n .rk-screen-aside {\n display: flex;\n }\n}\n\n/* `shell-frame` from `rei-kit/shell/mobile.css` supplies the geometry — the\n 430px column and the desktop height. This adds only the surface. */\n.rk-shell {\n position: relative;\n margin: auto;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background: color-mix(in oklab, var(--color-surface) var(--surface-opacity), transparent);\n backdrop-filter: var(--surface-backdrop);\n -webkit-backdrop-filter: var(--surface-backdrop);\n}\n\n@media (min-width: 48rem) {\n .rk-shell {\n border: var(--surface-border-width) solid var(--surface-border-color);\n border-radius: var(--radius-shell);\n box-shadow: var(--shadow-overlay);\n }\n}\n</style>\n","<script setup lang=\"ts\">\n/**\n * The phone frame the whole app sits inside.\n *\n * On a phone this is invisible — the shell fills the screen and there is\n * nothing around it. On a desktop it is the bordered, rounded card in the\n * middle of a textured field, which is what makes a phone-shaped app look\n * deliberate on a wide screen rather than stretched or abandoned.\n *\n * Both phone apps had this at 120 lines, differing in the product name, two\n * colour variables and one hover colour. None of those is a reason to own a\n * frame, so the colours are custom properties and the name is a slot.\n *\n * ## What stays in the app\n *\n * Which layout a route uses, and the `RouterView` inside it. That is the one\n * part that genuinely differs — an app with no auth screens has no layout\n * switch — and it is also the part that must stay in the app so a page that\n * throws does not take the tab bar with it.\n *\n * @example\n * ```vue\n * <TabShell>\n * <template #aside><AppCredits /></template>\n * <template #chrome><UpdatePrompt /></template>\n *\n * <component :is=\"layoutComponent\">\n * <RouterView v-slot=\"{ Component, route }\">\n * <Transition :name=\"tabTransition.name.value\">\n * <component :is=\"Component\" :key=\"route.path\" :class=\"pageClass\" />\n * </Transition>\n * </RouterView>\n * </component>\n * </TabShell>\n * ```\n */\ndefineSlots<{\n /**\n * Beside the shell, on a desktop only. Credits, a build number, a link home.\n *\n * Hidden below `md` rather than left out: on a phone the shell covers the\n * whole viewport, so anything here would be behind it.\n */\n aside?: () => unknown\n /**\n * Inside the shell and above everything in it — an update prompt, typically.\n *\n * Above the tab bar on purpose, and outside the layout so it also appears on\n * the sign-in screens, which is where somebody who has been away the longest\n * arrives.\n */\n chrome?: () => unknown\n /** The layout and the page. */\n default: () => unknown\n}>()\n</script>\n\n<template>\n <div class=\"rk-screen\">\n <aside v-if=\"$slots.aside\" class=\"rk-screen-aside\">\n <slot name=\"aside\" />\n </aside>\n\n <div class=\"shell-frame rk-shell\">\n <slot name=\"chrome\" />\n <slot />\n </div>\n </div>\n</template>\n\n<style scoped>\n/* A barely-there diamond lattice, so the area around the shell is not a flat\n slab. Both layers are theme colours at very low alpha, so it reads as texture\n rather than decoration and inverts with the theme for free.\n\n The colour is a custom property because it is the one thing each app wants\n different: set `--rk-lattice` on any ancestor. It defaults to the ink colour,\n which is legible against every theme the tokens can produce. */\n.rk-screen {\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: var(--color-canvas);\n /* The lattice sits on top of whatever the material puts behind the page —\n glass needs colour to blur, and it is this layer that supplies it. */\n background-image:\n repeating-linear-gradient(\n 45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n repeating-linear-gradient(\n -45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n var(--canvas-backdrop);\n}\n\n.rk-screen-aside {\n position: absolute;\n bottom: 1.5rem;\n left: 1.5rem;\n display: none;\n flex-direction: column;\n gap: 0.25rem;\n font-size: 11px;\n color: var(--color-ink-soft);\n}\n\n@media (min-width: 48rem) {\n .rk-screen-aside {\n display: flex;\n }\n}\n\n/* `shell-frame` from `rei-kit/shell/mobile.css` supplies the geometry — the\n 430px column and the desktop height. This adds only the surface. */\n.rk-shell {\n position: relative;\n margin: auto;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background: color-mix(in oklab, var(--color-surface) var(--surface-opacity), transparent);\n backdrop-filter: var(--surface-backdrop);\n -webkit-backdrop-filter: var(--surface-backdrop);\n}\n\n@media (min-width: 48rem) {\n .rk-shell {\n border: var(--surface-border-width) solid var(--surface-border-color);\n border-radius: var(--radius-shell);\n box-shadow: var(--shadow-overlay);\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport { inertOutside } from '../utils/inert'\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/** Gives the page back; set while the guide is open. */\nlet releaseInert: (() => void) | 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 releaseInert?.()\n releaseInert = null\n if (!isOpen) return\n\n await nextTick()\n if (dialog.value) releaseInert = inertOutside(dialog.value)\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => releaseInert?.())\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-(--duration-slow)\"\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 var(--duration-base) 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'\nimport { inertOutside } from '../utils/inert'\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/** Gives the page back; set while the guide is open. */\nlet releaseInert: (() => void) | 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 releaseInert?.()\n releaseInert = null\n if (!isOpen) return\n\n await nextTick()\n if (dialog.value) releaseInert = inertOutside(dialog.value)\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => releaseInert?.())\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-(--duration-slow)\"\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 var(--duration-base) ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n</style>\n","import type { NavigationGuard, NavigationHookAfter, RouteLocationRaw } from 'vue-router'\n\nimport { safeRedirect, toRedirectPath } from '../utils/redirect'\n\nexport type AuthGuardOptions = {\n /** Read per navigation, not captured: the answer changes while the app runs. */\n isAuthenticated: () => boolean\n /**\n * Where an unauthenticated visitor is sent. The redirect query is added to it.\n *\n * A string is treated as a path, so `'/giris'` and `{ name: 'LoginView' }`\n * both work.\n */\n signIn: RouteLocationRaw\n /**\n * Where a signed-in visitor is sent off a `guestOnly` route.\n *\n * Omitted, they go wherever the redirect query points, falling back to `/` —\n * which is what sends somebody who was bounced to the login screen back to\n * the page they actually wanted.\n */\n home?: RouteLocationRaw | undefined\n /** The query key carrying the return path. Default `'redirect'`. */\n redirectQuery?: string | undefined\n /**\n * Awaited before the first decision — restoring a session, typically.\n *\n * Without it the guard runs while the session is still being read and bounces\n * a signed-in visitor to the login screen on a cold load.\n */\n ready?: (() => Promise<void> | void) | undefined\n}\n\n/**\n * The route guard three apps had written separately.\n *\n * It answers two questions and nothing else: may this visitor see this route,\n * and where do they go if not. Everything that differs between the apps — the\n * name of the login route, whether the return path travels as `redirect` or\n * `next`, whether a session has to be restored first — is an option, because\n * each of those is a decision about the app rather than about guarding.\n *\n * It does nothing while prerendering. Every prerendered route is public, the\n * server has no session to read, and a guard that redirected there would write\n * a login page into a file meant to be content.\n *\n * The return path goes through `toRedirectPath`, so an OAuth fragment is\n * dropped rather than copied into a query string the server will see.\n *\n * @example\n * ```ts\n * router.beforeEach(\n * createAuthGuard({\n * isAuthenticated: () => useAuthStore().isAuthenticated,\n * signIn: { name: 'LoginView' },\n * }),\n * )\n * ```\n */\nexport function createAuthGuard(options: AuthGuardOptions): NavigationGuard {\n const { isAuthenticated, signIn, home, redirectQuery = 'redirect', ready } = options\n\n return async (to) => {\n // `import.meta.env` is the consumer's, substituted when they build. The\n // optional read is for a runtime that has no such object at all.\n if (import.meta.env?.['SSR']) return true\n\n await ready?.()\n\n if (to.meta['requiresAuth'] && !isAuthenticated()) {\n const target = typeof signIn === 'string' ? { path: signIn } : signIn\n\n return {\n ...(target as object),\n query: { [redirectQuery]: toRedirectPath(to.fullPath) },\n } as RouteLocationRaw\n }\n\n if (to.meta['guestOnly'] && isAuthenticated()) {\n return home ?? safeRedirect(to.query[redirectQuery] as string | null | undefined)\n }\n\n return true\n }\n}\n\n/**\n * The document title, which a single-page app has to set for itself.\n *\n * An `afterEach` rather than a `beforeEach`: the title describes where the\n * visitor arrived, and a guard that can still cancel has not arrived anywhere.\n *\n * @example\n * ```ts\n * router.afterEach(createTitleGuard('Kakei')) // \"Ledger · Kakei\"\n * ```\n */\nexport function createTitleGuard(suffix: string, separator = '·'): NavigationHookAfter {\n return (to) => {\n const title = to.meta['title']\n\n document.title = title ? `${String(title)} ${separator} ${suffix}` : suffix\n }\n}\n","/**\n * The cache settings two apps had picked independently and identically.\n *\n * Returned as a plain object rather than a built `QueryClient`, so the kit does\n * not depend on TanStack Query. An app that uses a different cache — or none —\n * pays nothing for this file, and the app keeps the client as its own\n * module-level singleton, which is what lets non-Vue code reach it (the auth\n * store calls `clear()` on sign-out).\n *\n * The numbers are not arbitrary and they are not TanStack's defaults:\n *\n * - `staleTime: 60_000` — the default is 0, which refetches on every mount. A\n * phone app remounts a screen every time a tab is touched, so the default\n * turns a tab bar into a network request per tap.\n * - `refetchOnWindowFocus` — left on. Coming back to a backgrounded phone app\n * is exactly when the data is most likely to be stale.\n * - `retry: 2` on queries, `0` on mutations. Retrying a read is free; retrying\n * a write that may already have landed is how a double charge happens.\n *\n * @example\n * ```ts\n * export const queryClient = new QueryClient({ defaultOptions: createQueryDefaults() })\n * ```\n */\nexport function createQueryDefaults(overrides: QueryDefaultsOverrides = {}) {\n const { staleTime = 60_000, gcTime = 5 * 60_000, retry = 2 } = overrides\n\n return {\n queries: {\n staleTime,\n gcTime,\n refetchOnWindowFocus: true,\n retry,\n },\n mutations: {\n // Not a typo and not a stricter version of the line above. A failed read\n // can be repeated safely; a failed write may have reached the server\n // before the response was lost.\n retry: 0,\n },\n }\n}\n\nexport type QueryDefaultsOverrides = {\n /** How long a result is served without refetching. Default 60s. */\n staleTime?: number | undefined\n /** How long an unused result is kept before eviction. Default 5m. */\n gcTime?: number | undefined\n /** Attempts for a failed *query*. Mutations are never retried. Default 2. */\n retry?: number | undefined\n}\n","import { useToast } from '../composables/use-toast'\n\n/**\n * Saying that a write happened.\n *\n * In one place so every mutation reports the same way. Both phone apps had\n * written this, and both had written it because before it existed none of their\n * mutations reported at all: a row was added, a row was deleted, and the only\n * evidence was that nothing had visibly broken.\n *\n * Deliberately generic. A message naming the thing that was saved reads better\n * once and worse every time after, and these are screens somebody uses dozens\n * of times in a sitting.\n *\n * Form validation does **not** come through here: a rejected field says so\n * beside itself, where the eye already is and where it stays until fixed. These\n * are for what has already happened.\n *\n * @example\n * ```ts\n * export const report = createWriteReport({\n * saved: () => t('common.saved'),\n * deleted: () => t('common.deleted'),\n * failed: () => t('common.failed'),\n * })\n * ```\n */\nexport function createWriteReport(messages: WriteReportMessages): WriteReport {\n return {\n saved: () => useToast().success(messages.saved()),\n deleted: () => useToast().success(messages.deleted()),\n // `danger` rather than `warning`: the change is not in the database and the\n // person who made it is the only one who can decide what to do about that.\n failed: () => useToast().danger(messages.failed()),\n }\n}\n\n/**\n * Functions, not strings.\n *\n * A string would be read once, when the report is built, and would then keep\n * whichever language was active at that moment for the life of the app. Called\n * per report, the wording follows a language switch.\n */\nexport type WriteReportMessages = {\n saved: () => string\n deleted: () => string\n failed: () => string\n}\n\nexport type WriteReport = {\n saved: () => void\n deleted: () => void\n failed: () => void\n}\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 { computed, 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 * The `<Transition>` name for the current direction, ready to bind.\n *\n * Empty when there is nothing to slide, which is how a `<Transition>` is\n * told to do nothing — an unnamed transition still runs a default `v-*`\n * animation, so the empty string matters.\n *\n * Both phone apps derived this from `direction` in their `App.vue`, in the\n * same three lines, and the names match the classes shipped in\n * `rei-kit/shell/mobile.css`.\n */\n name: computed(() => (direction.value === 'none' ? '' : `slide-${direction.value}`)),\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","$slots","$emit"],"sources":["../src/app/AuthForm.vue","../src/app/AuthForm.vue","../src/app/AuthShell.vue","../src/app/AuthShell.vue","../src/app/FabButton.vue","../src/app/FabButton.vue","../src/app/LocaleSheet.vue","../src/app/LocaleSheet.vue","../src/app/OfflineBanner.vue","../src/app/OfflineBanner.vue","../src/app/TabShell.vue","../src/app/TabShell.vue","../src/app/TourShell.vue","../src/app/TourShell.vue","../src/app/guards.ts","../src/app/query-defaults.ts","../src/app/write-report.ts","../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 { tapFeedback } from '../utils/haptics'\n\n/**\n * The one action the app is built around, reachable from every screen.\n *\n * Extended rather than a bare circle: a lone \"+\" says nothing about what it\n * adds, and this is the button the whole product is arranged around. Both phone\n * apps reached the same conclusion and wrote the same forty lines of CSS to get\n * there.\n *\n * It shares the tab bar's column rather than being anchored to the layout, so\n * it lines up with the bar's right edge at every width. Anchored to the layout\n * instead, it sat four hundred pixels away from the shell on a desktop screen —\n * which is how the shared version came to exist.\n */\ndefineProps<{\n /** The words beside the icon. */\n label: string\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n\ndefineSlots<{\n /** The icon. A `lucide-vue-next` component, usually. */\n default: () => unknown\n}>()\n\nfunction press() {\n // The haptic belongs to the press, not to what the press opens: it has to\n // fire even when the sheet it opens is still being fetched.\n tapFeedback()\n emit('click')\n}\n</script>\n\n<template>\n <div class=\"rk-fab-slot\">\n <button type=\"button\" class=\"rk-fab\" @click=\"press\">\n <span class=\"rk-fab-icon\" aria-hidden=\"true\"><slot /></span>\n <span class=\"rk-fab-label\">{{ label }}</span>\n </button>\n </div>\n</template>\n\n<style scoped>\n.rk-fab-slot {\n position: absolute;\n bottom: calc(6rem + env(safe-area-inset-bottom, 0px));\n left: 50%;\n z-index: 40;\n display: flex;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n justify-content: flex-end;\n padding: 0 1rem;\n /* The slot spans the shell so the button can line up with the tab bar; it\n must not swallow taps meant for the page underneath it. */\n pointer-events: none;\n}\n\n.rk-fab {\n pointer-events: auto;\n display: flex;\n height: 3rem;\n align-items: center;\n gap: 0.375rem;\n border-radius: 9999px;\n padding-left: 1rem;\n padding-right: 1.25rem;\n color: var(--color-on-primary);\n background: var(--color-primary);\n transition: transform var(--duration-fast);\n /* A ring in the canvas colour separates it from whatever scrolls behind; the\n depth under it is the material's, so a brutalist app gets a hard offset\n and a soft one gets a puff without this file knowing either. */\n box-shadow:\n 0 0 0 4px var(--color-canvas),\n var(--shadow-raised);\n}\n\n.rk-fab:active {\n transform: translate(var(--press-offset), var(--press-offset)) scale(var(--press-scale));\n}\n\n/* The ring is not decoration. This button was a `BaseButton variant=\"unstyled\"`\n in the app it came from, which meant it inherited a focus ring for free; a\n bare <button> here would have dropped it, and a keyboard user would have had\n no way to see where they were. */\n.rk-fab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-fab-icon {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n}\n\n.rk-fab-icon :deep(svg) {\n width: 1.25rem;\n height: 1.25rem;\n stroke-width: 2.5px;\n}\n\n.rk-fab-label {\n font-size: 0.875rem;\n font-weight: 600;\n white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-fab {\n transition: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { tapFeedback } from '../utils/haptics'\n\n/**\n * The one action the app is built around, reachable from every screen.\n *\n * Extended rather than a bare circle: a lone \"+\" says nothing about what it\n * adds, and this is the button the whole product is arranged around. Both phone\n * apps reached the same conclusion and wrote the same forty lines of CSS to get\n * there.\n *\n * It shares the tab bar's column rather than being anchored to the layout, so\n * it lines up with the bar's right edge at every width. Anchored to the layout\n * instead, it sat four hundred pixels away from the shell on a desktop screen —\n * which is how the shared version came to exist.\n */\ndefineProps<{\n /** The words beside the icon. */\n label: string\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n\ndefineSlots<{\n /** The icon. A `lucide-vue-next` component, usually. */\n default: () => unknown\n}>()\n\nfunction press() {\n // The haptic belongs to the press, not to what the press opens: it has to\n // fire even when the sheet it opens is still being fetched.\n tapFeedback()\n emit('click')\n}\n</script>\n\n<template>\n <div class=\"rk-fab-slot\">\n <button type=\"button\" class=\"rk-fab\" @click=\"press\">\n <span class=\"rk-fab-icon\" aria-hidden=\"true\"><slot /></span>\n <span class=\"rk-fab-label\">{{ label }}</span>\n </button>\n </div>\n</template>\n\n<style scoped>\n.rk-fab-slot {\n position: absolute;\n bottom: calc(6rem + env(safe-area-inset-bottom, 0px));\n left: 50%;\n z-index: 40;\n display: flex;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n justify-content: flex-end;\n padding: 0 1rem;\n /* The slot spans the shell so the button can line up with the tab bar; it\n must not swallow taps meant for the page underneath it. */\n pointer-events: none;\n}\n\n.rk-fab {\n pointer-events: auto;\n display: flex;\n height: 3rem;\n align-items: center;\n gap: 0.375rem;\n border-radius: 9999px;\n padding-left: 1rem;\n padding-right: 1.25rem;\n color: var(--color-on-primary);\n background: var(--color-primary);\n transition: transform var(--duration-fast);\n /* A ring in the canvas colour separates it from whatever scrolls behind; the\n depth under it is the material's, so a brutalist app gets a hard offset\n and a soft one gets a puff without this file knowing either. */\n box-shadow:\n 0 0 0 4px var(--color-canvas),\n var(--shadow-raised);\n}\n\n.rk-fab:active {\n transform: translate(var(--press-offset), var(--press-offset)) scale(var(--press-scale));\n}\n\n/* The ring is not decoration. This button was a `BaseButton variant=\"unstyled\"`\n in the app it came from, which meant it inherited a focus ring for free; a\n bare <button> here would have dropped it, and a keyboard user would have had\n no way to see where they were. */\n.rk-fab:focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 2px;\n}\n\n.rk-fab-icon {\n display: grid;\n place-items: center;\n flex-shrink: 0;\n}\n\n.rk-fab-icon :deep(svg) {\n width: 1.25rem;\n height: 1.25rem;\n stroke-width: 2.5px;\n}\n\n.rk-fab-label {\n font-size: 0.875rem;\n font-weight: 600;\n white-space: nowrap;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .rk-fab {\n transition: none;\n }\n}\n</style>\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 { useOnline } from '../composables/use-online'\n\n/**\n * A floating note that the connection has gone.\n *\n * Floating, not in flow: connectivity flickers in lifts and tunnels, and a\n * banner that reflows the page on every flicker is worse than the outage it is\n * reporting. It sits under the top bar and above the content, so appearing and\n * disappearing costs no layout at all.\n *\n * `role=\"status\"`, not `alert`: losing signal is a condition to know about, not\n * something to interrupt a reader mid-sentence for.\n *\n * Both phone apps had this, identically, down to the 200ms and the half-rem\n * travel.\n */\nconst { label } = defineProps<{\n /** The message. Required, because the kit has no language of its own. */\n label: string\n}>()\n\nconst isOnline = useOnline()\n</script>\n\n<template>\n <Transition name=\"rk-offline\">\n <p v-if=\"!isOnline\" role=\"status\" class=\"rk-offline-banner\">{{ label }}</p>\n </Transition>\n</template>\n\n<style scoped>\n.rk-offline-banner {\n position: absolute;\n top: 4.75rem;\n left: 50%;\n z-index: 30;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n border-radius: 9999px;\n padding: 0.5rem 1rem;\n text-align: center;\n font-size: 0.75rem;\n font-weight: 500;\n color: var(--color-ink);\n background: color-mix(in srgb, var(--color-warning) 90%, transparent);\n box-shadow: var(--shadow-raised);\n backdrop-filter: blur(4px);\n}\n\n.rk-offline-enter-active,\n.rk-offline-leave-active {\n transition:\n opacity var(--duration-base) ease,\n transform var(--duration-base) var(--ease-sheet);\n}\n\n.rk-offline-enter-from,\n.rk-offline-leave-to {\n opacity: 0;\n transform: translate(-50%, -0.5rem);\n}\n\n/* The travel is the part that causes trouble; the fade is not. */\n@media (prefers-reduced-motion: reduce) {\n .rk-offline-enter-from,\n .rk-offline-leave-to {\n transform: translate(-50%, 0);\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { useOnline } from '../composables/use-online'\n\n/**\n * A floating note that the connection has gone.\n *\n * Floating, not in flow: connectivity flickers in lifts and tunnels, and a\n * banner that reflows the page on every flicker is worse than the outage it is\n * reporting. It sits under the top bar and above the content, so appearing and\n * disappearing costs no layout at all.\n *\n * `role=\"status\"`, not `alert`: losing signal is a condition to know about, not\n * something to interrupt a reader mid-sentence for.\n *\n * Both phone apps had this, identically, down to the 200ms and the half-rem\n * travel.\n */\nconst { label } = defineProps<{\n /** The message. Required, because the kit has no language of its own. */\n label: string\n}>()\n\nconst isOnline = useOnline()\n</script>\n\n<template>\n <Transition name=\"rk-offline\">\n <p v-if=\"!isOnline\" role=\"status\" class=\"rk-offline-banner\">{{ label }}</p>\n </Transition>\n</template>\n\n<style scoped>\n.rk-offline-banner {\n position: absolute;\n top: 4.75rem;\n left: 50%;\n z-index: 30;\n width: 100%;\n max-width: 360px;\n transform: translateX(-50%);\n border-radius: 9999px;\n padding: 0.5rem 1rem;\n text-align: center;\n font-size: 0.75rem;\n font-weight: 500;\n color: var(--color-ink);\n background: color-mix(in srgb, var(--color-warning) 90%, transparent);\n box-shadow: var(--shadow-raised);\n backdrop-filter: blur(4px);\n}\n\n.rk-offline-enter-active,\n.rk-offline-leave-active {\n transition:\n opacity var(--duration-base) ease,\n transform var(--duration-base) var(--ease-sheet);\n}\n\n.rk-offline-enter-from,\n.rk-offline-leave-to {\n opacity: 0;\n transform: translate(-50%, -0.5rem);\n}\n\n/* The travel is the part that causes trouble; the fade is not. */\n@media (prefers-reduced-motion: reduce) {\n .rk-offline-enter-from,\n .rk-offline-leave-to {\n transform: translate(-50%, 0);\n }\n}\n</style>\n","<script setup lang=\"ts\">\n/**\n * The phone frame the whole app sits inside.\n *\n * On a phone this is invisible — the shell fills the screen and there is\n * nothing around it. On a desktop it is the bordered, rounded card in the\n * middle of a textured field, which is what makes a phone-shaped app look\n * deliberate on a wide screen rather than stretched or abandoned.\n *\n * Both phone apps had this at 120 lines, differing in the product name, two\n * colour variables and one hover colour. None of those is a reason to own a\n * frame, so the colours are custom properties and the name is a slot.\n *\n * ## What stays in the app\n *\n * Which layout a route uses, and the `RouterView` inside it. That is the one\n * part that genuinely differs — an app with no auth screens has no layout\n * switch — and it is also the part that must stay in the app so a page that\n * throws does not take the tab bar with it.\n *\n * @example\n * ```vue\n * <TabShell>\n * <template #aside><AppCredits /></template>\n * <template #chrome><UpdatePrompt /></template>\n *\n * <component :is=\"layoutComponent\">\n * <RouterView v-slot=\"{ Component, route }\">\n * <Transition :name=\"tabTransition.name.value\">\n * <component :is=\"Component\" :key=\"route.path\" :class=\"pageClass\" />\n * </Transition>\n * </RouterView>\n * </component>\n * </TabShell>\n * ```\n */\ndefineSlots<{\n /**\n * Beside the shell, on a desktop only. Credits, a build number, a link home.\n *\n * Hidden below `md` rather than left out: on a phone the shell covers the\n * whole viewport, so anything here would be behind it.\n */\n aside?: () => unknown\n /**\n * Inside the shell and above everything in it — an update prompt, typically.\n *\n * Above the tab bar on purpose, and outside the layout so it also appears on\n * the sign-in screens, which is where somebody who has been away the longest\n * arrives.\n */\n chrome?: () => unknown\n /** The layout and the page. */\n default: () => unknown\n}>()\n</script>\n\n<template>\n <div class=\"rk-screen\">\n <aside v-if=\"$slots.aside\" class=\"rk-screen-aside\">\n <slot name=\"aside\" />\n </aside>\n\n <div class=\"shell-frame rk-shell\">\n <slot name=\"chrome\" />\n <slot />\n </div>\n </div>\n</template>\n\n<style scoped>\n/* A barely-there diamond lattice, so the area around the shell is not a flat\n slab. Both layers are theme colours at very low alpha, so it reads as texture\n rather than decoration and inverts with the theme for free.\n\n The colour is a custom property because it is the one thing each app wants\n different: set `--rk-lattice` on any ancestor. It defaults to the ink colour,\n which is legible against every theme the tokens can produce. */\n.rk-screen {\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: var(--color-canvas);\n /* The lattice sits on top of whatever the material puts behind the page —\n glass needs colour to blur, and it is this layer that supplies it. */\n background-image:\n repeating-linear-gradient(\n 45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n repeating-linear-gradient(\n -45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n var(--canvas-backdrop);\n}\n\n.rk-screen-aside {\n position: absolute;\n bottom: 1.5rem;\n left: 1.5rem;\n display: none;\n flex-direction: column;\n gap: 0.25rem;\n font-size: 11px;\n color: var(--color-ink-soft);\n}\n\n@media (min-width: 48rem) {\n .rk-screen-aside {\n display: flex;\n }\n}\n\n/* `shell-frame` from `rei-kit/shell/mobile.css` supplies the geometry — the\n 430px column and the desktop height. This adds only the surface. */\n.rk-shell {\n position: relative;\n margin: auto;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background: color-mix(in oklab, var(--color-surface) var(--surface-opacity), transparent);\n backdrop-filter: var(--surface-backdrop);\n -webkit-backdrop-filter: var(--surface-backdrop);\n}\n\n@media (min-width: 48rem) {\n .rk-shell {\n border: var(--surface-border-width) solid var(--surface-border-color);\n border-radius: var(--radius-shell);\n box-shadow: var(--shadow-overlay);\n }\n}\n</style>\n","<script setup lang=\"ts\">\n/**\n * The phone frame the whole app sits inside.\n *\n * On a phone this is invisible — the shell fills the screen and there is\n * nothing around it. On a desktop it is the bordered, rounded card in the\n * middle of a textured field, which is what makes a phone-shaped app look\n * deliberate on a wide screen rather than stretched or abandoned.\n *\n * Both phone apps had this at 120 lines, differing in the product name, two\n * colour variables and one hover colour. None of those is a reason to own a\n * frame, so the colours are custom properties and the name is a slot.\n *\n * ## What stays in the app\n *\n * Which layout a route uses, and the `RouterView` inside it. That is the one\n * part that genuinely differs — an app with no auth screens has no layout\n * switch — and it is also the part that must stay in the app so a page that\n * throws does not take the tab bar with it.\n *\n * @example\n * ```vue\n * <TabShell>\n * <template #aside><AppCredits /></template>\n * <template #chrome><UpdatePrompt /></template>\n *\n * <component :is=\"layoutComponent\">\n * <RouterView v-slot=\"{ Component, route }\">\n * <Transition :name=\"tabTransition.name.value\">\n * <component :is=\"Component\" :key=\"route.path\" :class=\"pageClass\" />\n * </Transition>\n * </RouterView>\n * </component>\n * </TabShell>\n * ```\n */\ndefineSlots<{\n /**\n * Beside the shell, on a desktop only. Credits, a build number, a link home.\n *\n * Hidden below `md` rather than left out: on a phone the shell covers the\n * whole viewport, so anything here would be behind it.\n */\n aside?: () => unknown\n /**\n * Inside the shell and above everything in it — an update prompt, typically.\n *\n * Above the tab bar on purpose, and outside the layout so it also appears on\n * the sign-in screens, which is where somebody who has been away the longest\n * arrives.\n */\n chrome?: () => unknown\n /** The layout and the page. */\n default: () => unknown\n}>()\n</script>\n\n<template>\n <div class=\"rk-screen\">\n <aside v-if=\"$slots.aside\" class=\"rk-screen-aside\">\n <slot name=\"aside\" />\n </aside>\n\n <div class=\"shell-frame rk-shell\">\n <slot name=\"chrome\" />\n <slot />\n </div>\n </div>\n</template>\n\n<style scoped>\n/* A barely-there diamond lattice, so the area around the shell is not a flat\n slab. Both layers are theme colours at very low alpha, so it reads as texture\n rather than decoration and inverts with the theme for free.\n\n The colour is a custom property because it is the one thing each app wants\n different: set `--rk-lattice` on any ancestor. It defaults to the ink colour,\n which is legible against every theme the tokens can produce. */\n.rk-screen {\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: var(--color-canvas);\n /* The lattice sits on top of whatever the material puts behind the page —\n glass needs colour to blur, and it is this layer that supplies it. */\n background-image:\n repeating-linear-gradient(\n 45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n repeating-linear-gradient(\n -45deg,\n color-mix(\n in srgb,\n var(--rk-lattice, var(--color-ink)) var(--rk-lattice-alpha, 5%),\n transparent\n )\n 0 1px,\n transparent 1px 56px\n ),\n var(--canvas-backdrop);\n}\n\n.rk-screen-aside {\n position: absolute;\n bottom: 1.5rem;\n left: 1.5rem;\n display: none;\n flex-direction: column;\n gap: 0.25rem;\n font-size: 11px;\n color: var(--color-ink-soft);\n}\n\n@media (min-width: 48rem) {\n .rk-screen-aside {\n display: flex;\n }\n}\n\n/* `shell-frame` from `rei-kit/shell/mobile.css` supplies the geometry — the\n 430px column and the desktop height. This adds only the surface. */\n.rk-shell {\n position: relative;\n margin: auto;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background: color-mix(in oklab, var(--color-surface) var(--surface-opacity), transparent);\n backdrop-filter: var(--surface-backdrop);\n -webkit-backdrop-filter: var(--surface-backdrop);\n}\n\n@media (min-width: 48rem) {\n .rk-shell {\n border: var(--surface-border-width) solid var(--surface-border-color);\n border-radius: var(--radius-shell);\n box-shadow: var(--shadow-overlay);\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport { inertOutside } from '../utils/inert'\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/** Gives the page back; set while the guide is open. */\nlet releaseInert: (() => void) | 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 releaseInert?.()\n releaseInert = null\n if (!isOpen) return\n\n await nextTick()\n if (dialog.value) releaseInert = inertOutside(dialog.value)\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => releaseInert?.())\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-(--duration-slow)\"\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 var(--duration-base) 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'\nimport { inertOutside } from '../utils/inert'\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/** Gives the page back; set while the guide is open. */\nlet releaseInert: (() => void) | 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 releaseInert?.()\n releaseInert = null\n if (!isOpen) return\n\n await nextTick()\n if (dialog.value) releaseInert = inertOutside(dialog.value)\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => releaseInert?.())\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-(--duration-slow)\"\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 var(--duration-base) ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n</style>\n","import type { NavigationGuard, NavigationHookAfter, RouteLocationRaw } from 'vue-router'\n\nimport { safeRedirect, toRedirectPath } from '../utils/redirect'\n\nexport type AuthGuardOptions = {\n /** Read per navigation, not captured: the answer changes while the app runs. */\n isAuthenticated: () => boolean\n /**\n * Where an unauthenticated visitor is sent. The redirect query is added to it.\n *\n * A string is treated as a path, so `'/giris'` and `{ name: 'LoginView' }`\n * both work.\n */\n signIn: RouteLocationRaw\n /**\n * Where a signed-in visitor is sent off a `guestOnly` route.\n *\n * Omitted, they go wherever the redirect query points, falling back to `/` —\n * which is what sends somebody who was bounced to the login screen back to\n * the page they actually wanted.\n */\n home?: RouteLocationRaw | undefined\n /** The query key carrying the return path. Default `'redirect'`. */\n redirectQuery?: string | undefined\n /**\n * Awaited before the first decision — restoring a session, typically.\n *\n * Without it the guard runs while the session is still being read and bounces\n * a signed-in visitor to the login screen on a cold load.\n */\n ready?: (() => Promise<void> | void) | undefined\n}\n\n/**\n * The route guard three apps had written separately.\n *\n * It answers two questions and nothing else: may this visitor see this route,\n * and where do they go if not. Everything that differs between the apps — the\n * name of the login route, whether the return path travels as `redirect` or\n * `next`, whether a session has to be restored first — is an option, because\n * each of those is a decision about the app rather than about guarding.\n *\n * It does nothing while prerendering. Every prerendered route is public, the\n * server has no session to read, and a guard that redirected there would write\n * a login page into a file meant to be content.\n *\n * The return path goes through `toRedirectPath`, so an OAuth fragment is\n * dropped rather than copied into a query string the server will see.\n *\n * @example\n * ```ts\n * router.beforeEach(\n * createAuthGuard({\n * isAuthenticated: () => useAuthStore().isAuthenticated,\n * signIn: { name: 'LoginView' },\n * }),\n * )\n * ```\n */\nexport function createAuthGuard(options: AuthGuardOptions): NavigationGuard {\n const { isAuthenticated, signIn, home, redirectQuery = 'redirect', ready } = options\n\n return async (to) => {\n // `import.meta.env` is the consumer's, substituted when they build. The\n // optional read is for a runtime that has no such object at all.\n if (import.meta.env?.['SSR']) return true\n\n await ready?.()\n\n if (to.meta['requiresAuth'] && !isAuthenticated()) {\n const target = typeof signIn === 'string' ? { path: signIn } : signIn\n\n return {\n ...(target as object),\n query: { [redirectQuery]: toRedirectPath(to.fullPath) },\n } as RouteLocationRaw\n }\n\n if (to.meta['guestOnly'] && isAuthenticated()) {\n return home ?? safeRedirect(to.query[redirectQuery] as string | null | undefined)\n }\n\n return true\n }\n}\n\n/**\n * The document title, which a single-page app has to set for itself.\n *\n * An `afterEach` rather than a `beforeEach`: the title describes where the\n * visitor arrived, and a guard that can still cancel has not arrived anywhere.\n *\n * @example\n * ```ts\n * router.afterEach(createTitleGuard('Kakei')) // \"Ledger · Kakei\"\n * ```\n */\nexport function createTitleGuard(suffix: string, separator = '·'): NavigationHookAfter {\n return (to) => {\n const title = to.meta['title']\n\n document.title = title ? `${String(title)} ${separator} ${suffix}` : suffix\n }\n}\n","/**\n * The cache settings two apps had picked independently and identically.\n *\n * Returned as a plain object rather than a built `QueryClient`, so the kit does\n * not depend on TanStack Query. An app that uses a different cache — or none —\n * pays nothing for this file, and the app keeps the client as its own\n * module-level singleton, which is what lets non-Vue code reach it (the auth\n * store calls `clear()` on sign-out).\n *\n * The numbers are not arbitrary and they are not TanStack's defaults:\n *\n * - `staleTime: 60_000` — the default is 0, which refetches on every mount. A\n * phone app remounts a screen every time a tab is touched, so the default\n * turns a tab bar into a network request per tap.\n * - `refetchOnWindowFocus` — left on. Coming back to a backgrounded phone app\n * is exactly when the data is most likely to be stale.\n * - `retry: 2` on queries, `0` on mutations. Retrying a read is free; retrying\n * a write that may already have landed is how a double charge happens.\n *\n * @example\n * ```ts\n * export const queryClient = new QueryClient({ defaultOptions: createQueryDefaults() })\n * ```\n */\nexport function createQueryDefaults(overrides: QueryDefaultsOverrides = {}) {\n const { staleTime = 60_000, gcTime = 5 * 60_000, retry = 2 } = overrides\n\n return {\n queries: {\n staleTime,\n gcTime,\n refetchOnWindowFocus: true,\n retry,\n },\n mutations: {\n // Not a typo and not a stricter version of the line above. A failed read\n // can be repeated safely; a failed write may have reached the server\n // before the response was lost.\n retry: 0,\n },\n }\n}\n\nexport type QueryDefaultsOverrides = {\n /** How long a result is served without refetching. Default 60s. */\n staleTime?: number | undefined\n /** How long an unused result is kept before eviction. Default 5m. */\n gcTime?: number | undefined\n /** Attempts for a failed *query*. Mutations are never retried. Default 2. */\n retry?: number | undefined\n}\n","import { useToast } from '../composables/use-toast'\n\n/**\n * Saying that a write happened.\n *\n * In one place so every mutation reports the same way. Both phone apps had\n * written this, and both had written it because before it existed none of their\n * mutations reported at all: a row was added, a row was deleted, and the only\n * evidence was that nothing had visibly broken.\n *\n * Deliberately generic. A message naming the thing that was saved reads better\n * once and worse every time after, and these are screens somebody uses dozens\n * of times in a sitting.\n *\n * Form validation does **not** come through here: a rejected field says so\n * beside itself, where the eye already is and where it stays until fixed. These\n * are for what has already happened.\n *\n * @example\n * ```ts\n * export const report = createWriteReport({\n * saved: () => t('common.saved'),\n * deleted: () => t('common.deleted'),\n * failed: () => t('common.failed'),\n * })\n * ```\n */\nexport function createWriteReport(messages: WriteReportMessages): WriteReport {\n return {\n saved: () => useToast().success(messages.saved()),\n deleted: () => useToast().success(messages.deleted()),\n // `danger` rather than `warning`: the change is not in the database and the\n // person who made it is the only one who can decide what to do about that.\n failed: () => useToast().danger(messages.failed()),\n }\n}\n\n/**\n * Functions, not strings.\n *\n * A string would be read once, when the report is built, and would then keep\n * whichever language was active at that moment for the life of the app. Called\n * per report, the wording follows a language switch.\n */\nexport type WriteReportMessages = {\n saved: () => string\n deleted: () => string\n failed: () => string\n}\n\nexport type WriteReport = {\n saved: () => void\n deleted: () => void\n failed: () => void\n}\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 { computed, 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 * The `<Transition>` name for the current direction, ready to bind.\n *\n * Empty when there is nothing to slide, which is how a `<Transition>` is\n * told to do nothing — an unnamed transition still runs a default `v-*`\n * animation, so the empty string matters.\n *\n * Both phone apps derived this from `direction` in their `App.vue`, in the\n * same three lines, and the names match the classes shipped in\n * `rei-kit/shell/mobile.css`.\n */\n name: computed(() => (direction.value === 'none' ? '' : `slide-${direction.value}`)),\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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"}
@@ -9,6 +9,10 @@ import { Component } from 'vue';
9
9
  *
10
10
  * `removeLabel` is required when it can be removed, because "×" alone is a
11
11
  * button a screen reader reads as "times".
12
+ *
13
+ * A chip that both selects and removes is two buttons side by side, never
14
+ * one inside the other: a button nested in a button is not a control a
15
+ * browser or a screen reader can make sense of.
12
16
  */
13
17
  type __VLS_Props = {
14
18
  /** The text. Already translated. */