@eifi1/ui-kit 0.44.0 → 0.45.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 (124) hide show
  1. package/README.md +10 -6
  2. package/dist/admin/admin-action-log-labels.js +9 -0
  3. package/dist/admin/admin-action-log-labels.js.map +1 -1
  4. package/dist/admin/admin-action-log.d.ts +18 -1
  5. package/dist/admin/admin-action-log.js +25 -16
  6. package/dist/admin/admin-action-log.js.map +1 -1
  7. package/dist/admin/admin-area-labels.d.ts +6 -0
  8. package/dist/admin/admin-area-labels.js +1 -0
  9. package/dist/admin/admin-area-labels.js.map +1 -1
  10. package/dist/admin/mail-test-card-labels.d.ts +3 -0
  11. package/dist/admin/mail-test-card-labels.js +17 -0
  12. package/dist/admin/mail-test-card-labels.js.map +1 -0
  13. package/dist/admin/mail-test-card.d.ts +74 -0
  14. package/dist/admin/mail-test-card.js +80 -0
  15. package/dist/admin/mail-test-card.js.map +1 -0
  16. package/dist/components/data-table-mobile.js +17 -2
  17. package/dist/components/data-table-mobile.js.map +1 -1
  18. package/dist/components/data-table-shared.d.ts +2 -0
  19. package/dist/components/data-table-shared.js +8 -0
  20. package/dist/components/data-table-shared.js.map +1 -1
  21. package/dist/components/data-table.js +1 -6
  22. package/dist/components/data-table.js.map +1 -1
  23. package/dist/eslint/index.js +1 -1
  24. package/dist/i18n/defaults.js +9 -1
  25. package/dist/i18n/defaults.js.map +1 -1
  26. package/dist/i18n/german.js +40 -0
  27. package/dist/i18n/german.js.map +1 -1
  28. package/dist/i18n/kit-labels.d.ts +13 -0
  29. package/dist/i18n/kit-labels.js.map +1 -1
  30. package/dist/i18n/locales/en.js +57 -1
  31. package/dist/i18n/locales/en.js.map +3 -3
  32. package/dist/i18n/locales/es.js +40 -0
  33. package/dist/i18n/locales/es.js.map +1 -1
  34. package/dist/i18n/locales/fr.js +40 -0
  35. package/dist/i18n/locales/fr.js.map +1 -1
  36. package/dist/i18n/locales/hu.js +40 -0
  37. package/dist/i18n/locales/hu.js.map +1 -1
  38. package/dist/i18n/locales/it.js +40 -0
  39. package/dist/i18n/locales/it.js.map +1 -1
  40. package/dist/i18n/locales/zh.js +40 -0
  41. package/dist/i18n/locales/zh.js.map +1 -1
  42. package/dist/i18n/review.js +13 -1
  43. package/dist/i18n/review.js.map +1 -1
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +5 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/lib/format.d.ts +2 -1
  48. package/dist/lib/format.js +6 -1
  49. package/dist/lib/format.js.map +1 -1
  50. package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
  51. package/dist/offline/pwa-update-prompt-labels.js +9 -0
  52. package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
  53. package/dist/offline/pwa-update-prompt.d.ts +32 -0
  54. package/dist/offline/pwa-update-prompt.js +36 -0
  55. package/dist/offline/pwa-update-prompt.js.map +1 -0
  56. package/dist/offline/query-persistence.d.ts +100 -0
  57. package/dist/offline/query-persistence.js +179 -0
  58. package/dist/offline/query-persistence.js.map +1 -0
  59. package/dist/offline/shared-computer-labels.d.ts +2 -0
  60. package/dist/offline/shared-computer-labels.js +9 -0
  61. package/dist/offline/shared-computer-labels.js.map +1 -0
  62. package/dist/offline/shared-computer.d.ts +44 -0
  63. package/dist/offline/shared-computer.js +51 -0
  64. package/dist/offline/shared-computer.js.map +1 -0
  65. package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
  66. package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
  67. package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
  68. package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
  69. package/dist/offline/sign-out-pending-dialog.js +66 -0
  70. package/dist/offline/sign-out-pending-dialog.js.map +1 -0
  71. package/dist/offline.d.ts +8 -0
  72. package/dist/offline.js +24 -0
  73. package/dist/offline.js.map +1 -0
  74. package/dist/shell/admin-menu.d.ts +24 -0
  75. package/dist/shell/admin-menu.js +12 -3
  76. package/dist/shell/admin-menu.js.map +1 -1
  77. package/dist/shell.d.ts +2 -0
  78. package/dist/shell.js +2 -0
  79. package/dist/shell.js.map +1 -1
  80. package/dist/tour/tour-catalogue.d.ts +7 -1
  81. package/dist/tour/tour-catalogue.js.map +1 -1
  82. package/dist/tour/tour.js +4 -2
  83. package/dist/tour/tour.js.map +1 -1
  84. package/dist/tour/use-tour-progress.d.ts +99 -1
  85. package/dist/tour/use-tour-progress.js +228 -12
  86. package/dist/tour/use-tour-progress.js.map +1 -1
  87. package/dist/tour.d.ts +2 -0
  88. package/dist/tour.js +2 -0
  89. package/dist/tour.js.map +1 -1
  90. package/package.json +25 -1
  91. package/src/admin/admin-action-log-labels.ts +9 -0
  92. package/src/admin/admin-action-log.tsx +41 -5
  93. package/src/admin/admin-area-labels.ts +7 -0
  94. package/src/admin/mail-test-card-labels.ts +19 -0
  95. package/src/admin/mail-test-card.tsx +186 -0
  96. package/src/components/data-table-mobile.tsx +26 -3
  97. package/src/components/data-table-shared.tsx +18 -1
  98. package/src/components/data-table.tsx +1 -15
  99. package/src/i18n/defaults.ts +8 -0
  100. package/src/i18n/german.ts +46 -0
  101. package/src/i18n/kit-labels.tsx +13 -0
  102. package/src/i18n/locales/en.ts +12 -0
  103. package/src/i18n/locales/es.ts +46 -0
  104. package/src/i18n/locales/fr.ts +46 -0
  105. package/src/i18n/locales/hu.ts +42 -0
  106. package/src/i18n/locales/it.ts +46 -0
  107. package/src/i18n/locales/zh.ts +40 -0
  108. package/src/i18n/review.ts +12 -0
  109. package/src/index.ts +3 -0
  110. package/src/lib/format.ts +13 -2
  111. package/src/offline/pwa-update-prompt-labels.ts +9 -0
  112. package/src/offline/pwa-update-prompt.tsx +72 -0
  113. package/src/offline/query-persistence.ts +323 -0
  114. package/src/offline/shared-computer-labels.ts +8 -0
  115. package/src/offline/shared-computer.tsx +87 -0
  116. package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
  117. package/src/offline/sign-out-pending-dialog.tsx +135 -0
  118. package/src/offline.ts +26 -0
  119. package/src/shell/admin-menu.tsx +40 -3
  120. package/src/shell.ts +4 -0
  121. package/src/tour/tour-catalogue.tsx +7 -1
  122. package/src/tour/tour.tsx +5 -2
  123. package/src/tour/use-tour-progress.ts +378 -12
  124. package/src/tour.ts +3 -0
