@eifi1/ui-kit 0.17.0 → 0.19.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 (210) hide show
  1. package/README.md +83 -4
  2. package/dist/chart.d.ts +13 -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 +13 -1
  10. package/dist/components/button-group.d.ts +13 -1
  11. package/dist/components/calculator.d.ts +13 -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 +13 -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 +13 -1
  29. package/dist/components/file-button.d.ts +13 -1
  30. package/dist/components/file-dropzone.d.ts +13 -1
  31. package/dist/components/floating-panel.d.ts +4 -1
  32. package/dist/components/floating-panel.js +15 -1
  33. package/dist/components/floating-panel.js.map +1 -1
  34. package/dist/components/form-actions.d.ts +13 -1
  35. package/dist/components/form-actions.js +3 -0
  36. package/dist/components/form-actions.js.map +1 -1
  37. package/dist/components/legal.d.ts +84 -0
  38. package/dist/components/legal.js +55 -0
  39. package/dist/components/legal.js.map +1 -0
  40. package/dist/components/money-field.d.ts +13 -1
  41. package/dist/components/number-field.d.ts +14 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +13 -1
  45. package/dist/components/numpad-sheet.d.ts +13 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +15 -1
  50. package/dist/components/progress-bar.js +8 -1
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +13 -1
  56. package/dist/components/server-wake.d.ts +60 -0
  57. package/dist/components/server-wake.js +79 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +13 -1
  60. package/dist/components/share-card.d.ts +64 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/status-dot.d.ts +2 -0
  64. package/dist/components/text-link.d.ts +13 -1
  65. package/dist/components/time-input.d.ts +13 -1
  66. package/dist/components/toast.d.ts +10 -1
  67. package/dist/components/toast.js +42 -2
  68. package/dist/components/toast.js.map +1 -1
  69. package/dist/components/toggle-group.js +5 -2
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/translation-review-editor.d.ts +56 -0
  72. package/dist/components/translation-review-editor.js +159 -0
  73. package/dist/components/translation-review-editor.js.map +1 -0
  74. package/dist/components/translation-review-labels.d.ts +92 -0
  75. package/dist/components/translation-review-labels.js +67 -0
  76. package/dist/components/translation-review-labels.js.map +1 -0
  77. package/dist/components/translation-review-parts.d.ts +97 -0
  78. package/dist/components/translation-review-parts.js +163 -0
  79. package/dist/components/translation-review-parts.js.map +1 -0
  80. package/dist/components/translation-review.d.ts +94 -0
  81. package/dist/components/translation-review.js +344 -0
  82. package/dist/components/translation-review.js.map +1 -0
  83. package/dist/components/ui.d.ts +13 -1
  84. package/dist/components/ui.js +43 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/components/use-table-state.d.ts +1 -1
  87. package/dist/components/user-avatar.d.ts +2 -0
  88. package/dist/components/write-lock.d.ts +68 -0
  89. package/dist/components/write-lock.js +33 -0
  90. package/dist/components/write-lock.js.map +1 -0
  91. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  92. package/dist/data-table.d.ts +1 -1
  93. package/dist/hooks/use-file-drop.d.ts +13 -1
  94. package/dist/hooks/use-overlay-history.js +16 -0
  95. package/dist/hooks/use-overlay-history.js.map +1 -1
  96. package/dist/i18n/defaults.d.ts +13 -1
  97. package/dist/i18n/defaults.js +15 -1
  98. package/dist/i18n/defaults.js.map +1 -1
  99. package/dist/i18n/german.d.ts +81 -0
  100. package/dist/i18n/{locales/de.js → german.js} +110 -9
  101. package/dist/i18n/german.js.map +1 -0
  102. package/dist/i18n/kit-labels.d.ts +13 -1
  103. package/dist/i18n/kit-labels.js +5 -0
  104. package/dist/i18n/kit-labels.js.map +1 -1
  105. package/dist/i18n/languages.d.ts +243 -0
  106. package/dist/i18n/languages.js +70 -0
  107. package/dist/i18n/languages.js.map +1 -0
  108. package/dist/i18n/locales/de-CH.d.ts +26 -8
  109. package/dist/i18n/locales/de-CH.js +7 -3
  110. package/dist/i18n/locales/de-CH.js.map +1 -1
  111. package/dist/i18n/locales/en.d.ts +13 -1
  112. package/dist/i18n/locales/en.js +8 -0
  113. package/dist/i18n/locales/en.js.map +1 -1
  114. package/dist/i18n/locales/es.d.ts +13 -1
  115. package/dist/i18n/locales/es.js +127 -27
  116. package/dist/i18n/locales/es.js.map +1 -1
  117. package/dist/i18n/locales/fr.d.ts +13 -1
  118. package/dist/i18n/locales/fr.js +101 -1
  119. package/dist/i18n/locales/fr.js.map +1 -1
  120. package/dist/i18n/locales/hu.d.ts +13 -1
  121. package/dist/i18n/locales/hu.js +105 -1
  122. package/dist/i18n/locales/hu.js.map +1 -1
  123. package/dist/i18n/locales/it.d.ts +13 -1
  124. package/dist/i18n/locales/it.js +134 -34
  125. package/dist/i18n/locales/it.js.map +1 -1
  126. package/dist/i18n/locales/zh.d.ts +13 -1
  127. package/dist/i18n/locales/zh.js +101 -1
  128. package/dist/i18n/locales/zh.js.map +1 -1
  129. package/dist/i18n/review.d.ts +102 -0
  130. package/dist/i18n/review.js +158 -0
  131. package/dist/i18n/review.js.map +1 -0
  132. package/dist/i18n/swiss.d.ts +3 -3
  133. package/dist/i18n/swiss.js.map +1 -1
  134. package/dist/index.d.ts +17 -4
  135. package/dist/index.js +97 -1
  136. package/dist/index.js.map +1 -1
  137. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
  138. package/dist/lib/floating-stack.d.ts +31 -0
  139. package/dist/lib/floating-stack.js +42 -0
  140. package/dist/lib/floating-stack.js.map +1 -0
  141. package/dist/lib/server-wake.d.ts +146 -0
  142. package/dist/lib/server-wake.js +122 -0
  143. package/dist/lib/server-wake.js.map +1 -0
  144. package/dist/lib/translation-review.d.ts +232 -0
  145. package/dist/lib/translation-review.js +194 -0
  146. package/dist/lib/translation-review.js.map +1 -0
  147. package/dist/rhf/fields.d.ts +13 -1
  148. package/dist/rhf/form.d.ts +13 -1
  149. package/dist/rhf.d.ts +13 -1
  150. package/dist/search/global-search.d.ts +3 -1
  151. package/dist/search/global-search.js.map +1 -1
  152. package/dist/shell/app-shell.d.ts +13 -1
  153. package/dist/shell/top-bar-brand.d.ts +13 -1
  154. package/dist/shell/topbar-action-menu.d.ts +2 -0
  155. package/dist/shell.d.ts +13 -1
  156. package/dist/wizard/stepper-nav.d.ts +13 -1
  157. package/dist/wizard.d.ts +13 -1
  158. package/package.json +1 -1
  159. package/src/components/account-chips.tsx +302 -0
  160. package/src/components/account-settings-labels.ts +16 -2
  161. package/src/components/chip.tsx +239 -6
  162. package/src/components/confirm-dialog.tsx +82 -7
  163. package/src/components/danger-confirm.tsx +118 -33
  164. package/src/components/data-table.tsx +20 -0
  165. package/src/components/floating-panel.tsx +19 -1
  166. package/src/components/form-actions.tsx +22 -3
  167. package/src/components/legal.tsx +159 -0
  168. package/src/components/number-field.tsx +32 -26
  169. package/src/components/passkeys-setting.tsx +68 -7
  170. package/src/components/progress-bar.tsx +21 -1
  171. package/src/components/reauth-dialog.tsx +273 -0
  172. package/src/components/server-wake.tsx +156 -0
  173. package/src/components/share-card.tsx +615 -0
  174. package/src/components/toast.tsx +72 -2
  175. package/src/components/toggle-group.tsx +5 -2
  176. package/src/components/translation-review-editor.tsx +261 -0
  177. package/src/components/translation-review-labels.ts +152 -0
  178. package/src/components/translation-review-parts.tsx +269 -0
  179. package/src/components/translation-review.tsx +502 -0
  180. package/src/components/ui.tsx +89 -32
  181. package/src/components/write-lock.tsx +96 -0
  182. package/src/hooks/use-overlay-history.ts +51 -0
  183. package/src/i18n/defaults.ts +14 -0
  184. package/src/i18n/{locales/de.ts → german.ts} +120 -13
  185. package/src/i18n/kit-labels.tsx +29 -2
  186. package/src/i18n/languages.ts +285 -0
  187. package/src/i18n/locales/de-CH.ts +17 -8
  188. package/src/i18n/locales/en.ts +8 -0
  189. package/src/i18n/locales/es.ts +128 -26
  190. package/src/i18n/locales/fr.ts +103 -0
  191. package/src/i18n/locales/hu.ts +107 -0
  192. package/src/i18n/locales/it.ts +135 -33
  193. package/src/i18n/locales/zh.ts +102 -0
  194. package/src/i18n/review.ts +193 -0
  195. package/src/i18n/swiss.ts +3 -3
  196. package/src/index.ts +86 -1
  197. package/src/lib/floating-stack.ts +66 -0
  198. package/src/lib/server-wake.ts +291 -0
  199. package/src/lib/translation-review.ts +465 -0
  200. package/src/search/global-search.tsx +3 -1
  201. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  202. package/dist/i18n/locales/de-CH-informal.js +0 -8
  203. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  204. package/dist/i18n/locales/de-informal.d.ts +0 -82
  205. package/dist/i18n/locales/de-informal.js +0 -37
  206. package/dist/i18n/locales/de-informal.js.map +0 -1
  207. package/dist/i18n/locales/de.d.ts +0 -66
  208. package/dist/i18n/locales/de.js.map +0 -1
  209. package/src/i18n/locales/de-CH-informal.ts +0 -10
  210. package/src/i18n/locales/de-informal.ts +0 -61
