rei-kit 0.15.0 → 0.16.1

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.
@@ -13,8 +13,17 @@
13
13
  export { default as AuthForm } from './AuthForm.vue';
14
14
  export type { AuthFormLabels, AuthFormValues } from './auth-form';
15
15
  export { default as AuthShell } from './AuthShell.vue';
16
+ export { default as FabButton } from './FabButton.vue';
16
17
  export { default as LocaleSheet } from './LocaleSheet.vue';
18
+ export { default as OfflineBanner } from './OfflineBanner.vue';
19
+ export { default as TabShell } from './TabShell.vue';
17
20
  export { default as TourShell } from './TourShell.vue';
21
+ export { createAuthGuard, createTitleGuard } from './guards';
22
+ export type { AuthGuardOptions } from './guards';
23
+ export { createQueryDefaults } from './query-defaults';
24
+ export type { QueryDefaultsOverrides } from './query-defaults';
25
+ export { createWriteReport } from './write-report';
26
+ export type { WriteReport, WriteReportMessages } from './write-report';
18
27
  export { fieldErrors } from './field-errors';
19
28
  export type { SafeParsable } from './field-errors';
20
29
  export { createTabTransition } from './use-tab-transition';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The cache settings two apps had picked independently and identically.
3
+ *
4
+ * Returned as a plain object rather than a built `QueryClient`, so the kit does
5
+ * not depend on TanStack Query. An app that uses a different cache — or none —
6
+ * pays nothing for this file, and the app keeps the client as its own
7
+ * module-level singleton, which is what lets non-Vue code reach it (the auth
8
+ * store calls `clear()` on sign-out).
9
+ *
10
+ * The numbers are not arbitrary and they are not TanStack's defaults:
11
+ *
12
+ * - `staleTime: 60_000` — the default is 0, which refetches on every mount. A
13
+ * phone app remounts a screen every time a tab is touched, so the default
14
+ * turns a tab bar into a network request per tap.
15
+ * - `refetchOnWindowFocus` — left on. Coming back to a backgrounded phone app
16
+ * is exactly when the data is most likely to be stale.
17
+ * - `retry: 2` on queries, `0` on mutations. Retrying a read is free; retrying
18
+ * a write that may already have landed is how a double charge happens.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * export const queryClient = new QueryClient({ defaultOptions: createQueryDefaults() })
23
+ * ```
24
+ */
25
+ export declare function createQueryDefaults(overrides?: QueryDefaultsOverrides): {
26
+ queries: {
27
+ staleTime: number;
28
+ gcTime: number;
29
+ refetchOnWindowFocus: boolean;
30
+ retry: number;
31
+ };
32
+ mutations: {
33
+ retry: number;
34
+ };
35
+ };
36
+ export type QueryDefaultsOverrides = {
37
+ /** How long a result is served without refetching. Default 60s. */
38
+ staleTime?: number | undefined;
39
+ /** How long an unused result is kept before eviction. Default 5m. */
40
+ gcTime?: number | undefined;
41
+ /** Attempts for a failed *query*. Mutations are never retried. Default 2. */
42
+ retry?: number | undefined;
43
+ };
@@ -33,6 +33,18 @@ export type SlideDirection = 'forward' | 'backward' | 'none';
33
33
  export declare function createTabTransition<K extends string>(order: readonly K[]): {
34
34
  /** Direction of the current tab change. Read by the route transition. */
35
35
  direction: Readonly<import('vue').Ref<SlideDirection, SlideDirection>>;
36
+ /**
37
+ * The `<Transition>` name for the current direction, ready to bind.
38
+ *
39
+ * Empty when there is nothing to slide, which is how a `<Transition>` is
40
+ * told to do nothing — an unnamed transition still runs a default `v-*`
41
+ * animation, so the empty string matters.
42
+ *
43
+ * Both phone apps derived this from `direction` in their `App.vue`, in the
44
+ * same three lines, and the names match the classes shipped in
45
+ * `rei-kit/shell/mobile.css`.
46
+ */
47
+ name: import('vue').ComputedRef<string>;
36
48
  /**
37
49
  * Resolves the direction for a navigation. Call once per route change.
38
50
  *
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Saying that a write happened.
3
+ *
4
+ * In one place so every mutation reports the same way. Both phone apps had
5
+ * written this, and both had written it because before it existed none of their
6
+ * mutations reported at all: a row was added, a row was deleted, and the only
7
+ * evidence was that nothing had visibly broken.
8
+ *
9
+ * Deliberately generic. A message naming the thing that was saved reads better
10
+ * once and worse every time after, and these are screens somebody uses dozens
11
+ * of times in a sitting.
12
+ *
13
+ * Form validation does **not** come through here: a rejected field says so
14
+ * beside itself, where the eye already is and where it stays until fixed. These
15
+ * are for what has already happened.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * export const report = createWriteReport({
20
+ * saved: () => t('common.saved'),
21
+ * deleted: () => t('common.deleted'),
22
+ * failed: () => t('common.failed'),
23
+ * })
24
+ * ```
25
+ */
26
+ export declare function createWriteReport(messages: WriteReportMessages): WriteReport;
27
+ /**
28
+ * Functions, not strings.
29
+ *
30
+ * A string would be read once, when the report is built, and would then keep
31
+ * whichever language was active at that moment for the life of the app. Called
32
+ * per report, the wording follows a language switch.
33
+ */
34
+ export type WriteReportMessages = {
35
+ saved: () => string;
36
+ deleted: () => string;
37
+ failed: () => string;
38
+ };
39
+ export type WriteReport = {
40
+ saved: () => void;
41
+ deleted: () => void;
42
+ failed: () => void;
43
+ };
package/dist/app.js CHANGED
@@ -1,14 +1,14 @@
1
- import { f as useTheme, i as BaseInput_default, l as isThemePreference, n as BaseCheckbox_default, o as BaseAlert_default, r as BaseSheet_default, t as GoogleButton_default } from "./GoogleButton-BQSsm0hp.js";
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
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
5
  //#region src/app/AuthForm.vue?vue&type=script&setup=true&lang.ts