@@ -72,7 +72,11 @@ export type AdminActionKind =
72
72
  | "group_rename"
73
73
  | "group_delete"
74
74
  | "membership_add"
75
- | "membership_role";
75
+ | "membership_role"
76
+ // 0.45 (admin §9.1.3): a group switched off or on again — kastlan's company PATCH
77
+ // (`is_active`). server-kit 0.15's `AdminAction.GROUP_DEACTIVATE` / `GROUP_REACTIVATE`.
78
+ | "group_deactivate"
79
+ | "group_reactivate";
76
80
 
77
81
  /** Every {@link AdminActionKind}, in the contract's order. */
78
82
  export const ADMIN_ACTION_KINDS: readonly AdminActionKind[] = [
@@ -98,6 +102,8 @@ export const ADMIN_ACTION_KINDS: readonly AdminActionKind[] = [
98
102
  "group_delete",
99
103
  "membership_add",
100
104
  "membership_role",
105
+ "group_deactivate",
106
+ "group_reactivate",
101
107
  ];
102
108
 
103
109
  /** The app's word for its groups (§11.5): Kurvenschmiede's teams, kastlan's companies,
@@ -105,11 +111,14 @@ export const ADMIN_ACTION_KINDS: readonly AdminActionKind[] = [
105
111
  * actions are said in. */
106
112
  export type AdminGroupNoun = "group" | "team" | "company" | "budget";
107
113
 
108
- /** The kinds said in the noun's words: the five of 0.34, and `membership_remove`. */
114
+ /** The kinds said in the noun's words: the five of 0.34, `membership_remove`, and 0.45's
115
+ * `group_deactivate` / `group_reactivate`. */
109
116
  type AdminGroupActionKind =
110
117
  | "group_create"
111
118
  | "group_rename"
112
119
  | "group_delete"
120
+ | "group_deactivate"
121
+ | "group_reactivate"
113
122
  | "membership_add"
114
123
  | "membership_role"
115
124
  | "membership_remove";
@@ -118,6 +127,8 @@ const GROUP_ACTION_KINDS: ReadonlySet<string> = new Set<AdminGroupActionKind>([
118
127
  "group_create",
119
128
  "group_rename",
120
129
  "group_delete",
130
+ "group_deactivate",
131
+ "group_reactivate",
121
132
  "membership_add",
122
133
  "membership_role",
123
134
  "membership_remove",
@@ -184,6 +195,13 @@ export interface AdminActionLogEntry {
184
195
  group?: AdminGroupTarget | null;
185
196
  /** The `detail` in the app's words — "Member → Admin", "12 items to Ben Example". */
186
197
  detail?: ReactNode;
198
+ /**
199
+ * 0.45 (admin §9.4, Marcel 2026-10-19): a `group_rename` row's `detail.{from, to}` —
200
+ * kastlan's company rename logs both names. With both, the row reads "Company renamed
201
+ * from A to B" (`renamedFromTo`) in place of the target. A rename that logs `{}` (KS's
202
+ * team rename: never a name) leaves it out and reads "Team renamed" and the group.
203
+ */
204
+ renamed?: { from?: string | null; to?: string | null } | null;
187
205
  }
188
206
 
189
207
  /** The `adminActionLog` namespace. */
@@ -206,6 +224,8 @@ export interface AdminActionLogLabels {
206
224
  showAll: string;
207
225
  /** A target name as a filter button — "Show only the actions on Ada Example". */
208
226
  filterBy: (target: string) => string;
227
+ /** 0.45: after "Company renamed" when the row carries both names — "from A to B". */
228
+ renamedFromTo: (from: string, to: string) => string;
209
229
  /** What each action is called, past tense, keyed by the vocabulary. An app adds its
210
230
  * own here (`labels={{ actions: { plan: "Plan changed" } }}`). A word given here
211
231
  * outranks the noun's in `groupActions` — so no catalogue holds the group kinds
@@ -223,6 +243,11 @@ interface AdminGroupActionLabels {
223
243
  group_rename: string;
224
244
  /** "Team deleted". */
225
245
  group_delete: string;
246
+ /** "Company deactivated" (0.45, admin §9.1.3): the group switched off — its people
247
+ * locked out — not a person's `deactivate`. */
248
+ group_deactivate: string;
249
+ /** "Company reactivated" (0.45). */
250
+ group_reactivate: string;
226
251
  /** "Added to the team" — said of the person, so it agrees with the account where a
227
252
  * language agrees (es "Añadida", fr "Ajouté"). */
228
253
  membership_add: string;
@@ -384,12 +409,22 @@ export function AdminActionLog({
384
409
  aria-busy={loading || undefined}
385
410
  className={cn("divide-y divide-subtle", loading && "opacity-60")}
386
411
  >
387
- {entries.map((entry) => (
412
+ {entries.map((entry) => {
413
+ const from = (entry.renamed?.from ?? "").trim();
414
+ const to = (entry.renamed?.to ?? "").trim();
415
+ const renamed = entry.action === "group_rename" && from !== "" && to !== "";
416
+ return (
388
417
  <li key={entry.id} data-action={entry.action} className="space-y-0.5 py-2 first:pt-0 last:pb-0">
389
418
  <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
390
419
  <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
391
420
  <span className="font-medium text-primary">{actionText(entry.action)}</span>
392
- {entry.target !== undefined && <span className="min-w-0 break-words">{targetNode(entry)}</span>}
421
+ {renamed ? (
422
+ <span data-private data-renamed className="min-w-0 break-words text-primary">
423
+ {labels.renamedFromTo(from, to)}
424
+ </span>
425
+ ) : (
426
+ entry.target !== undefined && <span className="min-w-0 break-words">{targetNode(entry)}</span>
427
+ )}
393
428
  {entry.group && (
394
429
  <span data-group className="min-w-0 break-words text-xs text-muted">
395
430
  {groupText(entry.group)}
@@ -410,7 +445,8 @@ export function AdminActionLog({
410
445
  )}
411
446
  </div>
412
447
  </li>
413
- ))}
448
+ );
449
+ })}
414
450
  </ol>
415
451
  )}
416
452
  {paging && paging.total > 0 && (
@@ -32,6 +32,12 @@ export interface AdminAreaLabels {
32
32
  /** The row's waiting count, for a reader ("Administration, 3 waiting"); the eye sees
33
33
  * the number in the badge. */
34
34
  waiting: (count: number) => string;
35
+ /**
36
+ * 0.45 (admin §9.3.4): a button's whole name with its count — "Platform, 2 waiting" —
37
+ * for an `IconButton` whose `badge` is an `AdminWaitingBadge`: the badge is decorative
38
+ * there, so the name carries the count. `label` is the button's own name.
39
+ */
40
+ waitingLabel: (label: string, count: number) => string;
35
41
  /** The 403 page's heading (§2.2 decision 8). */
36
42
  forbiddenTitle: string;
37
43
  /**
@@ -59,6 +65,7 @@ export interface AdminAreaLabels {
59
65
  export const DEFAULT_ADMIN_AREA_LABELS: AdminAreaLabels = {
60
66
  title: "Administration",
61
67
  waiting: (count) => `${count} waiting`,
68
+ waitingLabel: (label, count) => `${label}, ${count} waiting`,
62
69
  forbiddenTitle: "Only for administrators",
63
70
  forbiddenBody: (appName) => `This is where administrators run ${appName}. Your account cannot open this page.`,
64
71
  forbiddenHome: "Go to the start page",
@@ -0,0 +1,19 @@
1
+ // The English words of ./mail-test-card, in a module with no component code: the English
2
+ // catalogue (src/i18n/defaults.ts) imports them without the component, so `en` loads as one
3
+ // chunk like the other languages (0.34, KS U8). ./mail-test-card re-exports them.
4
+
5
+ import { englishRateLimited } from "../auth/auth-errors";
6
+ import type { MailTestCardLabels } from "./mail-test-card";
7
+
8
+ /** docs/admin-harmonization.md §9.2.4, word for word. */
9
+ export const DEFAULT_MAIL_TEST_CARD_LABELS: MailTestCardLabels = {
10
+ title: "Test mail",
11
+ description: "Sends one mail to your own address, through the transport this installation sends all its mail with.",
12
+ transport: (backend) => `Transport: ${backend}`,
13
+ action: "Send a test mail to me",
14
+ sent: (address) => `Sent to ${address}. Check the inbox.`,
15
+ console: "The console transport only logs mail; nothing was sent.",
16
+ failed: "Could not send. The server log has the provider’s answer.",
17
+ rateLimited: englishRateLimited,
18
+ keywords: "test mail email e-mail transport smtp resend console send deliver",
19
+ };
@@ -0,0 +1,186 @@
1
+ import { useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { Send } from "lucide-react";
4
+
5
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
6
+ import { isRateLimited, retryAfterSeconds } from "../auth/auth-errors";
7
+ import { AlertBanner } from "../components/alert-banner";
8
+ import { Button } from "../components/button";
9
+ import { Chip } from "../components/chip";
10
+ import { SettingsSection, type SettingsSectionProps } from "../settings/settings-section";
11
+ import { DEFAULT_MAIL_TEST_CARD_LABELS } from "./mail-test-card-labels";
12
+
13
+ /* ── Labels ──────────────────────────────────────────────────────────────── */
14
+
15
+ /** Every string {@link MailTestCard} renders — the `mailTestCard` namespace of
16
+ * `<UiKitProvider labels>`, overridable per instance through `labels`
17
+ * (docs/admin-harmonization.md §9.2.4). */
18
+ export interface MailTestCardLabels {
19
+ /** The card's title, and the app's catalogue entry's. */
20
+ title: string;
21
+ /** The sentence under it. */
22
+ description: string;
23
+ /** The transport's chip: "Transport: resend". `backend` is the server's `mail_backend`. */
24
+ transport: (backend: string) => string;
25
+ /** The button. */
26
+ action: string;
27
+ /** The answer said "sent" through a real transport. `address` is masked ("a…@example.com"). */
28
+ sent: (address: string) => string;
29
+ /** `mail_backend` is `console`: said whatever `sent` is — a console answers `true`. */
30
+ console: string;
31
+ /** Not sent, or the request failed. The provider's error stays in the server log. */
32
+ failed: string;
33
+ /** HTTP 429 — the app's limiter (server-kit's `MAIL_TEST_LIMIT`, 3 per 10 minutes);
34
+ * `seconds` from `Retry-After` when the response has one. */
35
+ rateLimited: (seconds?: number) => string;
36
+ /** For the app's catalogue entry (`keywords`); the card never reads it. */
37
+ keywords: string;
38
+ }
39
+
40
+ // Defined in ./mail-test-card-labels, which holds no component code.
41
+ export { DEFAULT_MAIL_TEST_CARD_LABELS };
42
+
43
+ /* ── The card ────────────────────────────────────────────────────────────── */
44
+
45
+ /** The answer of `POST /admin/mail/test` — server-kit 0.15's `MailTestResult`. */
46
+ export interface MailTestAnswer {
47
+ sent: boolean;
48
+ /** The transport that took it: `console`, `resend`, `smtp`, … */
49
+ mail_backend: string;
50
+ }
51
+
52
+ /** The card's default DOM id: its catalogue entry's `anchor`, the `?focus=` target. */
53
+ export const MAIL_TEST_ANCHOR = "admin-mail-test";
54
+
55
+ export interface MailTestCardProps
56
+ extends Omit<SettingsSectionProps, "anchor" | "title" | "description" | "children"> {
57
+ /** The installation's transport, as the admin page has it (`mail_backend`): the chip
58
+ * before a send. After one, the answer's. */
59
+ mailBackend: string;
60
+ /** The app's `POST /admin/mail/test` (kastlan `/platform/mail/test`), no body: it
61
+ * awaits the app's own send and answers `{sent, mail_backend}`. A rejection is read
62
+ * for a 429 (the limiter); anything else is "could not send". */
63
+ send: () => Promise<MailTestAnswer>;
64
+ /** The signed-in admin's own address — where the mail goes. Shown masked. */
65
+ address: string;
66
+ /** Default {@link MAIL_TEST_ANCHOR}; the app's catalogue entry names the same. */
67
+ anchor?: string;
68
+ /** The app's sentence after the kit's description. */
69
+ description?: ReactNode;
70
+ labels?: LabelOverride<MailTestCardLabels>;
71
+ }
72
+
73
+ /**
74
+ * An address with its local part masked to its first character: "ada@example.com" →
75
+ * "a…@example.com". The domain stays, so the admin sees which mailbox to open.
76
+ */
77
+ export function maskMailAddress(address: string): string {
78
+ const text = address.trim();
79
+ const at = text.lastIndexOf("@");
80
+ if (at <= 0) return text === "" ? "" : `${Array.from(text)[0]}…`;
81
+ return `${Array.from(text.slice(0, at))[0]}…${text.slice(at)}`;
82
+ }
83
+
84
+ type Outcome =
85
+ | { kind: "sent"; backend: string }
86
+ | { kind: "console"; backend: string }
87
+ | { kind: "failed"; backend?: string }
88
+ | { kind: "limited"; seconds?: number };
89
+
90
+ /**
91
+ * "Send a test mail to me" (docs/admin-harmonization.md §9.2): a `SettingsSection` with a
92
+ * sentence, the transport as a chip, the button and the result line. One mail goes to the
93
+ * signed-in admin's own address, never another, through the app's own send.
94
+ *
95
+ * **The result line reads `mail_backend` first**: with `console` it says "nothing was sent"
96
+ * whatever `sent` is, since every console path answers `true` (§9.2 Today). Otherwise
97
+ * `sent` says "Sent to a…@example.com. Check the inbox." or "Could not send." — the
98
+ * provider's error stays in the server log (§9.2.7). A 429 says the limiter's line. The
99
+ * button is busy while the request runs; the line is a live region.
100
+ *
101
+ * Not a commit: the test writes nothing, so it is not under the write lock (a demo never
102
+ * reaches the admin area, and the server refuses it anyway). The app adds the catalogue
103
+ * entry `{ id, anchor: MAIL_TEST_ANCHOR, title, keywords }` from these labels.
104
+ */
105
+ export function MailTestCard({
106
+ mailBackend,
107
+ send,
108
+ address,
109
+ anchor = MAIL_TEST_ANCHOR,
110
+ description,
111
+ labels: labelsProp,
112
+ ...rest
113
+ }: MailTestCardProps) {
114
+ const labels = useKitLabels("mailTestCard", DEFAULT_MAIL_TEST_CARD_LABELS, labelsProp);
115
+ const [busy, setBusy] = useState(false);
116
+ const [outcome, setOutcome] = useState<Outcome | null>(null);
117
+
118
+ const run = async () => {
119
+ if (busy) return;
120
+ setBusy(true);
121
+ setOutcome(null);
122
+ try {
123
+ const answer = await send();
124
+ const backend = answer.mail_backend;
125
+ if (backend === "console") setOutcome({ kind: "console", backend });
126
+ else setOutcome(answer.sent ? { kind: "sent", backend } : { kind: "failed", backend });
127
+ } catch (error) {
128
+ setOutcome(isRateLimited(error) ? { kind: "limited", seconds: retryAfterSeconds(error) } : { kind: "failed" });
129
+ } finally {
130
+ setBusy(false);
131
+ }
132
+ };
133
+
134
+ const backend = outcome && "backend" in outcome && outcome.backend ? outcome.backend : mailBackend;
135
+
136
+ return (
137
+ <SettingsSection
138
+ {...rest}
139
+ anchor={anchor}
140
+ title={labels.title}
141
+ description={
142
+ description != null ? (
143
+ <>
144
+ {labels.description} {description}
145
+ </>
146
+ ) : (
147
+ labels.description
148
+ )
149
+ }
150
+ >
151
+ <div className="space-y-2">
152
+ <div className="flex flex-wrap items-center gap-2">
153
+ <Button variant="secondary" pending={busy} onClick={() => void run()}>
154
+ <Send className="size-4" aria-hidden />
155
+ {labels.action}
156
+ </Button>
157
+ {backend && (
158
+ <Chip size="sm" data-slot="mail-backend">
159
+ {labels.transport(backend)}
160
+ </Chip>
161
+ )}
162
+ </div>
163
+ {outcome?.kind === "sent" && (
164
+ <AlertBanner variant="inline" block tone="success">
165
+ <span data-private>{labels.sent(maskMailAddress(address))}</span>
166
+ </AlertBanner>
167
+ )}
168
+ {outcome?.kind === "console" && (
169
+ <AlertBanner variant="inline" block tone="info">
170
+ {labels.console}
171
+ </AlertBanner>
172
+ )}
173
+ {outcome?.kind === "failed" && (
174
+ <AlertBanner variant="inline" block tone="danger">
175
+ {labels.failed}
176
+ </AlertBanner>
177
+ )}
178
+ {outcome?.kind === "limited" && (
179
+ <AlertBanner variant="inline" block tone="warning">
180
+ {labels.rateLimited(outcome.seconds)}
181
+ </AlertBanner>
182
+ )}
183
+ </div>
184
+ </SettingsSection>
185
+ );
186
+ }
@@ -1,5 +1,5 @@
1
1
  import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
2
- import type { ReactNode } from "react";
2
+ import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
3
3
  import { ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, ChevronRight, Filter } from "lucide-react";
4
4
  import { FOCUS_RING } from "./focus-ring";
5
5
  import { IconButton } from "./icon-button";
@@ -15,7 +15,7 @@ import { DialogFrame } from "./dialog-frame";
15
15
  import { Checkbox } from "./checkbox";
16
16
  import { useLongPress } from "../hooks/use-long-press";
17
17
  import type { DataTableLabels } from "./data-table-labels";
18
- import { RowLink, cleanAttrs, columnLabel, fromOutside } from "./data-table-shared";
18
+ import { RowLink, cleanAttrs, columnLabel, fromOutside, fromOwnControl } from "./data-table-shared";
19
19
  import type {
20
20
  DataTableCellProps,
21
21
  DataTableColumn,
@@ -383,7 +383,25 @@ export function MobileRow<T>({
383
383
  // Attached whenever the table selects, the hook's `enabled` deciding whether a press
384
384
  // arms: its pointerdown also clears a stale swallow latch, which must happen in the
385
385
  // mode too, or a long press whose click never came would eat the first toggle.
386
- const press = select ? longPress.handlers : undefined;
386
+ //
387
+ // A press that starts on a control in a cell (or in a portal one opened) is that
388
+ // control's, not a way into the mode (keksdose live #428): it only clears the latch.
389
+ const press = select
390
+ ? {
391
+ ...longPress.handlers,
392
+ onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
393
+ if (fromOutside(e.target, e.currentTarget) || fromOwnControl(e.target, e.currentTarget)) {
394
+ longPress.consumeClick();
395
+ return;
396
+ }
397
+ longPress.handlers.onPointerDown(e);
398
+ },
399
+ onContextMenu: (e: ReactMouseEvent<HTMLElement>) => {
400
+ if (fromOutside(e.target, e.currentTarget) || fromOwnControl(e.target, e.currentTarget)) return;
401
+ longPress.handlers.onContextMenu(e);
402
+ },
403
+ }
404
+ : undefined;
387
405
  const body = selecting ? (
388
406
  // In the mode a card is a toggle — kk's register row: `role="button"` with
389
407
  // `aria-pressed`, the box beside it only a picture of that state (the card is the
@@ -399,6 +417,8 @@ export function MobileRow<T>({
399
417
  {...press}
400
418
  onClick={(e) => {
401
419
  if (!select!.selectable || fromOutside(e.target, e.currentTarget)) return;
420
+ // A tap on a control in a cell is that control's (keksdose live #428).
421
+ if (fromOwnControl(e.target, e.currentTarget)) return;
402
422
  select!.onToggle(!select!.selected);
403
423
  }}
404
424
  onKeyDown={(e) => {
@@ -455,6 +475,9 @@ export function MobileRow<T>({
455
475
  interactive
456
476
  ? (e) => {
457
477
  if (fromOutside(e.target, e.currentTarget)) return;
478
+ // A tap on a control in a cell (a select, a button) is that
479
+ // control's, as on the desktop `<tr>` (keksdose live #428).
480
+ if (fromOwnControl(e.target, e.currentTarget)) return;
458
481
  onRowClick!(row);
459
482
  }
460
483
  : undefined
@@ -147,6 +147,21 @@ export function fromOutside(target: EventTarget | null, row: HTMLElement): boole
147
147
  return !(target instanceof Node) || !row.contains(target);
148
148
  }
149
149
 
150
+ /**
151
+ * What counts as a control of its own inside a clickable row: a click or key press
152
+ * that lands on one of these belongs to it, not to the row's `onRowClick`. The row's
153
+ * own link (`rowHref`, marked `data-row-link`) is the exception — it IS the row.
154
+ */
155
+ const OWN_CONTROL =
156
+ 'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="option"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
157
+
158
+ /** Whether an event on `row` started on a control nested inside it. */
159
+ export function fromOwnControl(target: EventTarget | null, row: HTMLElement): boolean {
160
+ if (!(target instanceof Element)) return false;
161
+ const hit = target.closest(OWN_CONTROL);
162
+ return !!hit && hit !== row && row.contains(hit) && !hit.hasAttribute("data-row-link");
163
+ }
164
+
150
165
  /** A click the APP owns. Anything else — middle, ⌘/Ctrl, Shift, Alt — belongs to
151
166
  * the browser, and the whole feature is not touching it. */
152
167
  const isPlainLeftClick = (e: React.MouseEvent) =>
@@ -215,7 +230,9 @@ export function RowLink({
215
230
  }
216
231
  // Already cancelled above; this phase only decides what opens. A nested
217
232
  // control that stopped propagation deliberately never reaches it, which is
218
- // exactly how the row behaved before it became a link.
233
+ // exactly how the row behaved before it became a link — and one that did not
234
+ // (a select in a phone card's cell, keksdose live #428) is still its own.
235
+ if (fromOwnControl(e.target, e.currentTarget)) return;
219
236
  onActivate?.();
220
237
  }}
221
238
  >
@@ -10,6 +10,7 @@ import {
10
10
  cleanAttrs,
11
11
  columnLabel,
12
12
  fromOutside,
13
+ fromOwnControl,
13
14
  logicalAlign,
14
15
  resolveDataTableLabels,
15
16
  splitCellProps,
@@ -702,21 +703,6 @@ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
702
703
 
703
704
  const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
704
705
 
705
- /**
706
- * What counts as a control of its own inside a clickable row: a click or key press
707
- * that lands on one of these belongs to it, not to the row's `onRowClick`. The row's
708
- * own link (`rowHref`, marked `data-row-link`) is the exception — it IS the row.
709
- */
710
- const OWN_CONTROL =
711
- 'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="option"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
712
-
713
- /** Whether an event on `row` started on a control nested inside it. */
714
- function fromOwnControl(target: EventTarget | null, row: HTMLElement): boolean {
715
- if (!(target instanceof Element)) return false;
716
- const hit = target.closest(OWN_CONTROL);
717
- return !!hit && hit !== row && row.contains(hit) && !hit.hasAttribute("data-row-link");
718
- }
719
-
720
706
  /** Arrow/Home/End between the focusable rows of one `<tbody>` — the roving half of
721
707
  * the keyboard-activatable rows (see the row's `onKeyDown`). */
722
708
  function moveRowFocus(from: HTMLElement, key: string): boolean {
@@ -100,6 +100,10 @@ import { DEFAULT_ROW_ACTIONS_LABELS } from "../components/row-actions-labels";
100
100
  import { DEFAULT_APP_SHELL_MORE_LABELS } from "../shell/app-shell-labels";
101
101
  import { DEFAULT_ACCOUNT_STATE_LABELS } from "../components/account-chips-labels";
102
102
  import { DEFAULT_ADMIN_AREA_LABELS } from "../admin/admin-area-labels";
103
+ import { DEFAULT_MAIL_TEST_CARD_LABELS } from "../admin/mail-test-card-labels";
104
+ import { DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "../offline/sign-out-pending-dialog-labels";
105
+ import { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "../offline/pwa-update-prompt-labels";
106
+ import { DEFAULT_SHARED_COMPUTER_LABELS } from "../offline/shared-computer-labels";
103
107
  import { DEFAULT_TOP_BAR_PARTS_LABELS } from "../shell/top-bar-parts-labels";
104
108
  import { DEFAULT_SHARE_CARD_LABELS } from "../components/share-card-labels";
105
109
  import { DEFAULT_REAUTH_DIALOG_LABELS } from "../components/reauth-dialog-labels";
@@ -262,4 +266,8 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
262
266
  notifications: DEFAULT_NOTIFICATIONS_LABELS,
263
267
  adminArea: DEFAULT_ADMIN_AREA_LABELS,
264
268
  topBarParts: DEFAULT_TOP_BAR_PARTS_LABELS,
269
+ mailTestCard: DEFAULT_MAIL_TEST_CARD_LABELS,
270
+ signOutPending: DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS,
271
+ pwaUpdate: DEFAULT_PWA_UPDATE_PROMPT_LABELS,
272
+ sharedComputer: DEFAULT_SHARED_COMPUTER_LABELS,
265
273
  };
@@ -1409,11 +1409,14 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1409
1409
  // 0.34 (§11.5): whole phrases per noun. `team` in Kurvenschmiede's own words ("Team
1410
1410
  // angelegt", "aufnehmen"), `budget` in keksdose's, `company` with the string
1411
1411
  // `actions.membership_remove` held until 0.34.
1412
+ renamedFromTo: (from, to) => `von ${from} in ${to}`,
1412
1413
  groupActions: {
1413
1414
  group: {
1414
1415
  group_create: "Gruppe erstellt",
1415
1416
  group_rename: "Gruppe umbenannt",
1416
1417
  group_delete: "Gruppe gelöscht",
1418
+ group_deactivate: "Gruppe deaktiviert",
1419
+ group_reactivate: "Gruppe reaktiviert",
1417
1420
  membership_add: "Zur Gruppe hinzugefügt",
1418
1421
  membership_role: "Rolle in der Gruppe geändert",
1419
1422
  membership_remove: "Aus der Gruppe entfernt",
@@ -1423,6 +1426,8 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1423
1426
  group_create: "Team angelegt",
1424
1427
  group_rename: "Team umbenannt",
1425
1428
  group_delete: "Team gelöscht",
1429
+ group_deactivate: "Team deaktiviert",
1430
+ group_reactivate: "Team reaktiviert",
1426
1431
  membership_add: "Ins Team aufgenommen",
1427
1432
  membership_role: "Rolle im Team geändert",
1428
1433
  membership_remove: "Aus dem Team entfernt",
@@ -1432,6 +1437,8 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1432
1437
  group_create: "Unternehmen erstellt",
1433
1438
  group_rename: "Unternehmen umbenannt",
1434
1439
  group_delete: "Unternehmen gelöscht",
1440
+ group_deactivate: "Unternehmen deaktiviert",
1441
+ group_reactivate: "Unternehmen reaktiviert",
1435
1442
  membership_add: "Zum Unternehmen hinzugefügt",
1436
1443
  membership_role: "Rolle im Unternehmen geändert",
1437
1444
  membership_remove: "Aus dem Unternehmen entfernt",
@@ -1441,6 +1448,8 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1441
1448
  group_create: "Budget erstellt",
1442
1449
  group_rename: "Budget umbenannt",
1443
1450
  group_delete: "Budget gelöscht",
1451
+ group_deactivate: "Budget deaktiviert",
1452
+ group_reactivate: "Budget reaktiviert",
1444
1453
  membership_add: "Zum Budget hinzugefügt",
1445
1454
  membership_role: "Rolle im Budget geändert",
1446
1455
  membership_remove: "Aus dem Budget entfernt",
@@ -1892,9 +1901,21 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1892
1901
  // 0.42.0 — docs/admin-harmonization.md §3.1: the admin area's shared words. "Verwaltung"
1893
1902
  // as settings §5 names the page; "mit Administratorrechten" rather than "für
1894
1903
  // Administratoren", which would give the reader a gender (as `transferOwnership`).
1904
+ mailTestCard: {
1905
+ title: "Testmail",
1906
+ description: "Sendet eine Mail an Ihre eigene Adresse, über den Versand, mit dem diese Installation alle Mails verschickt.",
1907
+ transport: (backend) => `Versand: ${backend}`,
1908
+ action: "Testmail an mich senden",
1909
+ sent: (address) => `An ${address} gesendet. Bitte prüfen Sie den Posteingang.`,
1910
+ console: "Der Konsolen-Versand schreibt Mails nur ins Log; es wurde nichts gesendet.",
1911
+ failed: "Senden fehlgeschlagen. Die Antwort des Anbieters steht im Server-Log.",
1912
+ rateLimited,
1913
+ keywords: "testmail test e-mail mail versand transport smtp resend konsole senden zustellen",
1914
+ },
1895
1915
  adminArea: {
1896
1916
  title: "Verwaltung",
1897
1917
  waiting: (count) => `${n(count)} offen`,
1918
+ waitingLabel: (label, count) => `${label}, ${n(count)} offen`,
1898
1919
  forbiddenTitle: "Nur mit Administratorrechten",
1899
1920
  forbiddenBody: (appName) => `Hier wird ${appName} verwaltet. Ihr Konto kann diese Seite nicht öffnen.`,
1900
1921
  forbiddenHome: "Zur Startseite",
@@ -1966,5 +1987,30 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1966
1987
  billingOff: "Die Abrechnung ist lokal aus: Tarife ändern nichts",
1967
1988
  },
1968
1989
  },
1990
+ // 0.45.0 — docs/local-first-harmonization.md §1.6 (`@eifi1/ui-kit/offline`): keksdose's
1991
+ // reviewed words (nav.logout_queued_*, pwa.*) where it has the language.
1992
+ signOutPending: {
1993
+ title: "Einige Änderungen sind noch nicht auf dem Server",
1994
+ lost: (count) =>
1995
+ count === 1
1996
+ ? `${n(count)} Änderung wartet noch auf die Synchronisierung. Beim Abmelden wird die Kopie auf diesem Gerät gelöscht – und das ist die einzige.`
1997
+ : `${n(count)} Änderungen warten noch auf die Synchronisierung. Beim Abmelden wird die Kopie auf diesem Gerät gelöscht – und das ist die einzige.`,
1998
+ kept: (count) =>
1999
+ count === 1
2000
+ ? `${n(count)} Änderung wartet noch auf die Synchronisierung. Sie bleibt für Sie auf diesem Gerät und wird bei Ihrer nächsten Anmeldung hier synchronisiert.`
2001
+ : `${n(count)} Änderungen warten noch auf die Synchronisierung. Sie bleiben für Sie auf diesem Gerät und werden bei Ihrer nächsten Anmeldung hier synchronisiert.`,
2002
+ syncNow: "Jetzt synchronisieren",
2003
+ signOutAnyway: "Trotzdem abmelden",
2004
+ discardWaiting: "Wartende Änderungen verwerfen",
2005
+ cancel: "Abbrechen",
2006
+ },
2007
+ pwaUpdate: {
2008
+ updateAvailable: "Eine neue Version ist verfügbar.",
2009
+ reload: "Neu laden",
2010
+ },
2011
+ sharedComputer: {
2012
+ label: "Dies ist ein gemeinsam genutzter Computer",
2013
+ hint: "Auf diesem Gerät bleibt keine Kopie Ihrer Daten. Seiten öffnen sich dann nur mit einer Verbindung.",
2014
+ },
1969
2015
  };
1970
2016
  }
@@ -120,7 +120,11 @@ import type { SignChipLabels } from "../components/sign-chip";
120
120
  import type { ColumnMapperLabels } from "../components/column-mapper";
121
121
  import type { NotificationsLabels } from "../notifications/notifications-labels";
122
122
  import type { AdminAreaLabels } from "../admin/admin-area-labels";
123
+ import type { MailTestCardLabels } from "../admin/mail-test-card";
123
124
  import type { TopBarPartsLabels } from "../shell/top-bar-parts-labels";
125
+ import type { SignOutPendingDialogLabels } from "../offline/sign-out-pending-dialog";
126
+ import type { PwaUpdatePromptLabels } from "../offline/pwa-update-prompt";
127
+ import type { SharedComputerLabels } from "../offline/shared-computer";
124
128
 
125
129
  /**
126
130
  * EVERY string the kit renders, as one typed tree — and an optional provider that
@@ -487,6 +491,15 @@ export interface UiKitLabels {
487
491
  /** 0.42.0: the top bar's shared parts — `TourMenu`, `SyncStatusIndicator`, `DevSwitchers`
488
492
  * (docs/top-bar-harmonization.md §3.1). */
489
493
  topBarParts: TopBarPartsLabels;
494
+ /** 0.45.0: "Send a test mail to me" (docs/admin-harmonization.md §9.2.4). */
495
+ mailTestCard: MailTestCardLabels;
496
+ /** 0.45.0: the sign-out question while changes wait to sync (`@eifi1/ui-kit/offline`,
497
+ * docs/local-first-harmonization.md §1.6). */
498
+ signOutPending: SignOutPendingDialogLabels;
499
+ /** 0.45.0: the service-worker update toast (local-first §1.5 D8). */
500
+ pwaUpdate: PwaUpdatePromptLabels;
501
+ /** 0.45.0: "This is a shared computer" on the sign-in form (local-first §1.5 D6). */
502
+ sharedComputer: SharedComputerLabels;
490
503
  }
491
504
 
492
505
  /**
@@ -220,6 +220,7 @@ export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
220
220
  adminArea: {
221
221
  ...d.adminArea,
222
222
  waiting: (count) => `${n(count)} waiting`,
223
+ waitingLabel: (label, count) => `${label}, ${n(count)} waiting`,
223
224
  tiles: { ...d.adminArea.tiles, usersNew: (count) => `${n(count)} new in 7 days` },
224
225
  },
225
226
  // 0.43.0: the tours page's counts (docs/tours-harmonization.md §3).
@@ -266,5 +267,16 @@ export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
266
267
  waitingChanges: (count) =>
267
268
  count === 1 ? "1 change waiting for a plan" : `${n(count)} changes waiting for a plan`,
268
269
  },
270
+ signOutPending: {
271
+ ...d.signOutPending,
272
+ lost: (count) =>
273
+ count === 1
274
+ ? d.signOutPending.lost(1)
275
+ : `${n(count)} changes are still waiting to sync. Logging out clears this device’s copy, and it is the only copy.`,
276
+ kept: (count) =>
277
+ count === 1
278
+ ? d.signOutPending.kept(1)
279
+ : `${n(count)} changes are still waiting to sync. They stay on this device for you and sync the next time you log in here.`,
280
+ },
269
281
  };
270
282
  }