rei-kit 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -15
- package/dist/{GoogleButton-lxNEycyw.js → GoogleButton-DS-3rpUA.js} +6 -6
- package/dist/GoogleButton-DS-3rpUA.js.map +1 -0
- package/dist/{SettingsGroup-JsD19V81.js → SettingsGroup-BXkajKGl.js} +2 -2
- package/dist/{SettingsGroup-JsD19V81.js.map → SettingsGroup-BXkajKGl.js.map} +1 -1
- package/dist/{SettingsRow-3lPRJbBZ.js → SettingsRow-BXFn8tCk.js} +12 -12
- package/dist/SettingsRow-BXFn8tCk.js.map +1 -0
- package/dist/app.js +7 -7
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +130 -30
- package/dist/index.js.map +1 -1
- package/dist/materials/brutal.css +32 -0
- package/dist/materials/glass.css +104 -0
- package/dist/materials/quiet.css +39 -0
- package/dist/materials/soft.css +57 -0
- package/dist/materials.css +15 -0
- package/dist/palettes/palettes.generated.json.d.ts +475 -0
- package/dist/palettes.css +420 -0
- package/dist/pwa.js +4 -4
- package/dist/pwa.js.map +1 -1
- package/dist/shell/mobile.css +7 -7
- package/dist/shell/web.css +4 -4
- package/dist/styles.css +296 -426
- package/dist/tokens.css +254 -8
- package/dist/utils/appearance.d.ts +71 -0
- package/dist/web.js +9 -9
- package/dist/web.js.map +1 -1
- package/package.json +21 -5
- package/dist/GoogleButton-lxNEycyw.js.map +0 -1
- package/dist/SettingsRow-3lPRJbBZ.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SettingsRow-BXFn8tCk.js","names":[],"sources":["../src/components/BaseButton.vue","../src/components/BaseButton.vue","../src/components/SettingsRow.vue","../src/components/SettingsRow.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<{\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n}>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-on-primary shadow-(--shadow-control) hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'control text-ink hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-on-negative shadow-(--shadow-control) hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-on-positive shadow-(--shadow-control) hover:bg-positive/90',\n warning: 'bg-warning text-on-warning shadow-(--shadow-control) hover:bg-warning/90',\n accent: 'bg-accent text-on-accent shadow-(--shadow-control) hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-on-primary hover:bg-primary/90',\n quiet: 'bg-primary text-on-primary hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-on-primary hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel =\n 'transition-[transform,translate,scale,color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-standard select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n /* The press belongs to the material. A shrink by default; a material that\n draws hard offset shadows moves the button *into* its shadow instead, and\n drops the shadow, so pressing reads as pressing on every one of them. */\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-(--press-scale) active:translate-x-(--press-offset) active:translate-y-(--press-offset) active:shadow-(--shadow-control-pressed)`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-xs'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<{\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n}>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-on-primary shadow-(--shadow-control) hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'control text-ink hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-on-negative shadow-(--shadow-control) hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-on-positive shadow-(--shadow-control) hover:bg-positive/90',\n warning: 'bg-warning text-on-warning shadow-(--shadow-control) hover:bg-warning/90',\n accent: 'bg-accent text-on-accent shadow-(--shadow-control) hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-on-primary hover:bg-primary/90',\n quiet: 'bg-primary text-on-primary hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-on-primary hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel =\n 'transition-[transform,translate,scale,color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-standard select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n /* The press belongs to the material. A shrink by default; a material that\n draws hard offset shadows moves the button *into* its shadow instead, and\n drops the shadow, so pressing reads as pressing on every one of them. */\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-(--press-scale) active:translate-x-(--press-offset) active:translate-y-(--press-offset) active:shadow-(--shadow-control-pressed)`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-xs'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { ChevronRight } from 'lucide-vue-next'\nimport type { Component } from 'vue'\n\n/**\n * One line in a settings card.\n *\n * `as` decides the element: a row that navigates has to be a button, and a row\n * that merely holds a control must not be, or the control becomes unreachable.\n */\nconst {\n label,\n description = '',\n icon = undefined,\n interactive = false,\n stacked = false,\n} = defineProps<{\n label: string\n description?: string | undefined\n icon?: Component | undefined\n /** Renders the row as a button with a chevron. */\n interactive?: boolean | undefined\n /** Puts the control on its own line below the label, for wide controls. */\n stacked?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <component\n :is=\"interactive ? 'button' : 'div'\"\n :type=\"interactive ? 'button' : undefined\"\n class=\"flex w-full items-center gap-3 px-4 py-3 text-left\"\n :class=\"[\n interactive ? 'hover:bg-muted/60 transition-colors active:scale-[0.99]' : '',\n stacked ? 'flex-col items-stretch gap-3' : '',\n ]\"\n @click=\"interactive && emit('click')\"\n >\n <div class=\"flex items-center gap-3\">\n <span\n v-if=\"icon\"\n class=\"bg-muted text-ink-soft flex size-9 shrink-0 items-center justify-center rounded-xl\"\n aria-hidden=\"true\"\n >\n <component :is=\"icon\" class=\"size-[18px]\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"text-ink text-sm font-medium\">{{ label }}</p>\n <p v-if=\"description\" class=\"text-ink-soft mt-0.5 text-xs leading-snug\">\n {{ description }}\n </p>\n </div>\n\n <div v-if=\"!stacked\" class=\"shrink-0\"><slot /></div>\n\n <ChevronRight v-if=\"interactive\" class=\"text-ink-soft size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <div v-if=\"stacked\"><slot /></div>\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { ChevronRight } from 'lucide-vue-next'\nimport type { Component } from 'vue'\n\n/**\n * One line in a settings card.\n *\n * `as` decides the element: a row that navigates has to be a button, and a row\n * that merely holds a control must not be, or the control becomes unreachable.\n */\nconst {\n label,\n description = '',\n icon = undefined,\n interactive = false,\n stacked = false,\n} = defineProps<{\n label: string\n description?: string | undefined\n icon?: Component | undefined\n /** Renders the row as a button with a chevron. */\n interactive?: boolean | undefined\n /** Puts the control on its own line below the label, for wide controls. */\n stacked?: boolean | undefined\n}>()\n\nconst emit = defineEmits<{ click: [] }>()\n</script>\n\n<template>\n <component\n :is=\"interactive ? 'button' : 'div'\"\n :type=\"interactive ? 'button' : undefined\"\n class=\"flex w-full items-center gap-3 px-4 py-3 text-left\"\n :class=\"[\n interactive ? 'hover:bg-muted/60 transition-colors active:scale-[0.99]' : '',\n stacked ? 'flex-col items-stretch gap-3' : '',\n ]\"\n @click=\"interactive && emit('click')\"\n >\n <div class=\"flex items-center gap-3\">\n <span\n v-if=\"icon\"\n class=\"bg-muted text-ink-soft flex size-9 shrink-0 items-center justify-center rounded-xl\"\n aria-hidden=\"true\"\n >\n <component :is=\"icon\" class=\"size-[18px]\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"text-ink text-sm font-medium\">{{ label }}</p>\n <p v-if=\"description\" class=\"text-ink-soft mt-0.5 text-xs leading-snug\">\n {{ description }}\n </p>\n </div>\n\n <div v-if=\"!stacked\" class=\"shrink-0\"><slot /></div>\n\n <ChevronRight v-if=\"interactive\" class=\"text-ink-soft size-4 shrink-0\" aria-hidden=\"true\" />\n </div>\n\n <div v-if=\"stacked\"><slot /></div>\n </component>\n</template>\n"],"mappings":"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"}
|
package/dist/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { _ as toRedirectPath, d as isThemePreference, g as safeRedirect, h as tapFeedback, i as BaseInput_default, l as useOnline, m as useTheme, n as BaseCheckbox_default, o as BaseAlert_default, r as BaseSheet_default, s as useToast, t as GoogleButton_default } from "./GoogleButton-
|
|
1
|
+
import { _ as toRedirectPath, d as isThemePreference, g as safeRedirect, h as tapFeedback, i as BaseInput_default, l as useOnline, m as useTheme, n as BaseCheckbox_default, o as BaseAlert_default, r as BaseSheet_default, s as useToast, t as GoogleButton_default } from "./GoogleButton-DS-3rpUA.js";
|
|
2
2
|
import { t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-BOaGB7Aw.js";
|
|
3
|
-
import { n as BaseButton_default, t as SettingsRow_default } from "./SettingsRow-
|
|
3
|
+
import { n as BaseButton_default, t as SettingsRow_default } from "./SettingsRow-BXFn8tCk.js";
|
|
4
4
|
import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, onUnmounted, openBlock, reactive, readonly, ref, renderList, renderSlot, toDisplayString, unref, useModel, watch, withCtx, withModifiers } from "vue";
|
|
5
5
|
import { Check, Languages } from "lucide-vue-next";
|
|
6
6
|
//#region src/app/AuthForm.vue?vue&type=script&setup=true&lang.ts
|
|
@@ -216,7 +216,7 @@ var FabButton_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
|
|
|
216
216
|
}, [createElementVNode("span", _hoisted_2$3, [renderSlot(_ctx.$slots, "default", {}, void 0, true)]), createElementVNode("span", _hoisted_3$3, toDisplayString(__props.label), 1)])]);
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
|
-
}), [["__scopeId", "data-v-
|
|
219
|
+
}), [["__scopeId", "data-v-3bd0d80a"]]);
|
|
220
220
|
//#endregion
|
|
221
221
|
//#region src/app/LocaleSheet.vue?vue&type=script&setup=true&lang.ts
|
|
222
222
|
var _hoisted_1$3 = { class: "text-ink-soft text-sm" };
|
|
@@ -316,7 +316,7 @@ var OfflineBanner_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @
|
|
|
316
316
|
});
|
|
317
317
|
};
|
|
318
318
|
}
|
|
319
|
-
}), [["__scopeId", "data-v-
|
|
319
|
+
}), [["__scopeId", "data-v-0f6db9dc"]]);
|
|
320
320
|
//#endregion
|
|
321
321
|
//#region src/app/TabShell.vue?vue&type=script&setup=true&lang.ts
|
|
322
322
|
var _hoisted_1$1 = { class: "rk-screen" };
|
|
@@ -369,7 +369,7 @@ var TabShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PUR
|
|
|
369
369
|
return openBlock(), createElementBlock("div", _hoisted_1$1, [_ctx.$slots.aside ? (openBlock(), createElementBlock("aside", _hoisted_2$1, [renderSlot(_ctx.$slots, "aside", {}, void 0, true)])) : createCommentVNode("", true), createElementVNode("div", _hoisted_3$1, [renderSlot(_ctx.$slots, "chrome", {}, void 0, true), renderSlot(_ctx.$slots, "default", {}, void 0, true)])]);
|
|
370
370
|
};
|
|
371
371
|
}
|
|
372
|
-
}), [["__scopeId", "data-v-
|
|
372
|
+
}), [["__scopeId", "data-v-211d6411"]]);
|
|
373
373
|
//#endregion
|
|
374
374
|
//#region src/app/TourShell.vue?vue&type=script&setup=true&lang.ts
|
|
375
375
|
var _hoisted_1 = ["aria-label"];
|
|
@@ -479,7 +479,7 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
|
|
|
479
479
|
class: "group flex flex-1 items-center py-2.5",
|
|
480
480
|
onClick: ($event) => _ctx.$emit("goTo", position - 1)
|
|
481
481
|
}, {
|
|
482
|
-
default: withCtx(() => [createElementVNode("span", { class: normalizeClass(["h-1 w-full rounded-full transition-colors duration-
|
|
482
|
+
default: withCtx(() => [createElementVNode("span", { class: normalizeClass(["h-1 w-full rounded-full transition-colors duration-(--duration-slow)", position - 1 <= __props.index ? "bg-primary" : "bg-hair group-hover:bg-ink-soft/40"]) }, null, 2)]),
|
|
483
483
|
_: 2
|
|
484
484
|
}, 1032, [
|
|
485
485
|
"aria-selected",
|
|
@@ -506,7 +506,7 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
|
|
|
506
506
|
})], 8, ["to"]);
|
|
507
507
|
};
|
|
508
508
|
}
|
|
509
|
-
}), [["__scopeId", "data-v-
|
|
509
|
+
}), [["__scopeId", "data-v-3bbf95fc"]]);
|
|
510
510
|
//#endregion
|
|
511
511
|
//#region src/app/guards.ts
|
|
512
512
|
/**
|
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: #fff;\n background: var(--color-primary);\n transition: transform 150ms;\n /* A ring in the canvas colour separates it from whatever scrolls behind. */\n box-shadow:\n 0 0 0 4px var(--color-canvas),\n 0 10px 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);\n}\n\n.rk-fab:active {\n transform: scale(0.95);\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: #fff;\n background: var(--color-primary);\n transition: transform 150ms;\n /* A ring in the canvas colour separates it from whatever scrolls behind. */\n box-shadow:\n 0 0 0 4px var(--color-canvas),\n 0 10px 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);\n}\n\n.rk-fab:active {\n transform: scale(0.95);\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: 0 4px 6px -1px rgb(0 0 0 / 0.1);\n backdrop-filter: blur(4px);\n}\n\n.rk-offline-enter-active,\n.rk-offline-leave-active {\n transition:\n opacity 200ms ease,\n transform 200ms cubic-bezier(0.32, 0.72, 0, 1);\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: 0 4px 6px -1px rgb(0 0 0 / 0.1);\n backdrop-filter: blur(4px);\n}\n\n.rk-offline-enter-active,\n.rk-offline-leave-active {\n transition:\n opacity 200ms ease,\n transform 200ms cubic-bezier(0.32, 0.72, 0, 1);\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 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}\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: var(--color-surface);\n}\n\n@media (min-width: 48rem) {\n .rk-shell {\n border: 1px solid var(--color-hair);\n border-radius: var(--radius-shell);\n box-shadow:\n 0 20px 25px -5px rgb(0 0 0 / 0.1),\n 0 8px 10px -6px rgb(0 0 0 / 0.1);\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 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}\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: var(--color-surface);\n}\n\n@media (min-width: 48rem) {\n .rk-shell {\n border: 1px solid var(--color-hair);\n border-radius: var(--radius-shell);\n box-shadow:\n 0 20px 25px -5px rgb(0 0 0 / 0.1),\n 0 8px 10px -6px rgb(0 0 0 / 0.1);\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-300\"\n :class=\"\n position - 1 <= index ? 'bg-primary' : 'bg-hair group-hover:bg-ink-soft/40'\n \"\n />\n </BaseButton>\n </div>\n\n <div class=\"flex gap-2\">\n <BaseButton\n v-if=\"index > 0\"\n variant=\"ghost\"\n class=\"shrink-0 px-5\"\n @click=\"$emit('back')\"\n >\n {{ backLabel }}\n </BaseButton>\n\n <BaseButton class=\"flex-1\" @click=\"$emit('next')\">\n {{ isLast ? lastLabel : nextLabel }}\n </BaseButton>\n </div>\n </footer>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n/* The dialog's own fade. The *slide* transition is not here: a scoped rule\n cannot reach slot content, which belongs to the caller's scope, so\n `.tour-forward-*` and `.tour-backward-*` ship unscoped in\n `rei-kit/shell/mobile.css` where the caller's slide can actually see them. */\n.tour-enter-active,\n.tour-leave-active {\n transition: opacity 220ms ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed, nextTick, onUnmounted, ref, watch } from 'vue'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-300\"\n :class=\"\n position - 1 <= index ? 'bg-primary' : 'bg-hair group-hover:bg-ink-soft/40'\n \"\n />\n </BaseButton>\n </div>\n\n <div class=\"flex gap-2\">\n <BaseButton\n v-if=\"index > 0\"\n variant=\"ghost\"\n class=\"shrink-0 px-5\"\n @click=\"$emit('back')\"\n >\n {{ backLabel }}\n </BaseButton>\n\n <BaseButton class=\"flex-1\" @click=\"$emit('next')\">\n {{ isLast ? lastLabel : nextLabel }}\n </BaseButton>\n </div>\n </footer>\n </div>\n </div>\n </Transition>\n </Teleport>\n</template>\n\n<style scoped>\n/* The dialog's own fade. The *slide* transition is not here: a scoped rule\n cannot reach slot content, which belongs to the caller's scope, so\n `.tour-forward-*` and `.tour-backward-*` ship unscoped in\n `rei-kit/shell/mobile.css` where the caller's slide can actually see them. */\n.tour-enter-active,\n.tour-leave-active {\n transition: opacity 220ms ease;\n}\n\n.tour-enter-from,\n.tour-leave-to {\n opacity: 0;\n}\n</style>\n","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'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-(--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'\n\n/**\n * The frame an onboarding guide runs inside.\n *\n * Both phone apps had this, 296 lines, 94% identical — and what differed was\n * every part that should: the slides, the wash colours, the illustrations. What\n * did not differ is here.\n *\n * The parts that are easy to get wrong and were written twice:\n *\n * - **`inert` on the app behind it.** Without it, Tab walks into a screen the\n * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone\n * on unmount or the whole app stays inert forever.\n * - **Focusing the dialog**, which is the only reason the arrow keys work.\n * - **The direction.** A guide that can jump to slide two from slide seven has\n * to animate backwards, so the transition name follows the index rather than\n * the button that was pressed.\n * - **A segmented track, not dots.** Ten slides is a sequence with a length,\n * and the reader deserves to see how much is left.\n *\n * Every string is a prop and the slide is a slot: the kit renders the frame and\n * knows nothing about what is being explained.\n */\nconst {\n index,\n total,\n dialogLabel,\n skipLabel,\n backLabel,\n nextLabel,\n lastLabel,\n stepLabel,\n teleportTo = 'body',\n} = defineProps<{\n /** Which slide, zero-based. */\n index: number\n total: number\n /** The dialog's accessible name. */\n dialogLabel: string\n skipLabel: string\n backLabel: string\n nextLabel: string\n /** The button on the final slide — \"Start\", rather than \"Next\". */\n lastLabel: string\n /** Names one segment for a screen reader, e.g. `(n) => \\`Step ${n} of ${total}\\``. */\n stepLabel: (position: number) => string\n /**\n * Where the dialog goes. A phone shell clips its children, so the guide has\n * to leave the tree to cover the tab bar and the header alike.\n */\n teleportTo?: string | undefined\n}>()\n\nconst open = defineModel<boolean>({ required: true })\n\nconst emit = defineEmits<{ next: []; back: []; dismiss: []; goTo: [position: number] }>()\n\nconst dialog = ref<HTMLElement | null>(null)\n\n/** Direction, so a jump backwards still animates backwards. */\nconst transitionName = ref('tour-forward')\n\nwatch(\n () => index,\n (next, previous) => {\n transitionName.value = next >= previous ? 'tour-forward' : 'tour-backward'\n },\n)\n\nconst isLast = computed(() => index >= total - 1)\n\n/* `immediate`, because a layout that mounts this only once the guide has been\n asked for would otherwise have the first `true` predate the watcher. */\nwatch(\n open,\n async (isOpen) => {\n if (typeof document === 'undefined') return\n\n document.getElementById('app')?.toggleAttribute('inert', isOpen)\n if (!isOpen) return\n\n await nextTick()\n dialog.value?.focus()\n },\n { immediate: true },\n)\n\nonUnmounted(() => {\n if (typeof document !== 'undefined') document.getElementById('app')?.removeAttribute('inert')\n})\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.key === 'ArrowRight') emit('next')\n if (event.key === 'ArrowLeft') emit('back')\n if (event.key === 'Escape') emit('dismiss')\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTo\">\n <Transition name=\"tour\" appear>\n <div\n v-if=\"open\"\n ref=\"dialog\"\n class=\"fixed inset-0 z-[60] flex items-center justify-center outline-none\"\n role=\"dialog\"\n aria-modal=\"true\"\n :aria-label=\"dialogLabel\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n >\n <div class=\"shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden\">\n <!-- Behind everything: the app's mood for this slide. -->\n <slot name=\"wash\" />\n\n <header class=\"relative flex shrink-0 items-center justify-between gap-3 px-6 pt-6\">\n <span class=\"text-ink-soft text-xs font-semibold tabular-nums\">\n {{ stepLabel(index + 1) }}\n </span>\n\n <BaseButton pill size=\"sm\" variant=\"quiet\" @click=\"$emit('dismiss')\">\n {{ skipLabel }}\n </BaseButton>\n </header>\n\n <!-- min-h-0 keeps the body inside the shell, so a long slide scrolls\n here rather than pushing the buttons off the bottom. -->\n <div\n class=\"relative flex min-h-0 flex-1 flex-col justify-center overflow-y-auto px-6 py-6\"\n >\n <Transition :name=\"transitionName\" mode=\"out-in\">\n <slot :index=\"index\" />\n </Transition>\n </div>\n\n <footer\n class=\"relative flex shrink-0 flex-col gap-4 px-6 pt-4 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]\"\n >\n <!-- A segmented track rather than dots: ten slides is a sequence\n with a length, and the reader deserves to see how much is left. -->\n <div\n class=\"-my-2 flex items-center gap-1\"\n role=\"tablist\"\n :aria-label=\"stepLabel(index + 1)\"\n >\n <BaseButton\n v-for=\"position in total\"\n :key=\"position\"\n variant=\"unstyled\"\n role=\"tab\"\n :aria-selected=\"position - 1 === index\"\n :aria-label=\"stepLabel(position)\"\n class=\"group flex flex-1 items-center py-2.5\"\n @click=\"$emit('goTo', position - 1)\"\n >\n <span\n class=\"h-1 w-full rounded-full transition-colors duration-(--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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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export { formatDate, setFormatLocale } from './utils/format';
|
|
|
26
26
|
export { relativeDayLabel } from './utils/day-label';
|
|
27
27
|
export type { DayLabels } from './utils/day-label';
|
|
28
28
|
export { downloadJson } from './utils/download';
|
|
29
|
+
export { MATERIALS, PALETTES, applyMaterial, applyPalette, isMaterial, isPaletteName, setMaterialStorageKey, setPaletteStorageKey, useMaterial, usePalette, } from './utils/appearance';
|
|
30
|
+
export type { Material, Palette, PaletteName, PaletteRoles } from './utils/appearance';
|
|
29
31
|
export { ensureSheetRoot, SHEET_ROOT_ID } from './utils/sheet-root';
|
|
30
32
|
export { safeRedirect, toRedirectPath } from './utils/redirect';
|
|
31
33
|
export type { QueryValue } from './utils/redirect';
|