6
- var _hoisted_1$3 = { class: "flex flex-col gap-5" };
7
- var _hoisted_2$3 = {
6
+ var _hoisted_1$6 = { class: "flex flex-col gap-5" };
7
+ var _hoisted_2$5 = {
8
8
  key: 0,
9
9
  class: "flex items-center gap-3"
10
10
  };
11
- var _hoisted_3$3 = { class: "text-ink-soft text-xs" };
11
+ var _hoisted_3$5 = { class: "text-ink-soft text-xs" };
12
12
  //#endregion
13
13
  //#region src/app/AuthForm.vue
14
14
  var AuthForm_default = /* @__PURE__ */ defineComponent({
@@ -64,7 +64,7 @@ var AuthForm_default = /* @__PURE__ */ defineComponent({
64
64
  emit("submit", payload);
65
65
  }
66
66
  return (_ctx, _cache) => {
67
- return openBlock(), createElementBlock("div", _hoisted_1$3, [
67
+ return openBlock(), createElementBlock("div", _hoisted_1$6, [
68
68
  renderSlot(_ctx.$slots, "header"),
69
69
  renderSlot(_ctx.$slots, "oauth", {}, () => [__props.labels.google ? (openBlock(), createBlock(GoogleButton_default, {
70
70
  key: 0,
@@ -72,9 +72,9 @@ var AuthForm_default = /* @__PURE__ */ defineComponent({
72
72
  disabled: __props.busy,
73
73
  onClick: _cache[0] || (_cache[0] = ($event) => emit("google"))
74
74
  }, null, 8, ["label", "disabled"])) : createCommentVNode("", true)]),
75
- __props.labels.or ? (openBlock(), createElementBlock("div", _hoisted_2$3, [
75
+ __props.labels.or ? (openBlock(), createElementBlock("div", _hoisted_2$5, [
76
76
  _cache[5] || (_cache[5] = createElementVNode("span", { class: "bg-hair h-px flex-1" }, null, -1)),
77
- createElementVNode("span", _hoisted_3$3, toDisplayString(__props.labels.or), 1),
77
+ createElementVNode("span", _hoisted_3$5, toDisplayString(__props.labels.or), 1),
78
78
  _cache[6] || (_cache[6] = createElementVNode("span", { class: "bg-hair h-px flex-1" }, null, -1))
79
79
  ])) : createCommentVNode("", true),
80
80
  createElementVNode("form", {
@@ -155,9 +155,9 @@ var AuthForm_default = /* @__PURE__ */ defineComponent({
155
155
  });
156
156
  //#endregion
157
157
  //#region src/app/AuthShell.vue?vue&type=script&setup=true&lang.ts
158
- var _hoisted_1$2 = { class: "flex min-h-0 w-full flex-1 flex-col items-center gap-8 overflow-y-auto px-6 pt-10 pb-10" };
159
- var _hoisted_2$2 = { class: "w-full max-w-[22rem]" };
160
- 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 = {
161
161
  key: 0,
162
162
  class: "mt-auto"
163
163
  };
@@ -179,19 +179,48 @@ var AuthShell_default = /* @__PURE__ */ defineComponent({
179
179
  * somebody who does not read the browser's language cannot get to one.
180
180
  */
181
181
  return (_ctx, _cache) => {
182
- return openBlock(), createElementBlock("div", _hoisted_1$2, [
182
+ return openBlock(), createElementBlock("div", _hoisted_1$5, [
183
183
  renderSlot(_ctx.$slots, "brand"),
184
- createElementVNode("main", _hoisted_2$2, [renderSlot(_ctx.$slots, "default")]),
185
- _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)
186
186
  ]);
187
187
  };
188
188
  }
189
189
  });
190
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-7e2bbe71"]]);
219
+ //#endregion
191
220
  //#region src/app/LocaleSheet.vue?vue&type=script&setup=true&lang.ts
192
- var _hoisted_1$1 = { class: "text-ink-soft text-sm" };
193
- var _hoisted_2$1 = { class: "flex flex-col" };
194
- 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" };
195
224
  //#endregion
196
225
  //#region src/app/LocaleSheet.vue
197
226
  var LocaleSheet_default = /* @__PURE__ */ defineComponent({
@@ -227,7 +256,7 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
227
256
  interactive: "",
228
257
  onClick: _cache[0] || (_cache[0] = ($event) => open.value = true)
229
258
  }, {
230
- default: withCtx(() => [createElementVNode("span", _hoisted_1$1, toDisplayString(current.value), 1)]),
259
+ default: withCtx(() => [createElementVNode("span", _hoisted_1$3, toDisplayString(current.value), 1)]),
231
260
  _: 1
232
261
  }, 8, [
233
262
  "label",
@@ -240,14 +269,14 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
240
269
  subtitle: __props.hint,
241
270
  "close-label": __props.closeLabel
242
271
  }, {
243
- 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) => {
244
273
  return openBlock(), createElementBlock("li", { key: row.value }, [createVNode(BaseButton_default, {
245
274
  variant: "row",
246
275
  class: "rounded-xl",
247
276
  pressed: model.value === row.value,
248
277
  onClick: ($event) => select(row.value)
249
278
  }, {
250
- 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), {
251
280
  key: 0,
252
281
  class: "text-primary size-5 shrink-0",
253
282
  "aria-hidden": "true"
@@ -266,6 +295,81 @@ var LocaleSheet_default = /* @__PURE__ */ defineComponent({
266
295
  }
267
296
  });
268
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
269
373
  //#region src/app/TourShell.vue?vue&type=script&setup=true&lang.ts
270
374
  var _hoisted_1 = ["aria-label"];
271
375
  var _hoisted_2 = { class: "shell-frame md:rounded-shell bg-canvas relative flex flex-col overflow-hidden" };
@@ -403,6 +507,142 @@ var TourShell_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PU
403
507
  }
404
508
  }), [["__scopeId", "data-v-1b9e7f4d"]]);
405
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
406
646
  //#region src/app/field-errors.ts
407
647
  /**
408
648
  * Field errors keyed by field name, or null when the values are valid.
@@ -470,6 +710,18 @@ function createTabTransition(order) {
470
710
  /** Direction of the current tab change. Read by the route transition. */
471
711
  direction: readonly(direction),
472
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
+ /**
473
725
  * Resolves the direction for a navigation. Call once per route change.
474
726
  *
475
727
  * @param to - Tab being entered, if the route has one.
@@ -533,6 +785,6 @@ function useThemeSync(stored) {
533
785
  }, { immediate: true });
534
786
  }
535
787
  //#endregion
536
- export { AuthForm_default as AuthForm, AuthShell_default as AuthShell, LocaleSheet_default as LocaleSheet, TourShell_default as TourShell, createTabTransition, fieldErrors, 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 };
537
789
 
538
790
  //# sourceMappingURL=app.js.map