rei-kit 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -1,11 +1,163 @@
1
- import { i as isThemePreference, s as useTheme, t as BaseSheet_default } from "./BaseSheet-CLIhXDwe.js";
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-BxsrxZdX.js";
2
2
  import { n as _plugin_vue_export_helper_default, r as BaseButton_default, t as SettingsRow_default } from "./SettingsRow-MnVleeTR.js";
3
- import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, onUnmounted, openBlock, readonly, ref, renderList, renderSlot, toDisplayString, unref, useModel, watch, withCtx } from "vue";
3
+ import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, onUnmounted, openBlock, reactive, readonly, ref, renderList, renderSlot, toDisplayString, unref, useModel, watch, withCtx, withModifiers } from "vue";
4
4
  import { Check, Languages } from "lucide-vue-next";
5
+ //#region src/app/AuthForm.vue?vue&type=script&setup=true&lang.ts
6
+ var _hoisted_1$6 = { class: "flex flex-col gap-5" };
7
+ var _hoisted_2$5 = {
8
+ key: 0,
9
+ class: "flex items-center gap-3"
10
+ };
11
+ var _hoisted_3$5 = { class: "text-ink-soft text-xs" };
12
+ //#endregion
13
+ //#region src/app/AuthForm.vue
14
+ var AuthForm_default = /* @__PURE__ */ defineComponent({
15
+ __name: "AuthForm",
16
+ props: /*@__PURE__*/ mergeModels({
17
+ mode: {},
18
+ labels: {},
19
+ busy: {
20
+ type: Boolean,
21
+ default: false
22
+ },
23
+ error: { default: "" },
24
+ validate: { type: Function }
25
+ }, {
26
+ "remember": {
27
+ type: Boolean,
28
+ default: true
29
+ },
30
+ "rememberModifiers": {}
31
+ }),
32
+ emits: /*@__PURE__*/ mergeModels(["submit", "google"], ["update:remember"]),
33
+ setup(__props, { emit: __emit }) {
34
+ const emit = __emit;
35
+ const remember = useModel(__props, "remember");
36
+ /**
37
+ * Remember-me belongs on sign-in, and only where the app has words for it.
38
+ *
39
+ * A brand-new account has nothing to remember, so offering the choice on
40
+ * sign-up is a question with one answer. Keyed off the label rather than a
41
+ * `showRemember` flag because Vue casts an absent Boolean prop to `false`,
42
+ * which makes "not passed" and "passed false" the same value — a flag that
43
+ * cannot express its own default is worse than no flag.
44
+ */
45
+ const wantsRemember = computed(() => __props.mode === "signIn" && Boolean(__props.labels.rememberMe));
46
+ const values = reactive({
47
+ email: "",
48
+ password: "",
49
+ confirmPassword: ""
50
+ });
51
+ const errors = ref({});
52
+ watch(() => __props.mode, () => {
53
+ errors.value = {};
54
+ });
55
+ function submit() {
56
+ const payload = {
57
+ email: values.email,
58
+ password: values.password,
59
+ confirmPassword: __props.mode === "signUp" ? values.confirmPassword : ""
60
+ };
61
+ const found = __props.validate?.(payload) ?? null;
62
+ errors.value = found ?? {};
63
+ if (found) return;
64
+ emit("submit", payload);
65
+ }
66
+ return (_ctx, _cache) => {
67
+ return openBlock(), createElementBlock("div", _hoisted_1$6, [
68
+ renderSlot(_ctx.$slots, "header"),
69
+ renderSlot(_ctx.$slots, "oauth", {}, () => [__props.labels.google ? (openBlock(), createBlock(GoogleButton_default, {
70
+ key: 0,
71
+ label: __props.labels.google,
72
+ disabled: __props.busy,
73
+ onClick: _cache[0] || (_cache[0] = ($event) => emit("google"))
74
+ }, null, 8, ["label", "disabled"])) : createCommentVNode("", true)]),
75
+ __props.labels.or ? (openBlock(), createElementBlock("div", _hoisted_2$5, [
76
+ _cache[5] || (_cache[5] = createElementVNode("span", { class: "bg-hair h-px flex-1" }, null, -1)),
77
+ createElementVNode("span", _hoisted_3$5, toDisplayString(__props.labels.or), 1),
78
+ _cache[6] || (_cache[6] = createElementVNode("span", { class: "bg-hair h-px flex-1" }, null, -1))
79
+ ])) : createCommentVNode("", true),
80
+ createElementVNode("form", {
81
+ novalidate: "",
82
+ class: "flex flex-col gap-4",
83
+ onSubmit: withModifiers(submit, ["prevent"])
84
+ }, [
85
+ createVNode(BaseInput_default, {
86
+ modelValue: values.email,
87
+ "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => values.email = $event),
88
+ type: "email",
89
+ label: __props.labels.email,
90
+ error: errors.value["email"],
91
+ placeholder: __props.labels.emailPlaceholder,
92
+ autocomplete: "email"
93
+ }, null, 8, [
94
+ "modelValue",
95
+ "label",
96
+ "error",
97
+ "placeholder"
98
+ ]),
99
+ createVNode(BaseInput_default, {
100
+ modelValue: values.password,
101
+ "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => values.password = $event),
102
+ type: "password",
103
+ label: __props.labels.password,
104
+ error: errors.value["password"],
105
+ hint: __props.mode === "signUp" ? __props.labels.passwordHint : void 0,
106
+ autocomplete: __props.mode === "signUp" ? "new-password" : "current-password"
107
+ }, null, 8, [
108
+ "modelValue",
109
+ "label",
110
+ "error",
111
+ "hint",
112
+ "autocomplete"
113
+ ]),
114
+ __props.mode === "signUp" ? (openBlock(), createBlock(BaseInput_default, {
115
+ key: 0,
116
+ modelValue: values.confirmPassword,
117
+ "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => values.confirmPassword = $event),
118
+ type: "password",
119
+ label: __props.labels.confirmPassword,
120
+ error: errors.value["confirmPassword"],
121
+ autocomplete: "new-password"
122
+ }, null, 8, [
123
+ "modelValue",
124
+ "label",
125
+ "error"
126
+ ])) : createCommentVNode("", true),
127
+ wantsRemember.value ? (openBlock(), createBlock(BaseCheckbox_default, {
128
+ key: 1,
129
+ modelValue: remember.value,
130
+ "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => remember.value = $event),
131
+ size: "sm",
132
+ label: __props.labels.rememberMe ?? ""
133
+ }, null, 8, ["modelValue", "label"])) : createCommentVNode("", true),
134
+ __props.error ? (openBlock(), createBlock(BaseAlert_default, {
135
+ key: 2,
136
+ tone: "danger",
137
+ assertive: ""
138
+ }, {
139
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.error), 1)]),
140
+ _: 1
141
+ })) : createCommentVNode("", true),
142
+ createVNode(BaseButton_default, {
143
+ type: "submit",
144
+ class: "w-full",
145
+ loading: __props.busy
146
+ }, {
147
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.busy && __props.labels.submitBusy || __props.labels.submit), 1)]),
148
+ _: 1
149
+ }, 8, ["loading"])
150
+ ], 32),
151
+ renderSlot(_ctx.$slots, "foot")
152
+ ]);
153
+ };
154
+ }
155
+ });
156
+ //#endregion
5
157
  //#region src/app/AuthShell.vue?vue&type=script&setup=true&lang.ts