package/src/index.ts CHANGED
@@ -109,7 +109,13 @@ export * from "./components/password-strength";
109
109
  export * from "./components/page-contents";
110
110
  export * from "./components/disclosure";
111
111
  export * from "./components/dialog-frame";
112
- export * from "./components/danger-confirm";
112
+ // Named, not `*`: danger-confirm also holds the password field and pending hook that
113
+ // ReauthDialog shares with it, which are kit-internal.
114
+ export { DangerConfirm, DEFAULT_DANGER_CONFIRM_LABELS, typedMatches } from "./components/danger-confirm";
115
+ export type { DangerConfirmLabels, DangerConfirmProps, TypedMatch } from "./components/danger-confirm";
116
+ // 0.18: re-authentication before a sensitive action (Kurvenschmiede 4).
117
+ export { ReauthDialog, DEFAULT_REAUTH_DIALOG_LABELS } from "./components/reauth-dialog";
118
+ export type { ReauthDialogProps, ReauthDialogLabels } from "./components/reauth-dialog";
113
119
  // `useConfirm()` — the promise-based replacement for `window.confirm`, one host per app.
114
120
  export * from "./components/confirm-dialog";
115
121
  // A non-modal corner panel and its round trigger (keksdose's assistant launcher).
@@ -174,6 +180,22 @@ export * from "./components/description-list";
174
180
  // The Save / Cancel row forms and dialogs end in, and the editable line-item repeater
