@eifi1/ui-kit 0.17.0 → 0.18.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.
Files changed (167) hide show
  1. package/README.md +29 -4
  2. package/dist/chart.d.ts +10 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +10 -1
  10. package/dist/components/button-group.d.ts +10 -1
  11. package/dist/components/calculator.d.ts +10 -1
  12. package/dist/components/chip.d.ts +25 -1
  13. package/dist/components/chip.js +143 -6
  14. package/dist/components/chip.js.map +1 -1
  15. package/dist/components/confirm-dialog.d.ts +42 -1
  16. package/dist/components/confirm-dialog.js +36 -5
  17. package/dist/components/confirm-dialog.js.map +1 -1
  18. package/dist/components/copy-button.d.ts +10 -1
  19. package/dist/components/danger-confirm.d.ts +55 -4
  20. package/dist/components/danger-confirm.js +67 -30
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/data-table-cells.d.ts +1 -1
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table.d.ts +1 -1
  26. package/dist/components/data-table.js +2 -0
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/components/facing-pair.d.ts +10 -1
  29. package/dist/components/file-button.d.ts +10 -1
  30. package/dist/components/file-dropzone.d.ts +10 -1
  31. package/dist/components/form-actions.d.ts +10 -1
  32. package/dist/components/form-actions.js +3 -0
  33. package/dist/components/form-actions.js.map +1 -1
  34. package/dist/components/money-field.d.ts +10 -1
  35. package/dist/components/number-field.d.ts +11 -2
  36. package/dist/components/number-field.js +23 -22
  37. package/dist/components/number-field.js.map +1 -1
  38. package/dist/components/number-input.d.ts +10 -1
  39. package/dist/components/numpad-sheet.d.ts +10 -1
  40. package/dist/components/passkeys-setting.d.ts +27 -1
  41. package/dist/components/passkeys-setting.js +31 -5
  42. package/dist/components/passkeys-setting.js.map +1 -1
  43. package/dist/components/progress-bar.d.ts +15 -1
  44. package/dist/components/progress-bar.js +8 -1
  45. package/dist/components/progress-bar.js.map +1 -1
  46. package/dist/components/reauth-dialog.d.ts +90 -0
  47. package/dist/components/reauth-dialog.js +158 -0
  48. package/dist/components/reauth-dialog.js.map +1 -0
  49. package/dist/components/series-chart.d.ts +10 -1
  50. package/dist/components/server-wake.d.ts +58 -0
  51. package/dist/components/server-wake.js +58 -0
  52. package/dist/components/server-wake.js.map +1 -0
  53. package/dist/components/settings-fields.d.ts +10 -1
  54. package/dist/components/share-card.d.ts +61 -0
  55. package/dist/components/share-card.js +350 -0
  56. package/dist/components/share-card.js.map +1 -0
  57. package/dist/components/status-dot.d.ts +2 -0
  58. package/dist/components/text-link.d.ts +10 -1
  59. package/dist/components/time-input.d.ts +10 -1
  60. package/dist/components/toast.d.ts +10 -1
  61. package/dist/components/toast.js +42 -2
  62. package/dist/components/toast.js.map +1 -1
  63. package/dist/components/toggle-group.js +5 -2
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/ui.d.ts +10 -1
  66. package/dist/components/ui.js +43 -28
  67. package/dist/components/ui.js.map +1 -1
  68. package/dist/components/use-table-state.d.ts +1 -1
  69. package/dist/components/user-avatar.d.ts +2 -0
  70. package/dist/components/write-lock.d.ts +68 -0
  71. package/dist/components/write-lock.js +33 -0
  72. package/dist/components/write-lock.js.map +1 -0
  73. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  74. package/dist/data-table.d.ts +1 -1
  75. package/dist/hooks/use-file-drop.d.ts +10 -1
  76. package/dist/hooks/use-overlay-history.js +16 -0
  77. package/dist/hooks/use-overlay-history.js.map +1 -1
  78. package/dist/i18n/defaults.d.ts +10 -1
  79. package/dist/i18n/defaults.js +11 -1
  80. package/dist/i18n/defaults.js.map +1 -1
  81. package/dist/i18n/german.d.ts +78 -0
  82. package/dist/i18n/{locales/de.js → german.js} +59 -9
  83. package/dist/i18n/german.js.map +1 -0
  84. package/dist/i18n/kit-labels.d.ts +10 -1
  85. package/dist/i18n/kit-labels.js +5 -0
  86. package/dist/i18n/kit-labels.js.map +1 -1
  87. package/dist/i18n/locales/de-CH.d.ts +23 -8
  88. package/dist/i18n/locales/de-CH.js +7 -3
  89. package/dist/i18n/locales/de-CH.js.map +1 -1
  90. package/dist/i18n/locales/en.d.ts +10 -1
  91. package/dist/i18n/locales/es.d.ts +10 -1
  92. package/dist/i18n/locales/es.js +51 -1
  93. package/dist/i18n/locales/es.js.map +1 -1
  94. package/dist/i18n/locales/fr.d.ts +10 -1
  95. package/dist/i18n/locales/fr.js +51 -1
  96. package/dist/i18n/locales/fr.js.map +1 -1
  97. package/dist/i18n/locales/hu.d.ts +10 -1
  98. package/dist/i18n/locales/hu.js +54 -1
  99. package/dist/i18n/locales/hu.js.map +1 -1
  100. package/dist/i18n/locales/it.d.ts +10 -1
  101. package/dist/i18n/locales/it.js +51 -1
  102. package/dist/i18n/locales/it.js.map +1 -1
  103. package/dist/i18n/locales/zh.d.ts +10 -1
  104. package/dist/i18n/locales/zh.js +50 -1
  105. package/dist/i18n/locales/zh.js.map +1 -1
  106. package/dist/i18n/swiss.d.ts +3 -3
  107. package/dist/i18n/swiss.js.map +1 -1
  108. package/dist/index.d.ts +9 -4
  109. package/dist/index.js +33 -1
  110. package/dist/index.js.map +1 -1
  111. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-CPbsjlXr.d.ts} +229 -9
  112. package/dist/lib/server-wake.d.ts +130 -0
  113. package/dist/lib/server-wake.js +121 -0
  114. package/dist/lib/server-wake.js.map +1 -0
  115. package/dist/rhf/fields.d.ts +10 -1
  116. package/dist/rhf/form.d.ts +10 -1
  117. package/dist/rhf.d.ts +10 -1
  118. package/dist/search/global-search.d.ts +3 -1
  119. package/dist/search/global-search.js.map +1 -1
  120. package/dist/shell/app-shell.d.ts +10 -1
  121. package/dist/shell/top-bar-brand.d.ts +10 -1
  122. package/dist/shell/topbar-action-menu.d.ts +2 -0
  123. package/dist/shell.d.ts +10 -1
  124. package/dist/wizard/stepper-nav.d.ts +10 -1
  125. package/dist/wizard.d.ts +10 -1
  126. package/package.json +1 -1
  127. package/src/components/account-chips.tsx +302 -0
  128. package/src/components/account-settings-labels.ts +16 -2
  129. package/src/components/chip.tsx +239 -6
  130. package/src/components/confirm-dialog.tsx +82 -7
  131. package/src/components/danger-confirm.tsx +118 -33
  132. package/src/components/data-table.tsx +20 -0
  133. package/src/components/form-actions.tsx +22 -3
  134. package/src/components/number-field.tsx +32 -26
  135. package/src/components/passkeys-setting.tsx +68 -7
  136. package/src/components/progress-bar.tsx +21 -1
  137. package/src/components/reauth-dialog.tsx +273 -0
  138. package/src/components/server-wake.tsx +124 -0
  139. package/src/components/share-card.tsx +615 -0
  140. package/src/components/toast.tsx +72 -2
  141. package/src/components/toggle-group.tsx +5 -2
  142. package/src/components/ui.tsx +89 -32
  143. package/src/components/write-lock.tsx +96 -0
  144. package/src/hooks/use-overlay-history.ts +51 -0
  145. package/src/i18n/defaults.ts +10 -0
  146. package/src/i18n/{locales/de.ts → german.ts} +68 -13
  147. package/src/i18n/kit-labels.tsx +22 -2
  148. package/src/i18n/locales/de-CH.ts +17 -8
  149. package/src/i18n/locales/es.ts +51 -0
  150. package/src/i18n/locales/fr.ts +52 -0
  151. package/src/i18n/locales/hu.ts +55 -0
  152. package/src/i18n/locales/it.ts +51 -0
  153. package/src/i18n/locales/zh.ts +50 -0
  154. package/src/i18n/swiss.ts +3 -3
  155. package/src/index.ts +23 -1
  156. package/src/lib/server-wake.ts +270 -0
  157. package/src/search/global-search.tsx +3 -1
  158. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  159. package/dist/i18n/locales/de-CH-informal.js +0 -8
  160. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  161. package/dist/i18n/locales/de-informal.d.ts +0 -82
  162. package/dist/i18n/locales/de-informal.js +0 -37
  163. package/dist/i18n/locales/de-informal.js.map +0 -1
  164. package/dist/i18n/locales/de.d.ts +0 -66
  165. package/dist/i18n/locales/de.js.map +0 -1
  166. package/src/i18n/locales/de-CH-informal.ts +0 -10
  167. package/src/i18n/locales/de-informal.ts +0 -61
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { AnchorHTMLAttributes, Ref, ReactElement, ButtonHTMLAttributes, ReactNode, SelectHTMLAttributes, InputHTMLAttributes, ComponentPropsWithoutRef, TextareaHTMLAttributes } from 'react';
2
+ import { AnchorHTMLAttributes, Ref, ReactElement, ButtonHTMLAttributes, ReactNode, SelectHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, TextareaHTMLAttributes } from 'react';
3
3
  import { DataTableLabels } from './components/data-table-labels.js';