6
- var _hoisted_1$2 = { class: "flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10" };
7
- var _hoisted_2$2 = { class: "w-full max-w-[22rem]" };
8
- var _hoisted_3$2 = {
158
+ var _hoisted_1$5 = { class: "flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10" };
159
+ var _hoisted_2$4 = { class: "w-full max-w-[22rem]" };
160
+ var _hoisted_3$4 = {
9
161
  key: 0,
10
162
  class: "mt-auto"
11
163
  };
@@ -27,19 +179,48 @@ var AuthShell_default = /* @__PURE__ */ defineComponent({
27
179
  * somebody who does not read the browser's language cannot get to one.
28
180
  */
29
181
  return (_ctx, _cache) => {
30
- return openBlock(), createElementBlock("div", _hoisted_1$2, [
182
+ return openBlock(), createElementBlock("div", _hoisted_1$5, [
31
183
  renderSlot(_ctx.$slots, "brand"),
32
- createElementVNode("main", _hoisted_2$2, [renderSlot(_ctx.$slots, "default")]),
33
- _ctx.$slots.foot ? (openBlock(), createElementBlock("div", _hoisted_3$2, [renderSlot(_ctx.$slots, "foot")])) : createCommentVNode("", true)
184
+ createElementVNode("main", _hoisted_2$4, [renderSlot(_ctx.$slots, "default")]),
185
+ _ctx.$slots.foot ? (openBlock(), createElementBlock("div", _hoisted_3$4, [renderSlot(_ctx.$slots, "foot")])) : createCommentVNode("", true)
34
186
  ]);
35
187
  };
36
188
  }
37
189
  });
38
190
  //#endregion
191
+ //#region src/app/FabButton.vue?vue&type=script&setup=true&lang.ts
192
+ var _hoisted_1$4 = { class: "rk-fab-slot" };
193
+ var _hoisted_2$3 = {
194
+ class: "rk-fab-icon",
195
+ "aria-hidden": "true"
196
+ };
197
+ var _hoisted_3$3 = { class: "rk-fab-label" };
198
+ //#endregion
199
+ //#region src/app/FabButton.vue
200
+ var FabButton_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
201
+ __name: "FabButton",
202
+ props: { label: {} },
203
+ emits: ["click"],
204
+ setup(__props, { emit: __emit }) {
205
+ const emit = __emit;
206
+ function press() {
207
+ tapFeedback();
208
+ emit("click");
209
+ }
210
+ return (_ctx, _cache) => {
211
+ return openBlock(), createElementBlock("div", _hoisted_1$4, [createElementVNode("button", {
212
+ type: "button",
213
+ class: "rk-fab",
214
+ onClick: press
215
+ }, [createElementVNode("span", _hoisted_2$3, [renderSlot(_ctx.$slots, "default", {}, void 0, true)]), createElementVNode("span", _hoisted_3$3, toDisplayString(__props.label), 1)])]);
216
+ };
217
+ }
218
+ }), [["__scopeId", "data-v-8f94cb0b"]]);
219
+ //#endregion
39
220
  //#region src/app/LocaleSheet.vue?vue&type=script&setup=true&lang.ts