175
181
  // (journal lines, lease components, invoice positions). 0.12.0.
176
182
  export * from "./components/form-actions";
183
+ // 0.18: lock every opted-in commit under one provider, the reason in its tooltip
184
+ // (Kurvenschmiede 2). `useCommitReason` stays internal.
185
+ export { WriteLockProvider, useWriteLock, DEFAULT_WRITE_LOCK_LABELS } from "./components/write-lock";
186
+ export type { WriteLock, WriteLockLabels, WriteLockProviderProps } from "./components/write-lock";
187
+ // 0.18: the parts both apps' admin rosters repeat (Kurvenschmiede 6).
188
+ export { RoleChip, AccountStateChip, DateMark, dateColumn, DEFAULT_ACCOUNT_STATE_LABELS, ACCOUNT_STATE_TONES } from "./components/account-chips";
189
+ export type { RoleChipProps, RoleDefinition, RoleVocabulary, AccountState, AccountStateLabels, AccountStateChipProps, DateMarkProps, DateColumnOptions } from "./components/account-chips";
190
+ // 0.18: the cold-start notice keksdose built (#199), for every app: a framework-free
191
+ // watchdog over the app's requests and the corner notice that explains the wait.
192
+ export { createServerWake, serverWake, watchReadsAnd, attachServerWake, wrapFetch } from "./lib/server-wake";
193
+ export type { ServerWakeStage, ServerWakeFilter, ServerWakeOptions, ServerWakeRequest, ServerWakeWatcher, ServerWakeAxiosConfig, AxiosLikeInstance } from "./lib/server-wake";
194
+ export { ServerWakeNotice, useServerWakeStage, DEFAULT_SERVER_WAKE_LABELS } from "./components/server-wake";
195
+ export type { ServerWakeLabels, ServerWakeNoticeProps } from "./components/server-wake";
196
+ // 0.18: sharing — grantees, roles, pending grants, candidates (Kurvenschmiede 1).
197
+ export { ShareCard, ShareDialog, SharePanel, DEFAULT_SHARE_CARD_LABELS } from "./components/share-card";
198
+ export type { ShareCardProps, ShareDialogProps, SharePanelProps, ShareCardLabels, ShareRole, ShareGrantee, SharePendingGrant, ShareCandidate, ShareAddRequest } from "./components/share-card";
177
199
  export * from "./components/line-items";