4
4
  import { MiniCalendarLabels, WeekDay } from './components/mini-calendar.js';
5
5
  import { CalendarHeatmapLabels } from './components/calendar-heatmap.js';
@@ -22,7 +22,7 @@ import { PasswordStrengthLabels } from './components/password-strength.js';
22
22
  import { DangerConfirmLabels } from './components/danger-confirm.js';
23
23
  import { SwatchPickerLabels } from './components/swatch-picker.js';
24
24
  import { IconPickerLabels } from './components/icon-picker.js';
25
- import { DialogFrameLabels } from './components/dialog-frame.js';
25
+ import { DialogFrameProps, DialogFrameLabels } from './components/dialog-frame.js';
26
26
  import { MeasuredGridLabels } from './components/measured-grid.js';
27
27
  import { f as FeedbackAttachmentFieldLabels, q as FeedbackDialogTextLabels } from './feedback-attachment-WFiXJ8We.js';
28
28
  import { FeedbackThreadLabels, FeedbackComposerLabels } from './feedback/feedback-thread.js';
@@ -42,6 +42,10 @@ import { ErrorBoundaryLabels } from './components/error-boundary.js';
42
42
  import { AuthedImageLabels } from './components/authed-image.js';
43
43
  import { ImageGridLabels } from './components/image-grid.js';
44
44
  import { LightboxLabels } from './components/lightbox.js';
45
+ import { WriteLockLabels } from './components/write-lock.js';
46
+ import { RoleDefinition, AccountStateLabels } from './components/account-chips.js';
47
+ import { ReauthDialogLabels } from './components/reauth-dialog.js';
48
+ import { ServerWakeLabels } from './components/server-wake.js';
45
49
 
46
50
  type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
47
51
  type ButtonSize = "sm" | "md";
@@ -145,6 +149,13 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
145
149
  * DangerConfirm keeps its own.
146
150
  */
147
151
  disabledReason?: ReactNode;
152
+ /**
153
+ * This button COMMITS — it saves, creates, deletes. Under a locked
154
+ * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's
155
+ * reason (which wins over a `disabledReason` of its own). No provider, or an
156
+ * unlocked one: no effect. See {@link WriteLockProvider}.
157
+ */
158
+ commit?: boolean;
148
159
  /**
149
160
  * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
150
161
  *
@@ -219,6 +230,7 @@ interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>,
219
230
  pending?: never;
220
231
  pressed?: never;
221
232
  disabledReason?: never;
233
+ commit?: never;
222
234
  }
223
235
  declare function Button(props: ButtonLinkProps): ReactElement;
224
236
  declare function Button(props: ButtonProps): ReactElement;
@@ -427,6 +439,10 @@ interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
427
439
  * reason nobody can see is not one. Wins over `disabled`, as on Button.
428
440
  */
429
441
  disabledReason?: ReactNode;
442
+ /** This action COMMITS — {@link Button}'s `commit`: under a locked
443
+ * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
444
+ * reason. No effect without a lock. */
445
+ commit?: boolean;
430
446
  /** The `<button>` element — a prop in React 19, as on {@link Button}. */
431
447
  ref?: Ref<HTMLButtonElement>;
432
448
  /** Only on the link form — see {@link IconButtonLinkProps}. */
@@ -470,6 +486,7 @@ interface IconButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElemen
470
486
  form?: never;
471
487
  pressed?: never;
472
488
  disabledReason?: never;
489
+ commit?: never;
473
490
  disabledStyle?: never;
474
491
  pending?: never;
475
492
  }
@@ -1446,7 +1463,8 @@ type FormActionsPlacement = "inline" | "sticky" | "dialog";
1446
1463
  * md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and
1447
1464
  * resolved the same way, in JS: the sticky row is positioned by inline style, which a
1448
1465
  * `md:` class cannot reach. keksdose switched `placement` on its own media query at
1449
- * every long form for want of this.
1466
+ * every long form for want of this. The breakpoints are the VIEWPORT's, not the
1467
+ * container's: a form in a narrow pane on a wide screen resolves to `md`.
1450
1468
  */
1451
1469
  interface ResponsiveFormActionsPlacement {
1452
1470
  base: FormActionsPlacement;
@@ -1468,7 +1486,7 @@ type FormActionsStickyWithin = "viewport" | "container";
1468
1486
  * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,
1469
1487
  * disabled, variant, form, children, the busy state) are not among them.
1470
1488
  */
