rei-kit 0.12.1 → 0.14.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.
@@ -0,0 +1,217 @@
1
+ import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, mergeProps, normalizeClass, openBlock, renderSlot, resolveDynamicComponent, toDisplayString, unref, withCtx } from "vue";
2
+ import { ChevronRight } from "lucide-vue-next";
3
+ //#region src/components/BaseButton.vue?vue&type=script&setup=true&lang.ts
4
+ var _hoisted_1$1 = {
5
+ key: 0,
6
+ class: "size-4 animate-spin rounded-full border-2 border-current border-t-transparent",
7
+ "aria-hidden": "true"
8
+ };
9
+ //#endregion
10
+ //#region src/components/BaseButton.vue
11
+ var BaseButton_default = /* @__PURE__ */ defineComponent({
12
+ __name: "BaseButton",
13
+ props: {
14
+ as: { default: "button" },
15
+ variant: { default: "primary" },
16
+ size: { default: "md" },
17
+ loading: {
18
+ type: Boolean,
19
+ default: false
20
+ },
21
+ disabled: {
22
+ type: Boolean,
23
+ default: false
24
+ },
25
+ type: { default: "button" },
26
+ icon: {
27
+ type: Boolean,
28
+ default: false
29
+ },
30
+ block: {
31
+ type: Boolean,
32
+ default: false
33
+ },
34
+ to: { default: () => void 0 },
35
+ href: { default: () => void 0 },
36
+ pill: {
37
+ type: Boolean,
38
+ default: false
39
+ },
40
+ pressed: {
41
+ type: Boolean,
42
+ default: () => void 0
43
+ }
44
+ },
45
+ setup(__props) {
46
+ const VARIANT_CLASS = {
47
+ primary: "bg-primary text-white hover:bg-primary/90",
48
+ secondary: "border-hair bg-surface text-ink border hover:bg-muted",
49
+ ghost: "bg-transparent text-ink hover:bg-muted",
50
+ destructive: "bg-transparent text-ink-soft hover:text-negative",
51
+ row: "w-full justify-start text-left bg-transparent text-ink hover:bg-muted",
52
+ quiet: "bg-transparent text-ink-soft hover:bg-muted hover:text-ink",
53
+ danger: "bg-negative text-white hover:bg-negative/90",
54
+ positive: "bg-positive text-white hover:bg-positive/90",
55
+ warning: "bg-warning text-white hover:bg-warning/90",
56
+ accent: "bg-accent text-white hover:bg-accent/90",
57
+ link: "bg-transparent underline underline-offset-2 hover:opacity-80",
58
+ unstyled: ""
59
+ };
60
+ const SIZE_CLASS = {
61
+ xs: "h-8 px-3 text-xs",
62
+ sm: "h-9 px-3 text-sm",
63
+ md: "h-11 px-4 text-base",
64
+ lg: "h-14 px-6 text-lg"
65
+ };
66
+ const ICON_SIZE_CLASS = {
67
+ xs: "size-8 text-xs",
68
+ sm: "size-9 text-sm",
69
+ md: "size-11 text-base",
70
+ lg: "size-14 text-lg"
71
+ };
72
+ const ROW_SIZE_CLASS = {
73
+ xs: "px-2 py-1.5 text-xs",
74
+ sm: "px-3 py-2 text-sm",
75
+ md: "px-3 py-2.5 text-base",
76
+ lg: "px-4 py-3 text-lg"
77
+ };
78
+ const LINK_SIZE_CLASS = {
79
+ xs: "text-xs",
80
+ sm: "text-sm",
81
+ md: "text-base",
82
+ lg: "text-lg"
83
+ };
84
+ const sizing = computed(() => {
85
+ if (__props.variant === "unstyled") return "";
86
+ if (__props.variant === "link") return LINK_SIZE_CLASS[__props.size];
87
+ if (__props.variant === "row") return ROW_SIZE_CLASS[__props.size];
88
+ return __props.icon ? ICON_SIZE_CLASS[__props.size] : SIZE_CLASS[__props.size];
89
+ });
90
+ const PRESSED_CLASS = {
91
+ ghost: "bg-primary text-white hover:bg-primary/90",
92
+ quiet: "bg-primary text-white hover:bg-primary/90",
93
+ secondary: "bg-primary border-primary text-white hover:bg-primary/90",
94
+ row: "w-full justify-start text-left bg-muted text-ink hover:bg-muted"
95
+ };
96
+ const surface = computed(() => {
97
+ if (__props.pressed === true) return PRESSED_CLASS[__props.variant] ?? VARIANT_CLASS[__props.variant];
98
+ if (__props.variant === "destructive") return `${VARIANT_CLASS.destructive} hover:bg-negative/10`;
99
+ return VARIANT_CLASS[__props.variant];
100
+ });
101
+ const shell = computed(() => {
102
+ if (__props.variant === "unstyled") return "";
103
+ const feel = "transition-[transform,color,background-color,border-color] duration-100 select-none";
104
+ if (__props.variant === "row") return `inline-flex items-center gap-2 font-medium ${feel}`;
105
+ return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-95`;
106
+ });
107
+ const radius = computed(() => {
108
+ if (__props.variant === "unstyled") return "";
109
+ if (__props.variant === "link") return "rounded-xs";
110
+ return __props.pill ? "rounded-full" : "rounded-card";
111
+ });
112
+ /** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */
113
+ const inactive = computed(() => __props.disabled || __props.loading);
114
+ const linkProps = computed(() => {
115
+ if (__props.as === "router-link") return { to: __props.to };
116
+ if (__props.as === "a") return inactive.value ? {} : { href: __props.href };
117
+ return {};
118
+ });
119
+ return (_ctx, _cache) => {
120
+ return openBlock(), createBlock(resolveDynamicComponent(__props.as), mergeProps(linkProps.value, {
121
+ type: __props.as === "button" ? __props.type : void 0,
122
+ disabled: __props.as === "button" ? inactive.value : void 0,
123
+ "aria-disabled": __props.as !== "button" && inactive.value ? "true" : void 0,
124
+ "aria-busy": __props.loading,
125
+ "aria-pressed": __props.pressed === void 0 ? void 0 : String(__props.pressed),
126
+ 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", [
127
+ shell.value,
128
+ surface.value,
129
+ sizing.value,
130
+ radius.value,
131
+ __props.block ? "w-full" : ""
132
+ ]]
133
+ }), {
134
+ default: withCtx(() => [__props.loading ? (openBlock(), createElementBlock("span", _hoisted_1$1)) : createCommentVNode("", true), renderSlot(_ctx.$slots, "default")]),
135
+ _: 3
136
+ }, 16, [
137
+ "type",
138
+ "disabled",
139
+ "aria-disabled",
140
+ "aria-busy",
141
+ "aria-pressed",
142
+ "class"
143
+ ]);
144
+ };
145
+ }
146
+ });
147
+ //#endregion
148
+ //#region \0plugin-vue:export-helper
149
+ var _plugin_vue_export_helper_default = (sfc, props) => {
150
+ const target = sfc.__vccOpts || sfc;
151
+ for (const [key, val] of props) target[key] = val;
152
+ return target;
153
+ };
154
+ //#endregion
155
+ //#region src/components/SettingsRow.vue?vue&type=script&setup=true&lang.ts
156
+ var _hoisted_1 = { class: "flex items-center gap-3" };
157
+ var _hoisted_2 = {
158
+ key: 0,
159
+ class: "bg-muted text-ink-soft flex size-9 shrink-0 items-center justify-center rounded-xl",
160
+ "aria-hidden": "true"
161
+ };
162
+ var _hoisted_3 = { class: "min-w-0 flex-1" };
163
+ var _hoisted_4 = { class: "text-ink text-sm font-medium" };
164
+ var _hoisted_5 = {
165
+ key: 0,
166
+ class: "text-ink-soft mt-0.5 text-xs leading-snug"
167
+ };
168
+ var _hoisted_6 = {
169
+ key: 1,
170
+ class: "shrink-0"
171
+ };
172
+ var _hoisted_7 = { key: 0 };
173
+ //#endregion
174
+ //#region src/components/SettingsRow.vue
175
+ var SettingsRow_default = /* @__PURE__ */ defineComponent({
176
+ __name: "SettingsRow",
177
+ props: {
178
+ label: {},
179
+ description: { default: "" },
180
+ icon: { default: () => void 0 },
181
+ interactive: {
182
+ type: Boolean,
183
+ default: false
184
+ },
185
+ stacked: {
186
+ type: Boolean,
187
+ default: false
188
+ }
189
+ },
190
+ emits: ["click"],
191
+ setup(__props, { emit: __emit }) {
192
+ const emit = __emit;
193
+ return (_ctx, _cache) => {
194
+ return openBlock(), createBlock(resolveDynamicComponent(__props.interactive ? "button" : "div"), {
195
+ type: __props.interactive ? "button" : void 0,
196
+ class: normalizeClass(["flex w-full items-center gap-3 px-4 py-3 text-left", [__props.interactive ? "hover:bg-muted/60 transition-colors active:scale-[0.99]" : "", __props.stacked ? "flex-col items-stretch gap-3" : ""]]),
197
+ onClick: _cache[0] || (_cache[0] = ($event) => __props.interactive && emit("click"))
198
+ }, {
199
+ default: withCtx(() => [createElementVNode("div", _hoisted_1, [
200
+ __props.icon ? (openBlock(), createElementBlock("span", _hoisted_2, [(openBlock(), createBlock(resolveDynamicComponent(__props.icon), { class: "size-[18px]" }))])) : createCommentVNode("", true),
201
+ createElementVNode("div", _hoisted_3, [createElementVNode("p", _hoisted_4, toDisplayString(__props.label), 1), __props.description ? (openBlock(), createElementBlock("p", _hoisted_5, toDisplayString(__props.description), 1)) : createCommentVNode("", true)]),
202
+ !__props.stacked ? (openBlock(), createElementBlock("div", _hoisted_6, [renderSlot(_ctx.$slots, "default")])) : createCommentVNode("", true),
203
+ __props.interactive ? (openBlock(), createBlock(unref(ChevronRight), {
204
+ key: 2,
205
+ class: "text-ink-soft size-4 shrink-0",
206
+ "aria-hidden": "true"
207
+ })) : createCommentVNode("", true)
208
+ ]), __props.stacked ? (openBlock(), createElementBlock("div", _hoisted_7, [renderSlot(_ctx.$slots, "default")])) : createCommentVNode("", true)]),
209
+ _: 3
210
+ }, 8, ["type", "class"]);
211
+ };
212
+ }
213
+ });
214
+ //#endregion
215
+ export { _plugin_vue_export_helper_default as n, BaseButton_default as r, SettingsRow_default as t };
216
+
217
+ //# sourceMappingURL=SettingsRow-MnVleeTR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SettingsRow-MnVleeTR.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-white hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'border-hair bg-surface text-ink border hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-white hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-white hover:bg-positive/90',\n warning: 'bg-warning text-white hover:bg-warning/90',\n accent: 'bg-accent text-white hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-white hover:bg-primary/90',\n quiet: 'bg-primary text-white hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-white hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel = 'transition-[transform,color,background-color,border-color] duration-100 select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-95`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-xs'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\n\n/**\n * The kit's button, and — when asked — its link.\n *\n * `as` exists because a button and a link are the same shape and a different\n * element, and the app was resolving that by nesting them: a consumer had\n * `<RouterLink><BaseButton>` in every call to action, which is an `<a>` around\n * a `<button>`. That is invalid HTML, two stops in the tab order and two\n * controls to a screen reader, for one thing on the screen. Whether something\n * navigates is the app's decision; carrying it is this component's job.\n *\n * `router-link` is resolved by name rather than imported, so `vue-router` stays\n * the optional peer it is. Only an app that passes `as=\"router-link\"` needs it,\n * and an app that passes it has it.\n */\nconst {\n as = 'button',\n variant = 'primary',\n size = 'md',\n loading = false,\n disabled = false,\n type = 'button',\n icon = false,\n block = false,\n pill = false,\n pressed = undefined,\n to = undefined,\n href = undefined,\n} = defineProps<{\n /** What to render. `button` unless this navigates. */\n as?: 'button' | 'a' | 'router-link' | undefined\n /**\n * `link` is a real action that should read as text — \"clear this note\",\n * \"remove\", \"change category\". It has no surface at all, so it also has no\n * height and no padding: giving it either would make it a ghost button,\n * which is a different thing and was already here.\n */\n variant?:\n | 'primary'\n | 'secondary'\n | 'ghost'\n | 'quiet'\n | 'destructive'\n | 'row'\n | 'danger'\n | 'positive'\n | 'warning'\n | 'accent'\n | 'link'\n | 'unstyled'\n | undefined\n /** `xs` is the action inside a prompt or a nudge, not on a page. */\n size?: 'xs' | 'sm' | 'md' | 'lg' | undefined\n loading?: boolean | undefined\n disabled?: boolean | undefined\n /** Ignored unless `as` is `button`. */\n type?: 'button' | 'submit' | undefined\n /**\n * Square, sized to its icon, with no label beside it.\n *\n * **Pass `aria-label`.** An icon on its own has no accessible name, and a\n * control a screen reader announces as \"button\" is not usable. Attributes\n * fall through, so `aria-label` lands where it should — nothing here can\n * check that you passed one, which is why it is said this loudly.\n */\n icon?: boolean | undefined\n /** Fills its container. The ordinary case under a form. */\n block?: boolean | undefined\n /** For `as=\"router-link\"`. */\n to?: string | Record<string, unknown> | undefined\n /** For `as=\"a\"`. */\n href?: string | undefined\n /**\n * Fully rounded rather than card-cornered.\n *\n * Every install prompt, update prompt and nudge across the apps used the\n * same pair — a filled pill to act and a quiet one to dismiss — and none of\n * them could use this component, because it only knew one corner radius.\n */\n pill?: boolean | undefined\n /**\n * That this button is a switch, and whether it is on.\n *\n * Omit it and the button is an action. Pass it and the button becomes a\n * toggle: `aria-pressed` is written, and the variants that have an \"off\"\n * look — ghost, quiet, secondary — take a filled one when on.\n *\n * There were 18 of these hand-written across the three apps, every one a\n * picker cell or a filter chip, and almost none of them said `aria-pressed`\n * at all. A screen reader met a row of identical buttons with no way to know\n * which was chosen.\n */\n pressed?: boolean | undefined\n}>()\n\nconst VARIANT_CLASS = {\n primary: 'bg-primary text-white hover:bg-primary/90',\n /*\n * An action that is real but not the one being urged.\n *\n * `ghost` had been standing in for this and cannot: with no border and no\n * fill it reads as text, so \"Save draft\" sitting next to \"Publish\" looked\n * like a caption rather than the other half of a choice. Ghost is for a\n * control that should recede until it is wanted — a toolbar, a menu row —\n * and that is a different job.\n */\n secondary: 'border-hair bg-surface text-ink border hover:bg-muted',\n ghost: 'bg-transparent text-ink hover:bg-muted',\n /*\n * Quiet until you reach for it, and then plainly destructive: a delete at\n * the end of a row, a \"remove this note\", an archive.\n *\n * Not `danger`, which is filled and shouts before it is needed — a red\n * button in a list of rows makes the list look like a warning. And not\n * `quiet` with a `hover:text-negative` class beside it, which is how all\n * three apps were doing it: that class and the variant's own\n * `hover:text-ink` set the same property at the same specificity, so which\n * one wins depends on the order they happen to land in the stylesheet.\n *\n * Fifteen of these across the three apps, and every one of them was that\n * coin toss.\n */\n destructive: 'bg-transparent text-ink-soft hover:text-negative',\n /*\n * A line in a list that is also a control: a settings row, a node in a tree,\n * a heading that opens something.\n *\n * Full width, aligned to the start, and a hover that fills the whole line\n * rather than a box inside it. Every app had written this — `.tree-row`,\n * `.row`, `.header-action` — because a button that centres its content\n * cannot be a row, and the alignment is the only thing that had to change.\n *\n * Padding stays the app's: a menu row and a tree node are not the same\n * height, and the kit has no opinion about which one this is.\n */\n row: 'w-full justify-start text-left bg-transparent text-ink hover:bg-muted',\n /*\n * The control that is present without asking for attention: a dismiss beside\n * an install prompt, a chevron beside a month, a delete at the end of a row.\n *\n * `ghost` is not this. Ghost keeps full-strength ink; this one starts soft\n * and darkens, which is the difference between a control waiting to be used\n * and one that is merely available. The pair `text-ink-soft hover:text-ink`\n * was hand-written 47 times across the three apps.\n *\n * It fills on hover, and 0.11.0 got that half-right by fill... only for\n * icons. The evidence said otherwise once the third app was read: an editor\n * toolbar's buttons carry text and fill exactly the same way. The shape is\n * \"a control in a strip\", not \"a control with a glyph in it\". A text action\n * that should have no surface at all is `link`.\n */\n quiet: 'bg-transparent text-ink-soft hover:bg-muted hover:text-ink',\n danger: 'bg-negative text-white hover:bg-negative/90',\n /*\n * The rest of the roles the kit already declares.\n *\n * `tokens.css` names five colour roles and this component exposed two of\n * them, so an app that wanted a success-coloured action had to hand-write\n * the button — which is what Hibi's green install button is. A component\n * that cannot use a role its own design system declares is not avoiding a\n * guess; it is incomplete.\n */\n positive: 'bg-positive text-white hover:bg-positive/90',\n warning: 'bg-warning text-white hover:bg-warning/90',\n accent: 'bg-accent text-white hover:bg-accent/90',\n /* No fill, no border, no box: underlined so it is still obviously a control\n without one. `ghost` cannot stand in — it has a hover surface and a\n radius, so it reads as a button that happens to be empty. */\n link: 'bg-transparent underline underline-offset-2 hover:opacity-80',\n /*\n * Everything this component is, except the paint.\n *\n * The reason it exists is measurable: across the three apps there were 58\n * raw `<button>` elements sitting in 24 files that already imported and used\n * `BaseButton`. The developer reached for the kit and gave up halfway down\n * the same file — because the kit offered all of its appearance or none of\n * itself, and what those places needed was everything but the appearance.\n *\n * A picker cell, a chip, a calendar day: the surface is the app's, and it\n * should be. The element, the focus ring, the disabled handling, the\n * `aria-pressed` bookkeeping and the `as` switch are not, and were being\n * rewritten every time — usually without the focus ring.\n */\n unstyled: '',\n} as const\n\n/* Two scales, because a square control cannot take horizontal padding and\n still be square. `lg` is here for a wide page's call to action: a 44px\n button is right under a thumb and undersized under a headline. */\nconst SIZE_CLASS = {\n xs: 'h-8 px-3 text-xs',\n sm: 'h-9 px-3 text-sm',\n md: 'h-11 px-4 text-base',\n lg: 'h-14 px-6 text-lg',\n} as const\n\nconst ICON_SIZE_CLASS = {\n xs: 'size-8 text-xs',\n sm: 'size-9 text-sm',\n md: 'size-11 text-base',\n lg: 'size-14 text-lg',\n} as const\n\n/* A row is sized by its padding, not by a height. A settings line holds one\n line of text and a tree node can hold two, and a fixed height turns the\n second into an overflow. */\nconst ROW_SIZE_CLASS = {\n xs: 'px-2 py-1.5 text-xs',\n sm: 'px-3 py-2 text-sm',\n md: 'px-3 py-2.5 text-base',\n lg: 'px-4 py-3 text-lg',\n} as const\n\n/* A link takes the type size and nothing else. Height and padding are what\n make a surface, and this variant is the one without one. */\nconst LINK_SIZE_CLASS = {\n xs: 'text-xs',\n sm: 'text-sm',\n md: 'text-base',\n lg: 'text-lg',\n} as const\n\nconst sizing = computed(() => {\n // Unstyled owns no box, so it takes no size: the app's own classes decide.\n if (variant === 'unstyled') return ''\n if (variant === 'link') return LINK_SIZE_CLASS[size]\n if (variant === 'row') return ROW_SIZE_CLASS[size]\n return icon ? ICON_SIZE_CLASS[size] : SIZE_CLASS[size]\n})\n\n/* The variants with an \"off\" look, and what \"on\" looks like for them. The\n filled ones are already on; link and unstyled have no surface to fill. */\nconst PRESSED_CLASS: Partial<Record<string, string>> = {\n ghost: 'bg-primary text-white hover:bg-primary/90',\n quiet: 'bg-primary text-white hover:bg-primary/90',\n secondary: 'bg-primary border-primary text-white hover:bg-primary/90',\n /* A selected row is filled, not recoloured: the line stays a line, and the\n fill is what a list uses to say \"this one\". Filling it with the primary\n colour instead would make one row of a list shout. */\n row: 'w-full justify-start text-left bg-muted text-ink hover:bg-muted',\n}\n\nconst surface = computed(() => {\n if (pressed === true) return PRESSED_CLASS[variant] ?? VARIANT_CLASS[variant]\n\n /* Destructive tints its own fill rather than borrowing the neutral one: a\n red glyph on a grey wash reads as two different states at once. */\n if (variant === 'destructive') return `${VARIANT_CLASS.destructive} hover:bg-negative/10`\n\n return VARIANT_CLASS[variant]\n})\n\n/* Layout and feel, which unstyled does not impose either — but the focus ring\n and the disabled handling stay, because those are the floor. A raw <button>\n is what happens when a component makes them optional.\n \n Colour is in the transition, not just transform. Every variant here changes\n colour on hover and none of them animated it, so every button in every\n consuming app snapped while the hand-written controls beside them faded —\n `transition-colors` appears 106 times across the three apps, which is the\n convention this component was the only thing not following. */\nconst shell = computed(() => {\n if (variant === 'unstyled') return ''\n\n const feel = 'transition-[transform,color,background-color,border-color] duration-100 select-none'\n\n /* A row does not press. Scaling a full-width line looks like the list itself\n flinched, and every hand-written row in the apps animated colour only. */\n if (variant === 'row') return `inline-flex items-center gap-2 font-medium ${feel}`\n\n return `inline-flex items-center justify-center gap-2 font-medium ${feel} active:scale-95`\n})\n\nconst radius = computed(() => {\n if (variant === 'unstyled') return ''\n if (variant === 'link') return 'rounded-xs'\n return pill ? 'rounded-full' : 'rounded-card'\n})\n\n/** Anything that is not a `<button>` cannot be `disabled`; it has to be told. */\nconst inactive = computed(() => disabled || loading)\n\nconst linkProps = computed(() => {\n if (as === 'router-link') return { to }\n // The href is dropped rather than kept alongside aria-disabled: an anchor\n // without one is not focusable and not activatable, which is the whole of\n // what \"disabled\" means for a link.\n if (as === 'a') return inactive.value ? {} : { href }\n return {}\n})\n</script>\n\n<template>\n <component\n :is=\"as\"\n v-bind=\"linkProps\"\n :type=\"as === 'button' ? type : undefined\"\n :disabled=\"as === 'button' ? inactive : undefined\"\n :aria-disabled=\"as !== 'button' && inactive ? 'true' : undefined\"\n :aria-busy=\"loading\"\n :aria-pressed=\"pressed === undefined ? undefined : String(pressed)\"\n class=\"focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n :class=\"[shell, surface, sizing, radius, block ? 'w-full' : '']\"\n >\n <span\n v-if=\"loading\"\n class=\"size-4 animate-spin rounded-full border-2 border-current border-t-transparent\"\n aria-hidden=\"true\"\n />\n <slot />\n </component>\n</template>\n","<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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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The frame every sign-in screen sits in.
3
+ *
4
+ * A brand mark, a narrow column, and the language links pinned to the bottom.
5
+ * The two phone apps had this file character for character — thirty-seven
6
+ * lines, no difference at all — and the only thing either would want to change
7
+ * is what goes in the slots.
8
+ *
9
+ * The language links matter more than they look. Sign-in is the first screen a
10
+ * new user sees and Settings is behind it, so without a way to switch here,
11
+ * somebody who does not read the browser's language cannot get to one.
12
+ */
13
+ type __VLS_Slots = {
14
+ /** The brand mark. */
15
+ brand?: () => unknown;
16
+ /** The form. */
17
+ default: () => unknown;
18
+ /** The language links, or anything else that belongs at the foot. */
19
+ foot?: () => unknown;
20
+ };
21
+ declare const __VLS_base: import('vue').DefineComponent<{}, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
22
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
23
+ declare const _default: typeof __VLS_export;
24
+ export default _default;
25
+ type __VLS_WithSlots<T, S> = T & {
26
+ new (): {
27
+ $slots: S;
28
+ };
29
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Choosing the interface language, from a settings row.
3
+ *
4
+ * A sheet rather than a segmented control: past four options a row of pills
5
+ * stops being readable, and the list of languages only grows.
6
+ *
7
+ * Both phone apps had this, 96% identical. What differed was one colour class.
8
+ *
9
+ * The labels are the caller's, and they should be **endonyms** — a language is
10
+ * always listed in its own language, so someone who cannot read the current
11
+ * interface can still find theirs. The kit cannot know them.
12
+ */
13
+ type __VLS_Props = {
14
+ /** The settings row's label, and the sheet's title. */
15
+ label: string;
16
+ hint?: string | undefined;
17
+ /** What "follow the device" is called. Its value is `system`. */
18
+ systemLabel: string;
19
+ /** In the order they should be listed. Labels are endonyms. */
20
+ options: readonly {
21
+ value: string;
22
+ label: string;
23
+ }[];
24
+ closeLabel?: string | undefined;
25
+ };
26
+ type __VLS_ModelProps = {
27
+ modelValue: string;
28
+ };
29
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
30
+ declare const __VLS_export: import('vue').DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
31
+ "update:modelValue": (value: string) => any;
32
+ }, string, import('vue').PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
33
+ "onUpdate:modelValue"?: (value: string) => any;
34
+ }>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
35
+ declare const _default: typeof __VLS_export;
36
+ export default _default;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The frame an onboarding guide runs inside.
3
+ *
4
+ * Both phone apps had this, 296 lines, 94% identical — and what differed was
5
+ * every part that should: the slides, the wash colours, the illustrations. What
6
+ * did not differ is here.
7
+ *
8
+ * The parts that are easy to get wrong and were written twice:
9
+ *
10
+ * - **`inert` on the app behind it.** Without it, Tab walks into a screen the
11
+ * reader cannot see. The same trick `BaseSheet` uses, and it has to be undone
12
+ * on unmount or the whole app stays inert forever.
13
+ * - **Focusing the dialog**, which is the only reason the arrow keys work.
14
+ * - **The direction.** A guide that can jump to slide two from slide seven has
15
+ * to animate backwards, so the transition name follows the index rather than
16
+ * the button that was pressed.
17
+ * - **A segmented track, not dots.** Ten slides is a sequence with a length,
18
+ * and the reader deserves to see how much is left.
19
+ *
20
+ * Every string is a prop and the slide is a slot: the kit renders the frame and
21
+ * knows nothing about what is being explained.
22
+ */
23
+ type __VLS_Props = {
24
+ /** Which slide, zero-based. */
25
+ index: number;
26
+ total: number;
27
+ /** The dialog's accessible name. */
28
+ dialogLabel: string;
29
+ skipLabel: string;
30
+ backLabel: string;
31
+ nextLabel: string;
32
+ /** The button on the final slide — "Start", rather than "Next". */
33
+ lastLabel: string;
34
+ /** Names one segment for a screen reader, e.g. `(n) => \`Step ${n} of ${total}\``. */
35
+ stepLabel: (position: number) => string;
36
+ /**
37
+ * Where the dialog goes. A phone shell clips its children, so the guide has
38
+ * to leave the tree to cover the tab bar and the header alike.
39
+ */
40
+ teleportTo?: string | undefined;
41
+ };
42
+ type __VLS_ModelProps = {
43
+ modelValue: boolean;
44
+ };
45
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
46
+ declare var __VLS_13: {}, __VLS_29: {
47
+ index: number;
48
+ };
49
+ type __VLS_Slots = {} & {
50
+ wash?: (props: typeof __VLS_13) => any;
51
+ } & {
52
+ default?: (props: typeof __VLS_29) => any;
53
+ };
54
+ declare const __VLS_base: import('vue').DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
55
+ "update:modelValue": (value: boolean) => any;
56
+ dismiss: () => any;
57
+ next: () => any;
58
+ back: () => any;
59
+ goTo: (position: number) => any;
60
+ }, string, import('vue').PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
61
+ "onUpdate:modelValue"?: (value: boolean) => any;
62
+ onDismiss?: () => any;
63
+ onNext?: () => any;
64
+ onBack?: () => any;
65
+ onGoTo?: (position: number) => any;
66
+ }>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
67
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
68
+ declare const _default: typeof __VLS_export;
69
+ export default _default;
70
+ type __VLS_WithSlots<T, S> = T & {
71
+ new (): {
72
+ $slots: S;
73
+ };
74
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * rei-kit/app — the parts a phone app is made of.
3
+ *
4
+ * Separate from the main entry because these are not primitives: they assume
5
+ * an app with tabs, an account and a sign-in screen. A wide site importing the
6
+ * kit should not have to know they exist.
7
+ *
8
+ * Everything here came out of two apps that had written it identically —
9
+ * `AuthShell` was thirty-seven lines with no difference at all between them,
10
+ * the tab transition thirty-four. What differs between two phone apps is the
11
+ * product; this is the part underneath it.
12
+ */
13
+ export { default as AuthShell } from './AuthShell.vue';
14
+ export { default as LocaleSheet } from './LocaleSheet.vue';
15
+ export { default as TourShell } from './TourShell.vue';
16
+ export { createTabTransition } from './use-tab-transition';
17
+ export type { SlideDirection } from './use-tab-transition';
18
+ export { useThemeSync } from './use-theme-sync';
@@ -0,0 +1,52 @@
1
+ /** Which way the screens slide during a tab change. */
2
+ export type SlideDirection = 'forward' | 'backward' | 'none';
3
+ /**
4
+ * Which way a tabbed app is moving.
5
+ *
6
+ * A phone app slides sideways between its tabs, and the direction has to come
7
+ * from somewhere: going from the second tab to the fourth is forward, the
8
+ * other way is back, and arriving from nowhere is neither. That is index
9
+ * arithmetic over the tab order, and it was written twice, identically, in the
10
+ * two phone apps this kit came from — thirty-four lines each, byte for byte
11
+ * the same.
12
+ *
13
+ * Generic over the tab key, so the app keeps its own union and the kit never
14
+ * learns what a tab is called.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // shared/lib/tabs.ts
19
+ * export const tabs = createTabTransition(['today', 'week', 'year', 'profile'] as const)
20
+ *
21
+ * // the router guard
22
+ * router.afterEach((to, from) => tabs.resolve(to.meta.tab, from.meta.tab))
23
+ *
24
+ * // App.vue
25
+ * const name = computed(() =>
26
+ * tabs.direction.value === 'none' ? '' : `slide-${tabs.direction.value}`,
27
+ * )
28
+ * ```
29
+ *
30
+ * The `slide-forward-*` and `slide-backward-*` classes those names refer to
31
+ * ship in `rei-kit/shell/mobile.css`.
32
+ */
33
+ export declare function createTabTransition<K extends string>(order: readonly K[]): {
34
+ /** Direction of the current tab change. Read by the route transition. */
35
+ direction: Readonly<import('vue').Ref<SlideDirection, SlideDirection>>;
36
+ /**
37
+ * Resolves the direction for a navigation. Call once per route change.
38
+ *
39
+ * @param to - Tab being entered, if the route has one.
40
+ * @param from - Tab being left, if the route had one.
41
+ */
42
+ resolve(to: K | undefined, from: K | undefined): void;
43
+ /**
44
+ * Forces the next navigation's direction, whatever the indices say.
45
+ *
46
+ * For the navigations that are not a tab change at heart: going back from
47
+ * a detail screen, or being sent to sign-in. Without it, leaving a detail
48
+ * page under the fourth tab for the first tab slides backward, which is
49
+ * right, and arriving there slides forward, which is not.
50
+ */
51
+ force(next: SlideDirection): void;
52
+ };
@@ -0,0 +1,23 @@
1
+ import { Ref } from 'vue';
2
+ /**
3
+ * Adopts the theme stored on the account, once, as soon as it arrives.
4
+ *
5
+ * Two things make this worth a component rather than four lines at a call
6
+ * site, and both are about *once*.
7
+ *
8
+ * It has to run at the app root rather than on the settings screen, or a user
9
+ * on a fresh device keeps the system theme until they happen to open Profile.
10
+ * And it has to run once and never again, or a later refetch of the profile
11
+ * undoes a choice the user has just made locally — the theme flips back under
12
+ * them a second after they set it, which reads as the app fighting them.
13
+ *
14
+ * The source is a ref rather than a query, so the kit never learns what a
15
+ * profile is or where it came from.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const { data: profile } = useProfile()
20
+ * useThemeSync(computed(() => profile.value?.theme))
21
+ * ```
22
+ */
23
+ export declare function useThemeSync(stored: Ref<string | null | undefined>): void;