178
200
  export * from "./components/progress-bar";
179
201
  // Named: skeleton.tsx also holds SKELETON_CLASS, the look StatTile shares — internal.
@@ -319,3 +341,66 @@ export * from "./search/global-search";
319
341
  // prop > the provider > the English default. See src/i18n/kit-labels.tsx.
320
342
  export * from "./i18n/kit-labels";
321
343
  export * from "./i18n/defaults";
344
+ // The kit's words as `key → text` rows for an app's translation review (keksdose).
345
+ export { kitLabelStrings } from "./i18n/review";
346
+ // The seven languages, how an app resolves one, and the kit catalogue loader for it.
347
+ export * from "./i18n/languages";
348
+ // Translation review (0.19): one contract for kastlan's and keksdose's review pages —
349
+ // the pure rows/status/placeholder logic and the presentational page parts.
350
+ export {
351
+ REVIEW_STATUSES,
352
+ fromApiReview,
353
+ toApiWrite,
354
+ flattenStrings,
355
+ keyNamespace,
356
+ placeholderTokens,
357
+ placeholderMismatch,
358
+ reviewStatus,
359
+ keyInArea,
360
+ keyInAreas,
361
+ translationRows,
362
+ summariseRows,
363
+ DEFAULT_TRANSLATION_REVIEW_FILTER,
364
+ filterTranslationRows,
365
+ reviewWrite,
366
+ mergeReviews,
367
+ dropReviews,
368
+ translationCorrections,
369
+ } from "./lib/translation-review";
370
+ export type {
371
+ TranslationVerdict,
372
+ TranslationReview,
373
+ TranslationReviewWrite,
374
+ TranslationReviewKey,
375
+ ReviewStatus,
376
+ TranslationRow,
377
+ ApiTranslationReview,
378
+ ApiTranslationReviewWrite,
379
+ TranslationRowsInput,
380
+ TranslationSummary,
381
+ TranslationReviewFilter,
382
+ TranslationCorrection,
383
+ } from "./lib/translation-review";
384
+ export {
385
+ TranslationReviewPanel,
386
+ TranslationReviewEditor,
387
+ ReviewStatusChip,
388
+ TranslationProgress,
389
+ TranslationLocaleTabs,
390
+ TranslationExportButton,
391
+ REVIEW_STATUS_TONES,
392
+ DEFAULT_TRANSLATION_REVIEW_LABELS,
393
+ } from "./components/translation-review";
394
+ export type {
395
+ TranslationReviewPanelProps,
396
+ TranslationReviewEditorProps,
397
+ ReviewStatusChipProps,
398
+ TranslationProgressProps,
399
+ TranslationLocaleTab,
400
+ TranslationLocaleTabsProps,
401
+ TranslationExportButtonProps,
402
+ TranslationReviewLabels,
403
+ } from "./components/translation-review";
404
+ // The legal pages' shell (0.19, H10): Imprint, Privacy Policy, Terms in every app.
405
+ export { LegalLayout, LegalSection, LegalLinks, DEFAULT_LEGAL_LABELS } from "./components/legal";
406
+ export type { LegalLabels, LegalLink, LegalLinksProps, LegalSectionProps, LegalLayoutProps } from "./components/legal";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What floats over the bottom of the screen right now, so a notice that comes and goes
3
+ * can sit above it instead of on it.
4
+ *
5
+ * kastlan 0.18: its offline pill is a `bottom-start` FloatingActionButton, and
6
+ * `ServerWakeNotice` sits in the same corner — right after a break both show at once
7
+ * (the photo outbox syncs while the first reads wake the server), one over the other.
8
+ * The FAB's `offset` cannot help: the notice comes and goes, and on a phone it is
9
+ * nearly as wide as the screen, so a FAB in EITHER corner can be under it.
10
+ *
11
+ * Measured, not declared: a FloatingActionButton registers its fixed element while it
12
+ * is mounted, and a notice asks {@link clearanceAbove} for the height it has to clear —
13
+ * whatever offset, `extended` label or `hidden` state the controls are in. A control
14
+ * that is `hidden` measures 0 × 0 and is skipped, so kastlan's pill, which stays
15
+ * mounted and hidden while there is nothing to report, costs the notice nothing.
16
+ *
17
+ * Internal: not exported from the barrel.
18
+ */
19
+
20
+ const occupants = new Set<HTMLElement>();
21
+ const listeners = new Set<() => void>();
22
+ let observer: ResizeObserver | null = null;
23
+
24
+ function notify() {
25
+ for (const listener of listeners) listener();
26
+ }
27
+
28
+ /** Track `element` until the returned function is called. Size changes — a label that
29
+ * grows, `hidden` toggling — notify the subscribers too. */
30
+ export function registerFloating(element: HTMLElement): () => void {
31
+ occupants.add(element);
32
+ if (observer === null && typeof ResizeObserver !== "undefined") observer = new ResizeObserver(notify);
33
+ observer?.observe(element);
34
+ notify();
35
+ return () => {
36
+ occupants.delete(element);
37
+ observer?.unobserve(element);
38
+ notify();
39
+ };
40
+ }
41
+
42
+ /** Hear every registration, removal and size change. */
43
+ export function subscribeFloating(listener: () => void): () => void {
44
+ listeners.add(listener);
45
+ return () => {
46
+ listeners.delete(listener);
47
+ };
48
+ }
49
+
50
+ /**
51
+ * The distance from the viewport's bottom edge to the top of the highest visible
52
+ * floating control that shares a column with `element` — what `element` has to clear —
53
+ * or `null` when nothing is under it.
54
+ */
55
+ export function clearanceAbove(element: HTMLElement | null): number | null {
56
+ if (!element) return null;
57
+ const own = element.getBoundingClientRect();
58
+ let top = Infinity;
59
+ for (const other of occupants) {
60
+ const box = other.getBoundingClientRect();
61
+ if (box.width === 0 || box.height === 0) continue;
62
+ if (box.right <= own.left || box.left >= own.right) continue;
63
+ top = Math.min(top, box.top);
64
+ }
65
+ return top === Infinity ? null : Math.max(0, window.innerHeight - top);
66
+ }
@@ -0,0 +1,291 @@
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
+ * Nor a DOWNLOAD, for the same reason (kastlan 0.18): axios answers only once the
29
+ * whole body is in, so a generated PDF or a large photo — a GET — ran past both
30
+ * thresholds on a warm server, and held the count for every read beside it. The
31
+ * default filter skips a request whose `responseType` is `"blob"`, `"arraybuffer"` or
32
+ * `"stream"`; a custom filter gets the request as its third argument to do the same.
33
+ * (`wrapFetch` ends the wait at the headers, so a download through it is harmless.)
34
+ *
35
+ * It never arms while the browser is offline, whatever `shouldWatch` says: that state
36
+ * has its own, truthful indicator, and a request that never left the device is not
37
+ * evidence of anything about the server.
38
+ */
39
+
40
+ /** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold
41
+ * start is the only sensible explanation. */
42
+ export type ServerWakeStage = "idle" | "slow" | "waking";
43
+
44
+ /** Decides which requests count. `method` arrives lower-cased (`"get"` when the
45
+ * request named none — axios's and fetch's default); `url` as the client had it;
46
+ * `request` is what the adapter saw — for axios the request config itself, so
47
+ * `responseType` or the app's own fields can decide. */
48
+ export type ServerWakeFilter = (method: string, url: string, request: ServerWakeRequest) => boolean;
49
+
50
+ export interface ServerWakeOptions {
51
+ /** Past this, a warm round trip is out of the question (a cold database query on a
52
+ * free tier can still take a second, so it is not tighter). Default 2000 ms. */
53
+ slowMs?: number;
54
+ /** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */
55
+ wakingMs?: number;
56
+ /** Which requests count. Default {@link watchReadsAnd}`()` — GETs only, downloads
57
+ * excluded. The offline guard runs before it and cannot be overridden. */
58
+ shouldWatch?: ServerWakeFilter;
59
+ }
60
+
61
+ /** What a request is, for {@link ServerWakeWatcher.track} and a {@link ServerWakeFilter}. */
62
+ export interface ServerWakeRequest {
63
+ method?: string;
64
+ url?: string;
65
+ /** axios's `responseType`. `"blob"`, `"arraybuffer"` and `"stream"` mark a download,
66
+ * which the default filter does not count. */
67
+ responseType?: string;
68
+ }
69
+
70
+ export interface ServerWakeWatcher {
71
+ /**
72
+ * Count a request that is going out, and arm the escalation if it is the first.
73
+ * Returns whether it was counted — the caller MUST call {@link end} exactly once
74
+ * for every `true`, or the counter never returns to zero and the notice sticks.
75
+ * (The axios adapter stashes it on the request config for that reason.)
76
+ * `request` reaches the filter as its third argument; default `{ method, url }`.
77
+ */
78
+ start(method?: string, url?: string, request?: ServerWakeRequest): boolean;
79
+ /** A counted request settled — either way: an error still proves the server
80
+ * answered or gave up. When the last one does, the notice goes away. Never takes
81
+ * the counter below zero. */
82
+ end(): void;
83
+ /** `start` + `end` around a promise (or a function returning one), for a request
84
+ * that does not go through an adapter. Resolves and rejects as the work does. */
85
+ track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;
86
+ /** Back to a pristine watchdog — for tests, which share an instance across cases. */
87
+ reset(): void;
88
+ /** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */
89
+ subscribe(listener: () => void): () => void;
90
+ getStage(): ServerWakeStage;
91
+ /** Counted requests currently outstanding. */
92
+ getInFlight(): number;
93
+ }
94
+
95
+ /** Response types that make a request a download — see the module comment. */
96
+ const DOWNLOAD_RESPONSE_TYPES = new Set(["blob", "arraybuffer", "stream"]);
97
+
98
+ /**
99
+ * The usual filter: every GET, plus any other request whose URL matches `writes` —
100
+ * keksdose's `/\/auth\/(login|register|demo-session)\b/` — but never a download
101
+ * (`responseType` `"blob"`, `"arraybuffer"` or `"stream"`). Keep upload routes out of
102
+ * the pattern (see the module comment).
103
+ */
104
+ export function watchReadsAnd(writes?: RegExp): ServerWakeFilter {
105
+ return (method, url, request) =>
106
+ !DOWNLOAD_RESPONSE_TYPES.has(request?.responseType ?? "") &&
107
+ (method === "get" || (writes !== undefined && writes.test(url)));
108
+ }
109
+
110
+ function isOffline(): boolean {
111
+ return typeof navigator !== "undefined" && navigator.onLine === false;
112
+ }
113
+
114
+ export function createServerWake({
115
+ slowMs = 2000,
116
+ wakingMs = 7000,
117
+ shouldWatch = watchReadsAnd(),
118
+ }: ServerWakeOptions = {}): ServerWakeWatcher {
119
+ let inFlight = 0;
120
+ let stage: ServerWakeStage = "idle";
121
+ let slowTimer: ReturnType<typeof setTimeout> | null = null;
122
+ let wakingTimer: ReturnType<typeof setTimeout> | null = null;
123
+ const listeners = new Set<() => void>();
124
+
125
+ const setStage = (next: ServerWakeStage) => {
126
+ if (next === stage) return;
127
+ stage = next;
128
+ for (const listener of listeners) listener();
129
+ };
130
+
131
+ const clearTimers = () => {
132
+ if (slowTimer !== null) clearTimeout(slowTimer);
133
+ if (wakingTimer !== null) clearTimeout(wakingTimer);
134
+ slowTimer = null;
135
+ wakingTimer = null;
136
+ };
137
+
138
+ const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {
139
+ if (isOffline()) return false;
140
+ if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "", request)) return false;
141
+ inFlight += 1;
142
+ // The clock runs from the FIRST outstanding request: a page firing a second read
143
+ // at 1.9 s is still waiting on the same sleeping server, not starting afresh.
144
+ if (inFlight === 1) {
145
+ clearTimers();
146
+ slowTimer = setTimeout(() => setStage("slow"), slowMs);
147
+ wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
148
+ }
149
+ return true;
150
+ };
151
+
152
+ const end = () => {
153
+ inFlight = Math.max(0, inFlight - 1);
154
+ if (inFlight > 0) return;
155
+ clearTimers();
156
+ setStage("idle");
157
+ };
158
+
159
+ return {
160
+ start,
161
+ end,
162
+ track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request: ServerWakeRequest = {}) {
163
+ const counted = start(request.method, request.url, request);
164
+ let promise: Promise<T>;
165
+ try {
166
+ promise = Promise.resolve(typeof work === "function" ? work() : work);
167
+ } catch (error) {
168
+ // A thunk that throws before it returns a promise settled too.
169
+ promise = Promise.reject(error);
170
+ }
171
+ return counted ? promise.finally(end) : promise;
172
+ },
173
+ reset() {
174
+ inFlight = 0;
175
+ clearTimers();
176
+ setStage("idle");
177
+ },
178
+ subscribe(listener) {
179
+ listeners.add(listener);
180
+ return () => {
181
+ listeners.delete(listener);
182
+ };
183
+ },
184
+ getStage: () => stage,
185
+ getInFlight: () => inFlight,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,
191
+ * {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs
192
+ * only, default timings — an app that also watches its login POST creates its own
193
+ * with {@link createServerWake} and passes it to both ends.
194
+ */
195
+ export const serverWake: ServerWakeWatcher = createServerWake();
196
+
197
+ // ── axios ─────────────────────────────────────────────────────────────────────
198
+
199
+ /** The config fields the watchdog reads — what axios's request config has. The whole
200
+ * config reaches the filter, so an app's filter can read the rest of it. */
201
+ export interface ServerWakeAxiosConfig {
202
+ method?: string;
203
+ url?: string;
204
+ responseType?: string;
205
+ }
206
+
207
+ /** The slice of an interceptor manager the adapter uses. A method (not a function
208
+ * property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */
209
+ interface InterceptorManagerLike<V> {
210
+ use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;
211
+ eject(id: number): void;
212
+ }
213
+
214
+ /** An axios instance, structurally — so the kit types the adapter without importing
215
+ * axios. `axios.create()`'s result fits it as is. */
216
+ export interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends { config: C }> {
217
+ interceptors: {
218
+ request: InterceptorManagerLike<C>;
219
+ response: InterceptorManagerLike<R>;
220
+ };
221
+ }
222
+
223
+ /** Whether the watchdog counted this request — the response side owes a matching
224
+ * `end()` only then. On the config, as keksdose's `_wake` was: it travels with the
225
+ * request onto the response and onto an error. Cleared once settled, so a 401 replay
226
+ * of the same config re-enters the request interceptor and is counted afresh — the
227
+ * pairing stays balanced. */
228
+ const COUNTED = "_serverWake";
229
+ type Marked = { [COUNTED]?: boolean };
230
+
231
+ /**
232
+ * Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.
233
+ * Returns a function that ejects both interceptors again.
234
+ *
235
+ * Attach it BEFORE the app's own response interceptors: axios runs those in the order
236
+ * they were added, so the wake one ends the wait before a 401 handler awaits a token
237
+ * refresh — otherwise the notice would keep counting a request that has already been
238
+ * answered. (Request-side order does not matter.)
239
+ */
240
+ export function attachServerWake<C extends ServerWakeAxiosConfig, R extends { config: C }>(
241
+ instance: AxiosLikeInstance<C, R>,
242
+ watcher: ServerWakeWatcher = serverWake,
243
+ ): () => void {
244
+ const settle = (config: unknown) => {
245
+ const marked = config as Marked | undefined;
246
+ if (marked?.[COUNTED]) {
247
+ marked[COUNTED] = false;
248
+ watcher.end();
249
+ }
250
+ };
251
+ const request = instance.interceptors.request.use(
252
+ (config) => {
253
+ (config as C & Marked)[COUNTED] = watcher.start(config.method, config.url, config);
254
+ return config;
255
+ },
256
+ (error) => Promise.reject(error),
257
+ );
258
+ const response = instance.interceptors.response.use(
259
+ (res) => {
260
+ settle(res.config);
261
+ return res;
262
+ },
263
+ (error) => {
264
+ settle((error as { config?: unknown } | null)?.config);
265
+ return Promise.reject(error);
266
+ },
267
+ );
268
+ return () => {
269
+ instance.interceptors.request.eject(request);
270
+ instance.interceptors.response.eject(response);
271
+ };
272
+ }
273
+
274
+ // ── fetch ─────────────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends
278
+ * when the response's HEADERS arrive (or the call fails) — the server is awake by then,
279
+ * however long the body takes.
280
+ */
281
+ export function wrapFetch(
282
+ fetchImpl: typeof fetch = globalThis.fetch,
283
+ watcher: ServerWakeWatcher = serverWake,
284
+ ): typeof fetch {
285
+ return (input, init) => {
286
+ const request = typeof Request !== "undefined" && input instanceof Request ? input : undefined;
287
+ const method = init?.method ?? request?.method;
288
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : (request?.url ?? "");
289
+ return watcher.track(() => fetchImpl(input, init), { method, url });
290
+ };
291
+ }