1471
- type FormActionsSubmitProps = Omit<ButtonProps, "type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy"> & {
1489
+ type FormActionsSubmitProps = Omit<ButtonProps, "type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy" | "commit"> & {
1472
1490
  [key: `data-${string}`]: string | number | boolean | undefined;
1473
1491
  };
1474
1492
  /** The start-side action of {@link FormActionsProps.destructive}, as data. */
@@ -1515,6 +1533,18 @@ interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "childr
1515
1533
  submitDisabled?: boolean;
1516
1534
  /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */
1517
1535
  submitDisabledReason?: ReactNode;
1536
+ /**
1537
+ * Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the
1538
+ * lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —
1539
+ * Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a
1540
+ * commit too and is locked with it; Cancel and `children` are not (closing a form
1541
+ * writes nothing). No provider, or an unlocked one: no effect. Default `false`.
1542
+ *
1543
+ * keksdose wrote `submitDisabled={lock.locked || …}` and
1544
+ * `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is
1545
+ * that pair, read from the provider.
1546
+ */
1547
+ commit?: boolean;
1518
1548
  /** The save button's variant. Default `brand`; `danger` for a save that destroys
1519
1549
  * (kastlan's `destructive` flag on its own FormActions). */
1520
1550
  submitVariant?: ButtonVariant;
@@ -1545,7 +1575,8 @@ interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "childr
1545
1575
  * from both sides, with the content scrolling past it in the gutters (keksdose wrote
1546
1576
  * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`"0.75rem"`,
1547
1577
  * `12`, `"var(--pane-px)"`). Applied only while the resolved placement is sticky, so
1548
- * `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone.
1578
+ * `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone. The margin
1579
+ * is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.
1549
1580
  */
1550
1581
  bleed?: string | number;
1551
1582
  /**
@@ -1591,7 +1622,187 @@ interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "childr
1591
1622
  * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`
1592
1623
  * so the button's name stays its text; `aria-busy` is what says it is working.
1593
1624
  */
1594
- declare function FormActions({ onCancel, onSubmit, submitLabel, cancelLabel, pendingLabel, submitIcon: SubmitIcon, submitProps, pending, submitDisabled, submitDisabledReason, submitVariant, destructive, start: startSlot, align, placement: placementProp, stickyWithin, bleed, form, children, className, style, ...rest }: FormActionsProps): react.JSX.Element;
1625
+ declare function FormActions({ onCancel, onSubmit, submitLabel, cancelLabel, pendingLabel, submitIcon: SubmitIcon, submitProps, pending, submitDisabled, submitDisabledReason, commit, submitVariant, destructive, start: startSlot, align, placement: placementProp, stickyWithin, bleed, form, children, className, style, ...rest }: FormActionsProps): react.JSX.Element;
1626
+
1627
+ /**
1628
+ * Who holds one record, and the owner's every way of changing that — Kurvenschmiede's
1629
+ * `features/sharing/share-dialog.tsx` and keksdose's `features/budgets/budget-share-card.tsx`,
1630
+ * which are the same component twice (Kurvenschmiede's own doc comment says so).
1631
+ *
1632
+ * Presentational: the kit draws the rows, asks before a removal (the kit's
1633
+ * {@link useConfirm}), keeps the field and the busy state, and checks only that an
1634
+ * address is SHAPED like one. Everything that is policy stays with the app — who may
1635
+ * share, whether an address has an account (that is what makes a grant pending), what
1636
+ * a role permits, every request — through the callbacks, which may return a promise.
1637
+ */
1638
+ interface ShareCardLabels {
1639
+ /** ShareDialog's heading. */
1640
+ dialogTitle: string;
1641
+ /** ShareDialog's close button. */
1642
+ close: string;
1643
+ /** The add field's label. */
1644
+ email: string;
1645
+ /** The add field's label when `addWithoutEmail` lets it stay empty. */
1646
+ emailOptional: string;
1647
+ emailPlaceholder: string;
1648
+ /** Under the field when what was typed is not an address. */
1649
+ invalidEmail: string;
1650
+ /** Accessible name of the role choice beside the field. */
1651
+ role: string;
1652
+ /** Accessible name of one grantee's role choice. */
1653
+ roleOf: (name: string) => string;
1654
+ /** The add button. */
1655
+ add: string;
1656
+ /** Heading over the list. */
1657
+ whoHasAccess: string;
1658
+ /** The list is empty. */
1659
+ nobodyYet: string;
1660
+ /** Chip on a grant whose address has no account yet. */
1661
+ pending: string;
1662
+ /** A pending grant with no address — a link anybody holding it can redeem. */
1663
+ openInvite: string;
1664
+ copyLink: string;
1665
+ /** A candidate that is a team (its sublabel, and the line under a team row). */
1666
+ team: string;
1667
+ /** Under the field while a team is the one named. */
1668
+ teamHint: (name: string) => string;
1669
+ /** The remove button on a grantee. */
1670
+ remove: string;
1671
+ removeConfirm: (name: string) => string;
1672
+ /** The remove button on a pending grant. */
1673
+ revokePending: string;
1674
+ revokePendingConfirm: (name: string) => string;
1675
+ /** A callback rejected and no `formatError` says better. */
1676
+ failed: string;
1677
+ /** Shown while `loading`. */
1678
+ loading: string;
1679
+ }
1680
+ declare const DEFAULT_SHARE_CARD_LABELS: ShareCardLabels;
1681
+ /**
1682
+ * One role of the app's vocabulary: Kurvenschmiede's viewer/editor, keksdose's guest.
1683
+ *
1684
+ * The admin roster's {@link RoleDefinition} (label, tone, icon) plus the key it is
1685
+ * filed under — an ARRAY here rather than the keyed `RoleVocabulary` record,
1686
+ * because the order is the order of the role choice's segments.
1687
+ */
1688
+ type ShareRole = RoleDefinition & {
1689
+ key: string;
1690
+ /** What the role allows — under the role choice once chosen, and the chip's tooltip. */
1691
+ description?: string;
1692
+ };
1693
+ /** Somebody (or a team) who holds the record now. */
1694
+ interface ShareGrantee {
1695
+ id: string | number;
1696
+ name: string;
1697
+ /** The second line. A team row says {@link ShareCardLabels.team} instead. */
1698
+ email?: string | null;
1699
+ /** A {@link ShareRole.key}. */
1700
+ role: string;
1701
+ /** `team`: the grant reaches everybody in it (Kurvenschmiede). Default `person`. */
1702
+ kind?: "person" | "team";
1703
+ /** Shown, but neither its role nor its access can be changed here (an owner row). */
1704
+ locked?: boolean;
1705
+ /** The app's own line under the row — keksdose's per-guest key delivery. */
1706
+ extra?: ReactNode;
1707
+ }
1708
+ /** A grant waiting for its address to have an account — or, with no address, an
1709
+ * open invite link (keksdose). */
1710
+ interface SharePendingGrant {
1711
+ id: string | number;
1712
+ email?: string | null;
1713
+ /** A {@link ShareRole.key}; left out when the app has only one role. */
1714
+ role?: string;
1715
+ /** The invite link, offered with a copy button. */
1716
+ link?: string;
1717
+ }
1718
+ /** A completion offered while typing: a past grantee, or one of the sharer's teams. */
1719
+ interface ShareCandidate {
1720
+ id: string | number;
1721
+ name: string;
1722
+ /** A person's address: taking the row fills the field with it, and the grant is
1723
+ * then an ordinary address grant. */
1724
+ email?: string | null;
1725
+ /** `team` (or a person with no address): taking the row names THIS candidate, and
1726
+ * `onAdd` receives it instead of an address. */
1727
+ kind?: "person" | "team";
1728
+ icon?: ReactNode;
1729
+ }
1730
+ /** What the add button asks for. Exactly one of `email` / `candidate` is set, except
1731
+ * under `addWithoutEmail`, where both may be null (an open invite). */
1732
+ interface ShareAddRequest {
1733
+ email: string | null;
1734
+ candidate: ShareCandidate | null;
1735
+ /** A {@link ShareRole.key}. */
1736
+ role: string;
1737
+ }
1738
+ type MaybePromise = void | Promise<unknown>;
1739
+ interface SharePanelProps {
1740
+ grantees: ShareGrantee[];
1741
+ /** The role vocabulary, in display order. One role: no choice is offered and each
1742
+ * row shows its chip. */
1743
+ roles: ShareRole[];
1744
+ pending?: SharePendingGrant[];
1745
+ /** Completions for the field. Left out: the field is a plain address field. */
1746
+ candidates?: ShareCandidate[];
1747
+ /** The role a new grant starts with. Default: the first of `roles`. */
1748
+ defaultRole?: string;
1749
+ /** Resolve to clear the field; reject to keep it and show the error. Left out (or
1750
+ * `readOnly`): no add form. */
1751
+ onAdd?: (request: ShareAddRequest) => MaybePromise;
1752
+ /** Left out: roles are shown as chips. */
1753
+ onRoleChange?: (grantee: ShareGrantee, role: string) => MaybePromise;
1754
+ /** Called after the kit's confirm. Left out: no remove button. */
1755
+ onRemove?: (grantee: ShareGrantee) => MaybePromise;
1756
+ /** Called after the kit's confirm. Left out: no withdraw button. */
1757
+ onRevokePending?: (grant: SharePendingGrant) => MaybePromise;
1758
+ /** keksdose: an empty field is a request too — an invite link with no address. */
1759
+ addWithoutEmail?: boolean;
1760
+ /** Under the add field: whatever the app needs said there (Kurvenschmiede's "your
1761
+ * pending grant does not let anybody register"). A function receives the taken
1762
+ * candidate, so it can say something else while a team is named. */
1763
+ caption?: ReactNode | ((candidate: ShareCandidate | null) => ReactNode);
1764
+ /** Above everything — what sharing means here (keksdose's guest explanation). */
1765
+ intro?: ReactNode;
1766
+ /** The list is being fetched: a spinner instead of the rows. */
1767
+ loading?: boolean;
1768
+ /** The app's own error, shown in the error box (wins over a caught rejection). */
1769
+ error?: ReactNode;
1770
+ /** Turns a callback's rejection into words. Default {@link ShareCardLabels.failed}. */
1771
+ formatError?: (error: unknown) => ReactNode;
1772
+ /** Look, do not touch: no form, chips for roles, no remove buttons. Copying an
1773
+ * invite link still works. */
1774
+ readOnly?: boolean;
1775
+ /** Focus the field on mount — a dialog that exists to add somebody. */
1776
+ autoFocus?: boolean;
1777
+ labels?: Partial<ShareCardLabels>;
1778
+ }
1779
+ /**
1780
+ * The body both forms share: the add form, then the list. Exported for an app that
1781
+ * frames it itself (a settings section, a sheet).
1782
+ */
1783
+ declare function SharePanel({ grantees, roles, pending, candidates, defaultRole, onAdd, onRoleChange, onRemove, onRevokePending, addWithoutEmail, caption, intro, loading, error, formatError, readOnly, autoFocus, labels: labelsProp, }: SharePanelProps): react.JSX.Element;
1784
+ interface ShareCardProps extends SharePanelProps {
1785
+ /** A heading over the card. */
1786
+ title?: ReactNode;
1787
+ /** The Card's variant. Default `outline` — a block inside a section, as keksdose's
1788
+ * card under a budget row is. */
1789
+ variant?: CardProps["variant"];
1790
+ className?: string;
1791
+ }
1792
+ /** {@link SharePanel} in a card — keksdose's inline form, under the row it shares. */
1793
+ declare function ShareCard({ title, variant, className, ...panel }: ShareCardProps): react.JSX.Element;
1794
+ interface ShareDialogProps extends SharePanelProps, Pick<DialogFrameProps, "open" | "onClose" | "size" | "className"> {
1795
+ /** The heading. Default {@link ShareCardLabels.dialogTitle}. */
1796
+ title?: ReactNode;
1797
+ /** Under the heading — what is being shared (Kurvenschmiede passes its name). */
1798
+ description?: ReactNode;
1799
+ }
1800
+ /**
1801
+ * {@link SharePanel} in the kit's {@link DialogFrame} with a Close button —
1802
+ * Kurvenschmiede's form. Sharing commits as it goes, so there is nothing to save or
1803
+ * cancel: Close is the only action.
1804
+ */
1805
+ declare function ShareDialog({ open, onClose, size, className, title, description, ...panel }: ShareDialogProps): react.JSX.Element;
1595
1806
 
1596
1807
  /**
1597
1808
  * EVERY string the kit renders, as one typed tree — and an optional provider that
@@ -1806,6 +2017,14 @@ interface UiKitLabels {
1806
2017
  authedImage: AuthedImageLabels;
1807
2018
  imageGrid: ImageGridLabels;
1808
2019
  lightbox: LightboxLabels;
2020
+ /** 0.18.0: `WriteLockProvider`'s fallback reason, `AccountStateChip`, `SharePanel` /
2021
+ * `ShareCard` / `ShareDialog`, `ReauthDialog`. */
2022
+ writeLock: WriteLockLabels;
2023
+ accountState: AccountStateLabels;
2024
+ shareCard: ShareCardLabels;
2025
+ reauthDialog: ReauthDialogLabels;
2026
+ /** 0.18.0: `ServerWakeNotice` — keksdose's cold-start notice, for every app. */
2027
+ serverWake: ServerWakeLabels;
1809
2028
  }