40
- var _hoisted_1$1 = { class: "text-ink-soft text-sm" };
41
- var _hoisted_2$1 = { class: "flex flex-col" };
42
- var _hoisted_3$1 = { class: "flex-1 text-base" };
221
+ var _hoisted_1$3 = { class: "text-ink-soft text-sm" };
222
+ var _hoisted_2$2 = { class: "flex flex-col" };
223
+ var _hoisted_3$2 = { class: "flex-1 text-base" };
43
224
  //#endregion
44
225
  //#region src/app/LocaleSheet.vue
45
226
  var LocaleSheet_default = /* @__PURE__ */ defineComponent({
@@ -75,7 +256,7 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
75
256
  interactive: "",
76
257
  onClick: _cache[0] || (_cache[0] = ($event) => open.value = true)
77
258
  }, {
78
- default: withCtx(() => [createElementVNode("span", _hoisted_1$1, toDisplayString(current.value), 1)]),
259
+ default: withCtx(() => [createElementVNode("span", _hoisted_1$3, toDisplayString(current.value), 1)]),
79
260
  _: 1
80
261
  }, 8, [
81
262
  "label",
@@ -88,14 +269,14 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
88
269
  subtitle: __props.hint,
89
270
  "close-label": __props.closeLabel
90
271
  }, {
91
- default: withCtx(() => [createElementVNode("ul", _hoisted_2$1, [(openBlock(true), createElementBlock(Fragment, null, renderList(rows.value, (row) => {
272
+ default: withCtx(() => [createElementVNode("ul", _hoisted_2$2, [(openBlock(true), createElementBlock(Fragment, null, renderList(rows.value, (row) => {
92
273
  return openBlock(), createElementBlock("li", { key: row.value }, [createVNode(BaseButton_default, {
93
274
  variant: "row",
94
275
  class: "rounded-xl",
95
276
  pressed: model.value === row.value,
96
277
  onClick: ($event) => select(row.value)
97
278
  }, {
98
- default: withCtx(() => [createElementVNode("span", _hoisted_3$1, toDisplayString(row.label), 1), model.value === row.value ? (openBlock(), createBlock(unref(Check), {
279
+ default: withCtx(() => [createElementVNode("span", _hoisted_3$2, toDisplayString(row.label), 1), model.value === row.value ? (openBlock(), createBlock(unref(Check), {
99
280
  key: 0,
100
281
  class: "text-primary size-5 shrink-0",
101
282
  "aria-hidden": "true"
@@ -114,6 +295,81 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
114
295
  }
115
296
  });
116
297
  //#endregion
298
+ //#region src/app/OfflineBanner.vue?vue&type=script&setup=true&lang.ts
299
+ var _hoisted_1$2 = {
300
+ key: 0,
301
+ role: "status",
302
+ class: "rk-offline-banner"
303
+ };
304
+ //#endregion
305
+ //#region src/app/OfflineBanner.vue
306
+ var OfflineBanner_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
307
+ __name: "OfflineBanner",
308
+ props: { label: {} },
309
+ setup(__props) {
310
+ const isOnline = useOnline();
311
+ return (_ctx, _cache) => {
312
+ return openBlock(), createBlock(Transition, { name: "rk-offline" }, {
313
+ default: withCtx(() => [!unref(isOnline) ? (openBlock(), createElementBlock("p", _hoisted_1$2, toDisplayString(__props.label), 1)) : createCommentVNode("", true)]),
314
+ _: 1
315
+ });
316
+ };
317
+ }
318
+ }), [["__scopeId", "data-v-0781b5b6"]]);
319
+ //#endregion
320
+ //#region src/app/TabShell.vue?vue&type=script&setup=true&lang.ts
321
+ var _hoisted_1$1 = { class: "rk-screen" };
322
+ var _hoisted_2$1 = {
323
+ key: 0,
324
+ class: "rk-screen-aside"
325
+ };
326
+ var _hoisted_3$1 = { class: "shell-frame rk-shell" };
327
+ //#endregion
328
+ //#region src/app/TabShell.vue
329
+ var TabShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
330
+ __name: "TabShell",
331
+ setup(__props) {
332
+ /**
333
+ * The phone frame the whole app sits inside.
334
+ *
335
+ * On a phone this is invisible — the shell fills the screen and there is
336
+ * nothing around it. On a desktop it is the bordered, rounded card in the
337
+ * middle of a textured field, which is what makes a phone-shaped app look
338
+ * deliberate on a wide screen rather than stretched or abandoned.
339
+ *
340
+ * Both phone apps had this at 120 lines, differing in the product name, two
341
+ * colour variables and one hover colour. None of those is a reason to own a
342
+ * frame, so the colours are custom properties and the name is a slot.
343
+ *
344
+ * ## What stays in the app
345
+ *
346
+ * Which layout a route uses, and the `RouterView` inside it. That is the one
347
+ * part that genuinely differs — an app with no auth screens has no layout
348
+ * switch — and it is also the part that must stay in the app so a page that
349
+ * throws does not take the tab bar with it.
350
+ *
351
+ * @example
352
+ * ```vue
353
+ * <TabShell>
354
+ * <template #aside><AppCredits /></template>
355
+ * <template #chrome><UpdatePrompt /></template>
356
+ *
357
+ * <component :is="layoutComponent">
358
+ * <RouterView v-slot="{ Component, route }">
359
+ * <Transition :name="tabTransition.name.value">
360
+ * <component :is="Component" :key="route.path" :class="pageClass" />
361
+ * </Transition>
362
+ * </RouterView>
363
+ * </component>
364
+ * </TabShell>
365
+ * ```
366
+ */
367
+ return (_ctx, _cache) => {
368
+ 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)])]);
369
+ };
370
+ }
371
+ }), [["__scopeId", "data-v-f19fd72a"]]);
372
+ //#endregion
117
373
  //#region src/app/TourShell.vue?vue&type=script&setup=true&lang.ts
118
374
  var _hoisted_1 = ["aria-label"];
119
375
  var _hoisted_2 = { class: "shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden" };
@@ -251,6 +507,171 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
251
507
  }
252
508
  }), [["__scopeId", "data-v-1b9e7f4d"]]);
253
509
  //#endregion
510
+ //#region src/app/guards.ts
511
+ /**
512
+ * The route guard three apps had written separately.
513
+ *
514
+ * It answers two questions and nothing else: may this visitor see this route,
515
+ * and where do they go if not. Everything that differs between the apps — the
516
+ * name of the login route, whether the return path travels as `redirect` or
517
+ * `next`, whether a session has to be restored first — is an option, because
518
+ * each of those is a decision about the app rather than about guarding.
519
+ *
520
+ * It does nothing while prerendering. Every prerendered route is public, the
521
+ * server has no session to read, and a guard that redirected there would write
522
+ * a login page into a file meant to be content.
523
+ *
524
+ * The return path goes through `toRedirectPath`, so an OAuth fragment is
525
+ * dropped rather than copied into a query string the server will see.
526
+ *
527
+ * @example
528
+ * ```ts
529
+ * router.beforeEach(
530
+ * createAuthGuard({
531
+ * isAuthenticated: () => useAuthStore().isAuthenticated,
532
+ * signIn: { name: 'LoginView' },
533
+ * }),
534
+ * )
535
+ * ```
536
+ */
537
+ function createAuthGuard(options) {
538
+ const { isAuthenticated, signIn, home, redirectQuery = "redirect", ready } = options;
539
+ return async (to) => {
540
+ if ({
541
+ "BASE_URL": "/",
542
+ "DEV": false,
543
+ "MODE": "production",
544
+ "PROD": true,
545
+ "SSR": false
546
+ }["SSR"]) return true;
547
+ await ready?.();
548
+ if (to.meta["requiresAuth"] && !isAuthenticated()) return {
549
+ ...typeof signIn === "string" ? { path: signIn } : signIn,
550
+ query: { [redirectQuery]: toRedirectPath(to.fullPath) }
551
+ };
552
+ if (to.meta["guestOnly"] && isAuthenticated()) return home ?? safeRedirect(to.query[redirectQuery]);
553
+ return true;
554
+ };
555
+ }
556
+ /**
557
+ * The document title, which a single-page app has to set for itself.
558
+ *
559
+ * An `afterEach` rather than a `beforeEach`: the title describes where the
560
+ * visitor arrived, and a guard that can still cancel has not arrived anywhere.
561
+ *
562
+ * @example
563
+ * ```ts
564
+ * router.afterEach(createTitleGuard('Kakei')) // "Ledger · Kakei"
565
+ * ```
566
+ */
567
+ function createTitleGuard(suffix, separator = "·") {
568
+ return (to) => {
569
+ const title = to.meta["title"];
570
+ document.title = title ? `${String(title)} ${separator} ${suffix}` : suffix;
571
+ };
572
+ }
573
+ //#endregion
574
+ //#region src/app/query-defaults.ts
575
+ /**
576
+ * The cache settings two apps had picked independently and identically.
577
+ *
578
+ * Returned as a plain object rather than a built `QueryClient`, so the kit does
579
+ * not depend on TanStack Query. An app that uses a different cache — or none —
580
+ * pays nothing for this file, and the app keeps the client as its own
581
+ * module-level singleton, which is what lets non-Vue code reach it (the auth
582
+ * store calls `clear()` on sign-out).
583
+ *
584
+ * The numbers are not arbitrary and they are not TanStack's defaults:
585
+ *
586
+ * - `staleTime: 60_000` — the default is 0, which refetches on every mount. A
587
+ * phone app remounts a screen every time a tab is touched, so the default
588
+ * turns a tab bar into a network request per tap.
589
+ * - `refetchOnWindowFocus` — left on. Coming back to a backgrounded phone app
590
+ * is exactly when the data is most likely to be stale.
591
+ * - `retry: 2` on queries, `0` on mutations. Retrying a read is free; retrying
592
+ * a write that may already have landed is how a double charge happens.
593
+ *
594
+ * @example
595
+ * ```ts
596
+ * export const queryClient = new QueryClient({ defaultOptions: createQueryDefaults() })
597
+ * ```
598
+ */
599
+ function createQueryDefaults(overrides = {}) {
600
+ const { staleTime = 6e4, gcTime = 3e5, retry = 2 } = overrides;
601
+ return {
602
+ queries: {
603
+ staleTime,
604
+ gcTime,
605
+ refetchOnWindowFocus: true,
606
+ retry
607
+ },
608
+ mutations: { retry: 0 }
609
+ };
610
+ }
611
+ //#endregion
612
+ //#region src/app/write-report.ts
613
+ /**
614
+ * Saying that a write happened.
615
+ *
616
+ * In one place so every mutation reports the same way. Both phone apps had
617
+ * written this, and both had written it because before it existed none of their
618
+ * mutations reported at all: a row was added, a row was deleted, and the only
619
+ * evidence was that nothing had visibly broken.
620
+ *
621
+ * Deliberately generic. A message naming the thing that was saved reads better
622
+ * once and worse every time after, and these are screens somebody uses dozens
623
+ * of times in a sitting.
624
+ *
625
+ * Form validation does **not** come through here: a rejected field says so
626
+ * beside itself, where the eye already is and where it stays until fixed. These
627
+ * are for what has already happened.
628
+ *
629
+ * @example
630
+ * ```ts
631
+ * export const report = createWriteReport({
632
+ * saved: () => t('common.saved'),
633
+ * deleted: () => t('common.deleted'),
634
+ * failed: () => t('common.failed'),
635
+ * })
636
+ * ```
637
+ */
638
+ function createWriteReport(messages) {
639
+ return {
640
+ saved: () => useToast().success(messages.saved()),
641
+ deleted: () => useToast().success(messages.deleted()),
642
+ failed: () => useToast().danger(messages.failed())
643
+ };
644
+ }
645
+ //#endregion
646
+ //#region src/app/field-errors.ts
647
+ /**
648
+ * Field errors keyed by field name, or null when the values are valid.
649
+ *
650
+ * Zod's own error shape is a tree; a form needs one message per input, and
651
+ * flattening it here keeps that reshaping out of every component that has a
652
+ * form in it. The first issue per field wins — a field shows one message, and
653
+ * the first is the one the reader can act on.
654
+ *
655
+ * Null rather than an empty object, so `if (errors)` is the guard and a caller
656
+ * cannot accidentally treat "no errors" as a failure.
657
+ *
658
+ * @example
659
+ * ```ts
660
+ * const found = fieldErrors(signupSchema(), values)
661
+ * if (found) return (errors.value = found)
662
+ * ```
663
+ */
664
+ function fieldErrors(schema, values) {
665
+ const result = schema.safeParse(values);
666
+ if (result.success) return null;
667
+ const errors = {};
668
+ for (const issue of result.error.issues) {
669
+ const field = String(issue.path[0] ?? "");
670
+ if (field && !errors[field]) errors[field] = issue.message;
671
+ }
672
+ return errors;
673
+ }
674
+ //#endregion
254
675
  //#region src/app/use-tab-transition.ts
255
676
  /**
256
677
  * Which way a tabbed app is moving.
@@ -289,6 +710,18 @@ function createTabTransition(order) {
289
710
  /** Direction of the current tab change. Read by the route transition. */
290
711
  direction: readonly(direction),
291
712
  /**
713
+ * The `<Transition>` name for the current direction, ready to bind.
714
+ *
715
+ * Empty when there is nothing to slide, which is how a `<Transition>` is
716
+ * told to do nothing — an unnamed transition still runs a default `v-*`
717
+ * animation, so the empty string matters.
718
+ *
719
+ * Both phone apps derived this from `direction` in their `App.vue`, in the
720
+ * same three lines, and the names match the classes shipped in
721
+ * `rei-kit/shell/mobile.css`.
722
+ */
723
+ name: computed(() => direction.value === "none" ? "" : `slide-${direction.value}`),
724
+ /**
292
725
  * Resolves the direction for a navigation. Call once per route change.
293
726
  *
294
727
  * @param to - Tab being entered, if the route has one.
@@ -352,6 +785,6 @@ function useThemeSync(stored) {
352
785
  }, { immediate: true });
353
786
  }
354
787
  //#endregion
355
- export { AuthShell_default as AuthShell, LocaleSheet_default as LocaleSheet, TourShell_default as TourShell, createTabTransition, useThemeSync };
788
+ export { AuthForm_default as AuthForm, AuthShell_default as AuthShell, FabButton_default as FabButton, LocaleSheet_default as LocaleSheet, OfflineBanner_default as OfflineBanner, TabShell_default as TabShell, TourShell_default as TourShell, createAuthGuard, createQueryDefaults, createTabTransition, createTitleGuard, createWriteReport, fieldErrors, useThemeSync };
356
789
 
357
790
  //# sourceMappingURL=app.js.map