1810
2029
  /**
1811
2030
  * A label override, as deep as the labels go: an object of labels (a namespace, or a
@@ -1939,13 +2158,14 @@ declare function useKitFileLabels(prop?: Partial<FileLabels>): FileLabels;
1939
2158
  * The dot paths of every key `labels` does NOT supply — the ones a translated app is
1940
2159
  * still showing in English. Assert on it in the app's own test:
1941
2160
  *
1942
- * expect(missingKitLabels(de)).toEqual([]);
2161
+ * expect(missingKitLabels(labels, DEFAULT_UI_KIT_LABELS)).toEqual([]);
1943
2162
  *
1944
2163
  * `reference` is the complete tree to check against — pass
1945
2164
  * {@link DEFAULT_UI_KIT_LABELS} (src/i18n/defaults.ts). It is a parameter rather than an import
1946
2165
  * because the defaults live beside their components, and this module is imported BY
1947
- * those components.
2166
+ * those components. Left out (a test file is not type-checked), it throws saying so
2167
+ * rather than "Cannot convert undefined or null to object" (keksdose, 0.18).
1948
2168
  */
1949
2169
  declare function missingKitLabels(labels: UiKitLabelOverrides | undefined, reference: UiKitLabels): string[];
1950
2170
 
1951
- export { FIELD_DISPLAY as $, type AppShellLabels as A, Button as B, type CalculatorLabels as C, CopyButton as D, type CopyButtonLabels as E, type CopyButtonProps as F, type CurrencyLabels as G, DEFAULT_APP_SHELL_LABELS as H, DEFAULT_CALCULATOR_LABELS as I, DEFAULT_COMBOBOX_LABELS as J, DEFAULT_COMMON_LABELS as K, DEFAULT_COPY_BUTTON_LABELS as L, DEFAULT_CURRENCY_LABELS as M, DEFAULT_DATE_PICKER_LABELS as N, DEFAULT_FILE_LABELS as O, DEFAULT_FILE_PICKER_LABELS as P, DEFAULT_FORM_ACTIONS_LABELS as Q, DEFAULT_MULTI_SELECT_LABELS as R, DEFAULT_PASSWORD_REVEAL_LABELS as S, DEFAULT_PICKER_SHEET_LABELS as T, DEFAULT_SWIPEABLE_ROW_LABELS as U, DEFAULT_TABS_LABELS as V, DEFAULT_TOP_BAR_LABELS as W, type DatePickerLabels as X, EmptyState as Y, type EmptyStateProps as Z, FIELD_BASE as _, type ButtonClassesOptions as a, type UiKitLabels as a$, FIELD_FLOATING_PAD as a0, FIELD_INVALID as a1, FIELD_TRIGGER as a2, FIELD_WRITABLE_LOOK as a3, FLOATING_INPUT_CLASS as a4, FLOATING_LABEL_CLASS as a5, FLOATING_LABEL_STATIC as a6, FieldChevron as a7, type FieldChevronProps as a8, FieldHint as a9, type IconButtonToneColor as aA, type IconButtonVariant as aB, Input as aC, type InputProps as aD, type KitLinkComponent as aE, type KitLinkProps as aF, Label as aG, type LabelOverride as aH, type LabelProps as aI, type MultiSelectLabels as aJ, PHONE_QUERY as aK, type PasswordRevealLabels as aL, type PickerSheetLabels as aM, type ResponsiveFormActionsPlacement as aN, Select as aO, type SelectProps as aP, Spinner as aQ, type SpinnerProps as aR, type SwipeableRowLabels as aS, type TabItem as aT, Tabs as aU, type TabsLabels as aV, type TabsProps as aW, Textarea as aX, type TextareaProps as aY, type TopBarLabels as aZ, type UiKitLabelOverrides as a_, type FieldHintProps as aa, FieldLabel as ab, type FieldLabelProps as ac, FileButton as ad, type FileButtonProps as ae, type FileLabels as af, type FilePickerLabels as ag, type FileRejection as ah, type FileRejectionReason as ai, type FileScreenOptions as aj, FloatingField as ak, type FloatingFieldProps as al, FormActions as am, type FormActionsAlign as an, type FormActionsDestructive as ao, type FormActionsLabels as ap, type FormActionsPlacement as aq, type FormActionsProps as ar, type FormActionsStickyWithin as as, type FormActionsSubmitProps as at, IconButton as au, type IconButtonGlyphSize as av, type IconButtonLinkProps as aw, type IconButtonProps as ax, type IconButtonSize as ay, type IconButtonTone as az, type ButtonLinkProps as b, UiKitProvider as b0, type UiKitProviderProps as b1, type UseFilePickerOptions as b2, type UseFilePickerReturn as b3, buttonClasses as b4, formatFileSize as b5, matchesAccept as b6, missingKitLabels as b7, resolvePasswordRevealLabels as b8, useFilePicker as b9, useKitChartTooltipPlacement as ba, useKitFileLabels as bb, useKitLabelOverrides as bc, useKitLabels as bd, useKitLink as be, useKitLocale as bf, useKitWeekStart as bg, type FilePickHandler as bh, formatAccept as bi, judgePick as bj, screenFiles as bk, summariseRejections as bl, type ButtonProps as c, type ButtonSize as d, type ButtonTone as e, type ButtonVariant as f, Card as g, CardAction as h, type CardActionProps as i, CardContent as j, type CardContentProps as k, type CardDensity as l, CardDescription as m, type CardDescriptionProps as n, CardFooter as o, type CardFooterProps as p, CardHeader as q, type CardHeaderProps as r, type CardProps as s, CardTitle as t, type CardTitleLevel as u, type CardTitleProps as v, type CardTone as w, type ChartTooltipPlacement as x, type ComboboxLabels as y, type CommonLabels as z };
2171
+ export { FIELD_BASE as $, type AppShellLabels as A, Button as B, type CalculatorLabels as C, CopyButton as D, type CopyButtonLabels as E, type CopyButtonProps as F, type CurrencyLabels as G, DEFAULT_APP_SHELL_LABELS as H, DEFAULT_CALCULATOR_LABELS as I, DEFAULT_COMBOBOX_LABELS as J, DEFAULT_COMMON_LABELS as K, DEFAULT_COPY_BUTTON_LABELS as L, DEFAULT_CURRENCY_LABELS as M, DEFAULT_DATE_PICKER_LABELS as N, DEFAULT_FILE_LABELS as O, DEFAULT_FILE_PICKER_LABELS as P, DEFAULT_FORM_ACTIONS_LABELS as Q, DEFAULT_MULTI_SELECT_LABELS as R, DEFAULT_PASSWORD_REVEAL_LABELS as S, DEFAULT_PICKER_SHEET_LABELS as T, DEFAULT_SHARE_CARD_LABELS as U, DEFAULT_SWIPEABLE_ROW_LABELS as V, DEFAULT_TABS_LABELS as W, DEFAULT_TOP_BAR_LABELS as X, type DatePickerLabels as Y, EmptyState as Z, type EmptyStateProps as _, type ButtonClassesOptions as a, type SharePendingGrant as a$, FIELD_DISPLAY as a0, FIELD_FLOATING_PAD as a1, FIELD_INVALID as a2, FIELD_TRIGGER as a3, FIELD_WRITABLE_LOOK as a4, FLOATING_INPUT_CLASS as a5, FLOATING_LABEL_CLASS as a6, FLOATING_LABEL_STATIC as a7, FieldChevron as a8, type FieldChevronProps as a9, type IconButtonTone as aA, type IconButtonToneColor as aB, type IconButtonVariant as aC, Input as aD, type InputProps as aE, type KitLinkComponent as aF, type KitLinkProps as aG, Label as aH, type LabelOverride as aI, type LabelProps as aJ, type MultiSelectLabels as aK, PHONE_QUERY as aL, type PasswordRevealLabels as aM, type PickerSheetLabels as aN, type ResponsiveFormActionsPlacement as aO, Select as aP, type SelectProps as aQ, type ShareAddRequest as aR, type ShareCandidate as aS, ShareCard as aT, type ShareCardLabels as aU, type ShareCardProps as aV, ShareDialog as aW, type ShareDialogProps as aX, type ShareGrantee as aY, SharePanel as aZ, type SharePanelProps as a_, FieldHint as aa, type FieldHintProps as ab, FieldLabel as ac, type FieldLabelProps as ad, FileButton as ae, type FileButtonProps as af, type FileLabels as ag, type FilePickerLabels as ah, type FileRejection as ai, type FileRejectionReason as aj, type FileScreenOptions as ak, FloatingField as al, type FloatingFieldProps as am, FormActions as an, type FormActionsAlign as ao, type FormActionsDestructive as ap, type FormActionsLabels as aq, type FormActionsPlacement as ar, type FormActionsProps as as, type FormActionsStickyWithin as at, type FormActionsSubmitProps as au, IconButton as av, type IconButtonGlyphSize as aw, type IconButtonLinkProps as ax, type IconButtonProps as ay, type IconButtonSize as az, type ButtonLinkProps as b, type ShareRole as b0, Spinner as b1, type SpinnerProps as b2, type SwipeableRowLabels as b3, type TabItem as b4, Tabs as b5, type TabsLabels as b6, type TabsProps as b7, Textarea as b8, type TextareaProps as b9, type TopBarLabels as ba, type UiKitLabelOverrides as bb, type UiKitLabels as bc, UiKitProvider as bd, type UiKitProviderProps as be, type UseFilePickerOptions as bf, type UseFilePickerReturn as bg, buttonClasses as bh, formatFileSize as bi, matchesAccept as bj, missingKitLabels as bk, resolvePasswordRevealLabels as bl, useFilePicker as bm, useKitChartTooltipPlacement as bn, useKitFileLabels as bo, useKitLabelOverrides as bp, useKitLabels as bq, useKitLink as br, useKitLocale as bs, useKitWeekStart as bt, type FilePickHandler as bu, formatAccept as bv, judgePick as bw, screenFiles as bx, summariseRejections as by, type ButtonProps as c, type ButtonSize as d, type ButtonTone as e, type ButtonVariant as f, Card as g, CardAction as h, type CardActionProps as i, CardContent as j, type CardContentProps as k, type CardDensity as l, CardDescription as m, type CardDescriptionProps as n, CardFooter as o, type CardFooterProps as p, CardHeader as q, type CardHeaderProps as r, type CardProps as s, CardTitle as t, type CardTitleLevel as u, type CardTitleProps as v, type CardTone as w, type ChartTooltipPlacement as x, type ComboboxLabels as y, type CommonLabels as z };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * "Is the server asleep, or is the app broken?" — the cold-start watchdog, lifted
3
+ * from keksdose (feedback #199) so kastlan and Kurvenschmiede get it too.
4
+ *
5
+ * All three backends run on Cloud Run with `--min-instances=0`: after an idle
6
+ * period the container is gone and the first request has to start it, which takes
7
+ * several seconds and occasionally more. Nothing in the UI said so, and none of the
8
+ * axios instances has a timeout, so the honest experience was a spinner that looked
9
+ * stuck. This counts the in-flight requests a cold start would actually delay and
10
+ * escalates in two steps, so a merely slow response says "still loading" and only a
11
+ * genuinely long wait blames the sleeping server.
12
+ *
13
+ * Framework-free on purpose (no zustand, no axios, no React): a counter, two timers
14
+ * and a listener set, read by `useServerWakeStage` through `useSyncExternalStore`.
15
+ * The adapters below take the app's axios instance / fetch as an ARGUMENT, so the
16
+ * kit never imports either.
17
+ *
18
+ * WHAT ARMS IT, and why the default is narrow:
19
+ * - reads (GET) — what every page does on open — always;
20
+ * - writes only when the app says so (`watchReadsAnd(/\/auth\/login\b/)`): the
21
+ * auth POSTs that gate the app are the only writes a user waits on before the
22
+ * app exists at all, so a cold start there looks exactly like a broken login.
23
+ * An UPLOAD must never be watched — a statement PDF or a photo takes seconds on a
24
+ * perfectly warm server, and "the server is waking up" during a 4 s upload would
25
+ * simply be false. With the default that holds by itself (an upload is a POST);
26
+ * an app widening `shouldWatch` keeps its upload routes out of it.
27
+ *
28
+ * It never arms while the browser is offline, whatever `shouldWatch` says: that state
29
+ * has its own, truthful indicator, and a request that never left the device is not
30
+ * evidence of anything about the server.
31
+ */
32
+ /** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold
33
+ * start is the only sensible explanation. */
34
+ type ServerWakeStage = "idle" | "slow" | "waking";
35
+ /** Decides which requests count. `method` arrives lower-cased (`"get"` when the
36
+ * request named none — axios's and fetch's default); `url` as the client had it. */
37
+ type ServerWakeFilter = (method: string, url: string) => boolean;
38
+ interface ServerWakeOptions {
39
+ /** Past this, a warm round trip is out of the question (a cold database query on a
40
+ * free tier can still take a second, so it is not tighter). Default 2000 ms. */
41
+ slowMs?: number;
42
+ /** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */
43
+ wakingMs?: number;
44
+ /** Which requests count. Default {@link watchReadsAnd}`()` — GETs only. The offline
45
+ * guard runs before it and cannot be overridden. */
46
+ shouldWatch?: ServerWakeFilter;
47
+ }
48
+ /** What a request is, for {@link ServerWakeWatcher.track}. */
49
+ interface ServerWakeRequest {
50
+ method?: string;
51
+ url?: string;
52
+ }
53
+ interface ServerWakeWatcher {
54
+ /**
55
+ * Count a request that is going out, and arm the escalation if it is the first.
56
+ * Returns whether it was counted — the caller MUST call {@link end} exactly once
57
+ * for every `true`, or the counter never returns to zero and the notice sticks.
58
+ * (The axios adapter stashes it on the request config for that reason.)
59
+ */
60
+ start(method?: string, url?: string): boolean;
61
+ /** A counted request settled — either way: an error still proves the server
62
+ * answered or gave up. When the last one does, the notice goes away. Never takes
63
+ * the counter below zero. */
64
+ end(): void;
65
+ /** `start` + `end` around a promise (or a function returning one), for a request
66
+ * that does not go through an adapter. Resolves and rejects as the work does. */
67
+ track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;
68
+ /** Back to a pristine watchdog — for tests, which share an instance across cases. */
69
+ reset(): void;
70
+ /** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */
71
+ subscribe(listener: () => void): () => void;
72
+ getStage(): ServerWakeStage;
73
+ /** Counted requests currently outstanding. */
74
+ getInFlight(): number;
75
+ }
76
+ /**
77
+ * The usual filter: every GET, plus any other request whose URL matches `writes` —
78
+ * keksdose's `/\/auth\/(login|register|demo-session)\b/`. Keep upload routes out of
79
+ * the pattern (see the module comment).
80
+ */
81
+ declare function watchReadsAnd(writes?: RegExp): ServerWakeFilter;
82
+ declare function createServerWake({ slowMs, wakingMs, shouldWatch, }?: ServerWakeOptions): ServerWakeWatcher;
83
+ /**
84
+ * The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,
85
+ * {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs
86
+ * only, default timings — an app that also watches its login POST creates its own
87
+ * with {@link createServerWake} and passes it to both ends.
88
+ */
89
+ declare const serverWake: ServerWakeWatcher;
90
+ /** The two config fields the watchdog reads — what axios's request config has. */
91
+ interface ServerWakeAxiosConfig {
92
+ method?: string;
93
+ url?: string;
94
+ }
95
+ /** The slice of an interceptor manager the adapter uses. A method (not a function
96
+ * property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */
97
+ interface InterceptorManagerLike<V> {
98
+ use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;
99
+ eject(id: number): void;
100
+ }
101
+ /** An axios instance, structurally — so the kit types the adapter without importing
102
+ * axios. `axios.create()`'s result fits it as is. */
103
+ interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends {
104
+ config: C;
105
+ }> {
106
+ interceptors: {
107
+ request: InterceptorManagerLike<C>;
108
+ response: InterceptorManagerLike<R>;
109
+ };
110
+ }
111
+ /**
112
+ * Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.
113
+ * Returns a function that ejects both interceptors again.
114
+ *
115
+ * Attach it BEFORE the app's own response interceptors: axios runs those in the order
116
+ * they were added, so the wake one ends the wait before a 401 handler awaits a token
117
+ * refresh — otherwise the notice would keep counting a request that has already been
118
+ * answered. (Request-side order does not matter.)
119
+ */
120
+ declare function attachServerWake<C extends ServerWakeAxiosConfig, R extends {
121
+ config: C;
122
+ }>(instance: AxiosLikeInstance<C, R>, watcher?: ServerWakeWatcher): () => void;
123
+ /**
124
+ * A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends
125
+ * when the response's HEADERS arrive (or the call fails) — the server is awake by then,
126
+ * however long the body takes.
127
+ */
128
+ declare function wrapFetch(fetchImpl?: typeof fetch, watcher?: ServerWakeWatcher): typeof fetch;
129
+
130
+ export { type AxiosLikeInstance, type ServerWakeAxiosConfig, type ServerWakeFilter, type ServerWakeOptions, type ServerWakeRequest, type ServerWakeStage, type ServerWakeWatcher, attachServerWake, createServerWake, serverWake, watchReadsAnd, wrapFetch };
@@ -0,0 +1,121 @@
1
+ "use client";
2
+ function watchReadsAnd(writes) {
3
+ return (method, url) => method === "get" || writes !== void 0 && writes.test(url);
4
+ }
5
+ function isOffline() {
6
+ return typeof navigator !== "undefined" && navigator.onLine === false;
7
+ }
8
+ function createServerWake({
9
+ slowMs = 2e3,
10
+ wakingMs = 7e3,
11
+ shouldWatch = watchReadsAnd()
12
+ } = {}) {
13
+ let inFlight = 0;
14
+ let stage = "idle";
15
+ let slowTimer = null;
16
+ let wakingTimer = null;
17
+ const listeners = /* @__PURE__ */ new Set();
18
+ const setStage = (next) => {
19
+ if (next === stage) return;
20
+ stage = next;
21
+ for (const listener of listeners) listener();
22
+ };
23
+ const clearTimers = () => {
24
+ if (slowTimer !== null) clearTimeout(slowTimer);
25
+ if (wakingTimer !== null) clearTimeout(wakingTimer);
26
+ slowTimer = null;
27
+ wakingTimer = null;
28
+ };
29
+ const start = (method, url) => {
30
+ if (isOffline()) return false;
31
+ if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "")) return false;
32
+ inFlight += 1;
33
+ if (inFlight === 1) {
34
+ clearTimers();
35
+ slowTimer = setTimeout(() => setStage("slow"), slowMs);
36
+ wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
37
+ }
38
+ return true;
39
+ };
40
+ const end = () => {
41
+ inFlight = Math.max(0, inFlight - 1);
42
+ if (inFlight > 0) return;
43
+ clearTimers();
44
+ setStage("idle");
45
+ };
46
+ return {
47
+ start,
48
+ end,
49
+ track(work, request = {}) {
50
+ const counted = start(request.method, request.url);
51
+ let promise;
52
+ try {
53
+ promise = Promise.resolve(typeof work === "function" ? work() : work);
54
+ } catch (error) {
55
+ promise = Promise.reject(error);
56
+ }
57
+ return counted ? promise.finally(end) : promise;
58
+ },
59
+ reset() {
60
+ inFlight = 0;
61
+ clearTimers();
62
+ setStage("idle");
63
+ },
64
+ subscribe(listener) {
65
+ listeners.add(listener);
66
+ return () => {
67
+ listeners.delete(listener);
68
+ };
69
+ },
70
+ getStage: () => stage,
71
+ getInFlight: () => inFlight
72
+ };
73
+ }
74
+ const serverWake = createServerWake();
75
+ const COUNTED = "_serverWake";
76
+ function attachServerWake(instance, watcher = serverWake) {
77
+ const settle = (config) => {
78
+ const marked = config;
79
+ if (marked?.[COUNTED]) {
80
+ marked[COUNTED] = false;
81
+ watcher.end();
82
+ }
83
+ };
84
+ const request = instance.interceptors.request.use(
85
+ (config) => {
86
+ config[COUNTED] = watcher.start(config.method, config.url);
87
+ return config;
88
+ },
89
+ (error) => Promise.reject(error)
90
+ );
91
+ const response = instance.interceptors.response.use(
92
+ (res) => {
93
+ settle(res.config);
94
+ return res;
95
+ },
96
+ (error) => {
97
+ settle(error?.config);
98
+ return Promise.reject(error);
99
+ }
100
+ );
101
+ return () => {
102
+ instance.interceptors.request.eject(request);
103
+ instance.interceptors.response.eject(response);
104
+ };
105
+ }
106
+ function wrapFetch(fetchImpl = globalThis.fetch, watcher = serverWake) {
107
+ return (input, init) => {
108
+ const request = typeof Request !== "undefined" && input instanceof Request ? input : void 0;
109
+ const method = init?.method ?? request?.method;
110
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : request?.url ?? "";
111
+ return watcher.track(() => fetchImpl(input, init), { method, url });
112
+ };
113
+ }
114
+ export {
115
+ attachServerWake,
116
+ createServerWake,
117
+ serverWake,
118
+ watchReadsAnd,
119
+ wrapFetch
120
+ };
121
+ //# sourceMappingURL=server-wake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/server-wake.ts"],"sourcesContent":["/**\n * \"Is the server asleep, or is the app broken?\" — the cold-start watchdog, lifted\n * from keksdose (feedback #199) so kastlan and Kurvenschmiede get it too.\n *\n * All three backends run on Cloud Run with `--min-instances=0`: after an idle\n * period the container is gone and the first request has to start it, which takes\n * several seconds and occasionally more. Nothing in the UI said so, and none of the\n * axios instances has a timeout, so the honest experience was a spinner that looked\n * stuck. This counts the in-flight requests a cold start would actually delay and\n * escalates in two steps, so a merely slow response says \"still loading\" and only a\n * genuinely long wait blames the sleeping server.\n *\n * Framework-free on purpose (no zustand, no axios, no React): a counter, two timers\n * and a listener set, read by `useServerWakeStage` through `useSyncExternalStore`.\n * The adapters below take the app's axios instance / fetch as an ARGUMENT, so the\n * kit never imports either.\n *\n * WHAT ARMS IT, and why the default is narrow:\n * - reads (GET) — what every page does on open — always;\n * - writes only when the app says so (`watchReadsAnd(/\\/auth\\/login\\b/)`): the\n * auth POSTs that gate the app are the only writes a user waits on before the\n * app exists at all, so a cold start there looks exactly like a broken login.\n * An UPLOAD must never be watched — a statement PDF or a photo takes seconds on a\n * perfectly warm server, and \"the server is waking up\" during a 4 s upload would\n * simply be false. With the default that holds by itself (an upload is a POST);\n * an app widening `shouldWatch` keeps its upload routes out of it.\n *\n * It never arms while the browser is offline, whatever `shouldWatch` says: that state\n * has its own, truthful indicator, and a request that never left the device is not\n * evidence of anything about the server.\n */\n\n/** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold\n * start is the only sensible explanation. */\nexport type ServerWakeStage = \"idle\" | \"slow\" | \"waking\";\n\n/** Decides which requests count. `method` arrives lower-cased (`\"get\"` when the\n * request named none — axios's and fetch's default); `url` as the client had it. */\nexport type ServerWakeFilter = (method: string, url: string) => boolean;\n\nexport interface ServerWakeOptions {\n /** Past this, a warm round trip is out of the question (a cold database query on a\n * free tier can still take a second, so it is not tighter). Default 2000 ms. */\n slowMs?: number;\n /** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */\n wakingMs?: number;\n /** Which requests count. Default {@link watchReadsAnd}`()` — GETs only. The offline\n * guard runs before it and cannot be overridden. */\n shouldWatch?: ServerWakeFilter;\n}\n\n/** What a request is, for {@link ServerWakeWatcher.track}. */\nexport interface ServerWakeRequest {\n method?: string;\n url?: string;\n}\n\nexport interface ServerWakeWatcher {\n /**\n * Count a request that is going out, and arm the escalation if it is the first.\n * Returns whether it was counted — the caller MUST call {@link end} exactly once\n * for every `true`, or the counter never returns to zero and the notice sticks.\n * (The axios adapter stashes it on the request config for that reason.)\n */\n start(method?: string, url?: string): boolean;\n /** A counted request settled — either way: an error still proves the server\n * answered or gave up. When the last one does, the notice goes away. Never takes\n * the counter below zero. */\n end(): void;\n /** `start` + `end` around a promise (or a function returning one), for a request\n * that does not go through an adapter. Resolves and rejects as the work does. */\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;\n /** Back to a pristine watchdog — for tests, which share an instance across cases. */\n reset(): void;\n /** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */\n subscribe(listener: () => void): () => void;\n getStage(): ServerWakeStage;\n /** Counted requests currently outstanding. */\n getInFlight(): number;\n}\n\n/**\n * The usual filter: every GET, plus any other request whose URL matches `writes` —\n * keksdose's `/\\/auth\\/(login|register|demo-session)\\b/`. Keep upload routes out of\n * the pattern (see the module comment).\n */\nexport function watchReadsAnd(writes?: RegExp): ServerWakeFilter {\n return (method, url) => method === \"get\" || (writes !== undefined && writes.test(url));\n}\n\nfunction isOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\nexport function createServerWake({\n slowMs = 2000,\n wakingMs = 7000,\n shouldWatch = watchReadsAnd(),\n}: ServerWakeOptions = {}): ServerWakeWatcher {\n let inFlight = 0;\n let stage: ServerWakeStage = \"idle\";\n let slowTimer: ReturnType<typeof setTimeout> | null = null;\n let wakingTimer: ReturnType<typeof setTimeout> | null = null;\n const listeners = new Set<() => void>();\n\n const setStage = (next: ServerWakeStage) => {\n if (next === stage) return;\n stage = next;\n for (const listener of listeners) listener();\n };\n\n const clearTimers = () => {\n if (slowTimer !== null) clearTimeout(slowTimer);\n if (wakingTimer !== null) clearTimeout(wakingTimer);\n slowTimer = null;\n wakingTimer = null;\n };\n\n const start = (method?: string, url?: string): boolean => {\n if (isOffline()) return false;\n if (!shouldWatch((method ?? \"get\").toLowerCase(), url ?? \"\")) return false;\n inFlight += 1;\n // The clock runs from the FIRST outstanding request: a page firing a second read\n // at 1.9 s is still waiting on the same sleeping server, not starting afresh.\n if (inFlight === 1) {\n clearTimers();\n slowTimer = setTimeout(() => setStage(\"slow\"), slowMs);\n wakingTimer = setTimeout(() => setStage(\"waking\"), wakingMs);\n }\n return true;\n };\n\n const end = () => {\n inFlight = Math.max(0, inFlight - 1);\n if (inFlight > 0) return;\n clearTimers();\n setStage(\"idle\");\n };\n\n return {\n start,\n end,\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request: ServerWakeRequest = {}) {\n const counted = start(request.method, request.url);\n let promise: Promise<T>;\n try {\n promise = Promise.resolve(typeof work === \"function\" ? work() : work);\n } catch (error) {\n // A thunk that throws before it returns a promise settled too.\n promise = Promise.reject(error);\n }\n return counted ? promise.finally(end) : promise;\n },\n reset() {\n inFlight = 0;\n clearTimers();\n setStage(\"idle\");\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getStage: () => stage,\n getInFlight: () => inFlight,\n };\n}\n\n/**\n * The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,\n * {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs\n * only, default timings — an app that also watches its login POST creates its own\n * with {@link createServerWake} and passes it to both ends.\n */\nexport const serverWake: ServerWakeWatcher = createServerWake();\n\n// ── axios ─────────────────────────────────────────────────────────────────────\n\n/** The two config fields the watchdog reads — what axios's request config has. */\nexport interface ServerWakeAxiosConfig {\n method?: string;\n url?: string;\n}\n\n/** The slice of an interceptor manager the adapter uses. A method (not a function\n * property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */\ninterface InterceptorManagerLike<V> {\n use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;\n eject(id: number): void;\n}\n\n/** An axios instance, structurally — so the kit types the adapter without importing\n * axios. `axios.create()`'s result fits it as is. */\nexport interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends { config: C }> {\n interceptors: {\n request: InterceptorManagerLike<C>;\n response: InterceptorManagerLike<R>;\n };\n}\n\n/** Whether the watchdog counted this request — the response side owes a matching\n * `end()` only then. On the config, as keksdose's `_wake` was: it travels with the\n * request onto the response and onto an error. Cleared once settled, so a 401 replay\n * of the same config re-enters the request interceptor and is counted afresh — the\n * pairing stays balanced. */\nconst COUNTED = \"_serverWake\";\ntype Marked = { [COUNTED]?: boolean };\n\n/**\n * Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.\n * Returns a function that ejects both interceptors again.\n *\n * Attach it BEFORE the app's own response interceptors: axios runs those in the order\n * they were added, so the wake one ends the wait before a 401 handler awaits a token\n * refresh — otherwise the notice would keep counting a request that has already been\n * answered. (Request-side order does not matter.)\n */\nexport function attachServerWake<C extends ServerWakeAxiosConfig, R extends { config: C }>(\n instance: AxiosLikeInstance<C, R>,\n watcher: ServerWakeWatcher = serverWake,\n): () => void {\n const settle = (config: unknown) => {\n const marked = config as Marked | undefined;\n if (marked?.[COUNTED]) {\n marked[COUNTED] = false;\n watcher.end();\n }\n };\n const request = instance.interceptors.request.use(\n (config) => {\n (config as C & Marked)[COUNTED] = watcher.start(config.method, config.url);\n return config;\n },\n (error) => Promise.reject(error),\n );\n const response = instance.interceptors.response.use(\n (res) => {\n settle(res.config);\n return res;\n },\n (error) => {\n settle((error as { config?: unknown } | null)?.config);\n return Promise.reject(error);\n },\n );\n return () => {\n instance.interceptors.request.eject(request);\n instance.interceptors.response.eject(response);\n };\n}\n\n// ── fetch ─────────────────────────────────────────────────────────────────────\n\n/**\n * A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends\n * when the response's HEADERS arrive (or the call fails) — the server is awake by then,\n * however long the body takes.\n */\nexport function wrapFetch(\n fetchImpl: typeof fetch = globalThis.fetch,\n watcher: ServerWakeWatcher = serverWake,\n): typeof fetch {\n return (input, init) => {\n const request = typeof Request !== \"undefined\" && input instanceof Request ? input : undefined;\n const method = init?.method ?? request?.method;\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (request?.url ?? \"\");\n return watcher.track(() => fetchImpl(input, init), { method, url });\n };\n}\n"],"mappings":";AAsFO,SAAS,cAAc,QAAmC;AAC/D,SAAO,CAAC,QAAQ,QAAQ,WAAW,SAAU,WAAW,UAAa,OAAO,KAAK,GAAG;AACtF;AAEA,SAAS,YAAqB;AAC5B,SAAO,OAAO,cAAc,eAAe,UAAU,WAAW;AAClE;AAEO,SAAS,iBAAiB;AAAA,EAC/B,SAAS;AAAA,EACT,WAAW;AAAA,EACX,cAAc,cAAc;AAC9B,IAAuB,CAAC,GAAsB;AAC5C,MAAI,WAAW;AACf,MAAI,QAAyB;AAC7B,MAAI,YAAkD;AACtD,MAAI,cAAoD;AACxD,QAAM,YAAY,oBAAI,IAAgB;AAEtC,QAAM,WAAW,CAAC,SAA0B;AAC1C,QAAI,SAAS,MAAO;AACpB,YAAQ;AACR,eAAW,YAAY,UAAW,UAAS;AAAA,EAC7C;AAEA,QAAM,cAAc,MAAM;AACxB,QAAI,cAAc,KAAM,cAAa,SAAS;AAC9C,QAAI,gBAAgB,KAAM,cAAa,WAAW;AAClD,gBAAY;AACZ,kBAAc;AAAA,EAChB;AAEA,QAAM,QAAQ,CAAC,QAAiB,QAA0B;AACxD,QAAI,UAAU,EAAG,QAAO;AACxB,QAAI,CAAC,aAAa,UAAU,OAAO,YAAY,GAAG,OAAO,EAAE,EAAG,QAAO;AACrE,gBAAY;AAGZ,QAAI,aAAa,GAAG;AAClB,kBAAY;AACZ,kBAAY,WAAW,MAAM,SAAS,MAAM,GAAG,MAAM;AACrD,oBAAc,WAAW,MAAM,SAAS,QAAQ,GAAG,QAAQ;AAAA,IAC7D;AACA,WAAO;AAAA,EACT;AAEA,QAAM,MAAM,MAAM;AAChB,eAAW,KAAK,IAAI,GAAG,WAAW,CAAC;AACnC,QAAI,WAAW,EAAG;AAClB,gBAAY;AACZ,aAAS,MAAM;AAAA,EACjB;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,MAAS,MAA+C,UAA6B,CAAC,GAAG;AACvF,YAAM,UAAU,MAAM,QAAQ,QAAQ,QAAQ,GAAG;AACjD,UAAI;AACJ,UAAI;AACF,kBAAU,QAAQ,QAAQ,OAAO,SAAS,aAAa,KAAK,IAAI,IAAI;AAAA,MACtE,SAAS,OAAO;AAEd,kBAAU,QAAQ,OAAO,KAAK;AAAA,MAChC;AACA,aAAO,UAAU,QAAQ,QAAQ,GAAG,IAAI;AAAA,IAC1C;AAAA,IACA,QAAQ;AACN,iBAAW;AACX,kBAAY;AACZ,eAAS,MAAM;AAAA,IACjB;AAAA,IACA,UAAU,UAAU;AAClB,gBAAU,IAAI,QAAQ;AACtB,aAAO,MAAM;AACX,kBAAU,OAAO,QAAQ;AAAA,MAC3B;AAAA,IACF;AAAA,IACA,UAAU,MAAM;AAAA,IAChB,aAAa,MAAM;AAAA,EACrB;AACF;AAQO,MAAM,aAAgC,iBAAiB;AA+B9D,MAAM,UAAU;AAYT,SAAS,iBACd,UACA,UAA6B,YACjB;AACZ,QAAM,SAAS,CAAC,WAAoB;AAClC,UAAM,SAAS;AACf,QAAI,SAAS,OAAO,GAAG;AACrB,aAAO,OAAO,IAAI;AAClB,cAAQ,IAAI;AAAA,IACd;AAAA,EACF;AACA,QAAM,UAAU,SAAS,aAAa,QAAQ;AAAA,IAC5C,CAAC,WAAW;AACV,MAAC,OAAsB,OAAO,IAAI,QAAQ,MAAM,OAAO,QAAQ,OAAO,GAAG;AACzE,aAAO;AAAA,IACT;AAAA,IACA,CAAC,UAAU,QAAQ,OAAO,KAAK;AAAA,EACjC;AACA,QAAM,WAAW,SAAS,aAAa,SAAS;AAAA,IAC9C,CAAC,QAAQ;AACP,aAAO,IAAI,MAAM;AACjB,aAAO;AAAA,IACT;AAAA,IACA,CAAC,UAAU;AACT,aAAQ,OAAuC,MAAM;AACrD,aAAO,QAAQ,OAAO,KAAK;AAAA,IAC7B;AAAA,EACF;AACA,SAAO,MAAM;AACX,aAAS,aAAa,QAAQ,MAAM,OAAO;AAC3C,aAAS,aAAa,SAAS,MAAM,QAAQ;AAAA,EAC/C;AACF;AASO,SAAS,UACd,YAA0B,WAAW,OACrC,UAA6B,YACf;AACd,SAAO,CAAC,OAAO,SAAS;AACtB,UAAM,UAAU,OAAO,YAAY,eAAe,iBAAiB,UAAU,QAAQ;AACrF,UAAM,SAAS,MAAM,UAAU,SAAS;AACxC,UAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,iBAAiB,MAAM,MAAM,OAAQ,SAAS,OAAO;AACrG,WAAO,QAAQ,MAAM,MAAM,UAAU,OAAO,IAAI,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpE;AACF;","names":[]}