@eifi1/ui-kit 0.30.0 → 0.31.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 (254) hide show
  1. package/README.md +42 -5
  2. package/dist/account/data-export-setting.js +3 -2
  3. package/dist/account/data-export-setting.js.map +1 -1
  4. package/dist/account/delete-account-setting.d.ts +5 -1
  5. package/dist/account/delete-account-setting.js +3 -2
  6. package/dist/account/delete-account-setting.js.map +1 -1
  7. package/dist/account/email-change-setting.js +4 -3
  8. package/dist/account/email-change-setting.js.map +1 -1
  9. package/dist/account/sessions-setting.js +3 -2
  10. package/dist/account/sessions-setting.js.map +1 -1
  11. package/dist/admin/admin-action-confirm.d.ts +5 -1
  12. package/dist/admin/invitations-panel.d.ts +5 -1
  13. package/dist/admin/invitations-panel.js +3 -2
  14. package/dist/admin/invitations-panel.js.map +1 -1
  15. package/dist/admin/reviewer-scope-editor.d.ts +5 -1
  16. package/dist/admin/role-select.d.ts +5 -1
  17. package/dist/admin/transfer-ownership-dialog.d.ts +5 -1
  18. package/dist/admin/user-roster.d.ts +5 -1
  19. package/dist/auth/auth-errors.d.ts +24 -6
  20. package/dist/auth/auth-errors.js +7 -1
  21. package/dist/auth/auth-errors.js.map +1 -1
  22. package/dist/auth/register-form.d.ts +5 -1
  23. package/dist/auth/sign-in-form.d.ts +15 -1
  24. package/dist/auth/sign-in-form.js +12 -3
  25. package/dist/auth/sign-in-form.js.map +1 -1
  26. package/dist/chart.d.ts +5 -1
  27. package/dist/components/account-settings-labels.d.ts +12 -0
  28. package/dist/components/account-settings-labels.js +2 -1
  29. package/dist/components/account-settings-labels.js.map +1 -1
  30. package/dist/components/account-settings.d.ts +8 -2
  31. package/dist/components/account-settings.js +19 -11
  32. package/dist/components/account-settings.js.map +1 -1
  33. package/dist/components/amount-input.d.ts +5 -1
  34. package/dist/components/button-group.d.ts +5 -1
  35. package/dist/components/calculator.d.ts +5 -1
  36. package/dist/components/confirm-dialog.d.ts +5 -1
  37. package/dist/components/copy-button.d.ts +5 -1
  38. package/dist/components/danger-confirm.d.ts +5 -1
  39. package/dist/components/data-table.js +8 -1
  40. package/dist/components/data-table.js.map +1 -1
  41. package/dist/components/date-picker.js +9 -4
  42. package/dist/components/date-picker.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +5 -1
  44. package/dist/components/file-button.d.ts +5 -1
  45. package/dist/components/file-dropzone.d.ts +5 -1
  46. package/dist/components/form-actions.d.ts +5 -1
  47. package/dist/components/iban-input.d.ts +5 -1
  48. package/dist/components/language-select.d.ts +5 -1
  49. package/dist/components/legal-page.d.ts +5 -1
  50. package/dist/components/legal.d.ts +5 -1
  51. package/dist/components/money-field.d.ts +5 -1
  52. package/dist/components/number-field.d.ts +5 -1
  53. package/dist/components/number-input.d.ts +5 -1
  54. package/dist/components/numpad-sheet.d.ts +5 -1
  55. package/dist/components/passkeys-setting.js +2 -1
  56. package/dist/components/passkeys-setting.js.map +1 -1
  57. package/dist/components/phone-input.d.ts +5 -1
  58. package/dist/components/series-chart.d.ts +5 -1
  59. package/dist/components/settings-fields.d.ts +40 -3
  60. package/dist/components/settings-fields.js +20 -1
  61. package/dist/components/settings-fields.js.map +1 -1
  62. package/dist/components/share-card.d.ts +5 -1
  63. package/dist/components/text-link.d.ts +5 -1
  64. package/dist/components/time-input.d.ts +5 -1
  65. package/dist/components/ui.d.ts +5 -1
  66. package/dist/{delete-account-setting-CGVMckMw.d.ts → delete-account-setting-DCFS5Vof.d.ts} +52 -23
  67. package/dist/demo/demo-banner.d.ts +51 -0
  68. package/dist/demo/demo-banner.js +97 -0
  69. package/dist/demo/demo-banner.js.map +1 -0
  70. package/dist/demo/demo-countdown.d.ts +58 -0
  71. package/dist/demo/demo-countdown.js +79 -0
  72. package/dist/demo/demo-countdown.js.map +1 -0
  73. package/dist/demo/demo-ended.d.ts +45 -0
  74. package/dist/demo/demo-ended.js +42 -0
  75. package/dist/demo/demo-ended.js.map +1 -0
  76. package/dist/demo/demo-labels.d.ts +53 -0
  77. package/dist/demo/demo-labels.js +29 -0
  78. package/dist/demo/demo-labels.js.map +1 -0
  79. package/dist/demo/demo-session.d.ts +30 -0
  80. package/dist/demo/demo-session.js +8 -0
  81. package/dist/demo/demo-session.js.map +1 -0
  82. package/dist/demo/demo-start.d.ts +54 -0
  83. package/dist/demo/demo-start.js +68 -0
  84. package/dist/demo/demo-start.js.map +1 -0
  85. package/dist/feedback/feedback-attachment.d.ts +5 -1
  86. package/dist/feedback/feedback-context.d.ts +5 -1
  87. package/dist/feedback/feedback-crash.d.ts +5 -1
  88. package/dist/feedback/feedback-dialog.d.ts +5 -1
  89. package/dist/feedback/feedback-inbox.d.ts +5 -1
  90. package/dist/feedback/feedback-inbox.js +12 -2
  91. package/dist/feedback/feedback-inbox.js.map +1 -1
  92. package/dist/feedback/feedback-labels.d.ts +5 -1
  93. package/dist/feedback/feedback-labels.js +1 -0
  94. package/dist/feedback/feedback-labels.js.map +1 -1
  95. package/dist/feedback/feedback-menu.d.ts +5 -1
  96. package/dist/feedback/feedback-record.d.ts +5 -1
  97. package/dist/feedback/feedback-record.js +2 -0
  98. package/dist/feedback/feedback-record.js.map +1 -1
  99. package/dist/feedback/feedback-row-detail.d.ts +5 -1
  100. package/dist/feedback/feedback-row-detail.js +7 -0
  101. package/dist/feedback/feedback-row-detail.js.map +1 -1
  102. package/dist/feedback/feedback-status-undo.d.ts +5 -1
  103. package/dist/feedback/feedback-submit.d.ts +5 -1
  104. package/dist/feedback/feedback-swipe.d.ts +6 -2
  105. package/dist/feedback/feedback-swipe.js.map +1 -1
  106. package/dist/feedback/feedback-table.d.ts +5 -1
  107. package/dist/feedback/feedback-thread.d.ts +5 -1
  108. package/dist/feedback.d.ts +5 -1
  109. package/dist/hooks/use-file-drop.d.ts +5 -1
  110. package/dist/i18n/defaults.d.ts +5 -1
  111. package/dist/i18n/defaults.js +6 -0
  112. package/dist/i18n/defaults.js.map +1 -1
  113. package/dist/i18n/german.d.ts +5 -1
  114. package/dist/i18n/german.js +84 -2
  115. package/dist/i18n/german.js.map +1 -1
  116. package/dist/i18n/kit-labels.d.ts +5 -1
  117. package/dist/i18n/kit-labels.js.map +1 -1
  118. package/dist/i18n/languages.d.ts +5 -1
  119. package/dist/i18n/locales/de-CH.d.ts +5 -1
  120. package/dist/i18n/locales/en.d.ts +5 -1
  121. package/dist/i18n/locales/es.d.ts +5 -1
  122. package/dist/i18n/locales/es.js +81 -2
  123. package/dist/i18n/locales/es.js.map +1 -1
  124. package/dist/i18n/locales/fr.d.ts +5 -1
  125. package/dist/i18n/locales/fr.js +82 -2
  126. package/dist/i18n/locales/fr.js.map +1 -1
  127. package/dist/i18n/locales/hu.d.ts +5 -1
  128. package/dist/i18n/locales/hu.js +82 -2
  129. package/dist/i18n/locales/hu.js.map +1 -1
  130. package/dist/i18n/locales/it.d.ts +5 -1
  131. package/dist/i18n/locales/it.js +81 -2
  132. package/dist/i18n/locales/it.js.map +1 -1
  133. package/dist/i18n/locales/zh.d.ts +5 -1
  134. package/dist/i18n/locales/zh.js +80 -2
  135. package/dist/i18n/locales/zh.js.map +1 -1
  136. package/dist/i18n/review.d.ts +5 -1
  137. package/dist/i18n/review.js +14 -1
  138. package/dist/i18n/review.js.map +1 -1
  139. package/dist/index.d.ts +25 -3
  140. package/dist/index.js +95 -0
  141. package/dist/index.js.map +1 -1
  142. package/dist/landing/access.d.ts +48 -0
  143. package/dist/landing/access.js +38 -0
  144. package/dist/landing/access.js.map +1 -0
  145. package/dist/landing/landing-actions.d.ts +138 -0
  146. package/dist/landing/landing-actions.js +39 -0
  147. package/dist/landing/landing-actions.js.map +1 -0
  148. package/dist/landing/landing-labels.d.ts +43 -0
  149. package/dist/landing/landing-labels.js +23 -0
  150. package/dist/landing/landing-labels.js.map +1 -0
  151. package/dist/landing/landing-sections.d.ts +218 -0
  152. package/dist/landing/landing-sections.js +167 -0
  153. package/dist/landing/landing-sections.js.map +1 -0
  154. package/dist/landing/page-seo.d.ts +99 -0
  155. package/dist/landing/page-seo.js +121 -0
  156. package/dist/landing/page-seo.js.map +1 -0
  157. package/dist/landing/public-header.d.ts +148 -0
  158. package/dist/landing/public-header.js +49 -0
  159. package/dist/landing/public-header.js.map +1 -0
  160. package/dist/landing/routing.d.ts +218 -0
  161. package/dist/landing/routing.js +81 -0
  162. package/dist/landing/routing.js.map +1 -0
  163. package/dist/rhf/fields.d.ts +5 -1
  164. package/dist/rhf/form.d.ts +5 -1
  165. package/dist/rhf.d.ts +5 -1
  166. package/dist/settings/settings-catalogue.d.ts +84 -0
  167. package/dist/settings/settings-catalogue.js +47 -0
  168. package/dist/settings/settings-catalogue.js.map +1 -0
  169. package/dist/settings/settings-context.d.ts +31 -0
  170. package/dist/settings/settings-context.js +11 -0
  171. package/dist/settings/settings-context.js.map +1 -0
  172. package/dist/settings/settings-heading.d.ts +50 -0
  173. package/dist/settings/settings-heading.js +27 -0
  174. package/dist/settings/settings-heading.js.map +1 -0
  175. package/dist/settings/settings-labels.d.ts +41 -0
  176. package/dist/settings/settings-labels.js +38 -0
  177. package/dist/settings/settings-labels.js.map +1 -0
  178. package/dist/settings/settings-layout.d.ts +77 -0
  179. package/dist/settings/settings-layout.js +242 -0
  180. package/dist/settings/settings-layout.js.map +1 -0
  181. package/dist/settings/settings-section.d.ts +133 -0
  182. package/dist/settings/settings-section.js +63 -0
  183. package/dist/settings/settings-section.js.map +1 -0
  184. package/dist/settings/use-account-language.d.ts +83 -0
  185. package/dist/settings/use-account-language.js +62 -0
  186. package/dist/settings/use-account-language.js.map +1 -0
  187. package/dist/settings/use-settings-focus.d.ts +34 -0
  188. package/dist/settings/use-settings-focus.js +77 -0
  189. package/dist/settings/use-settings-focus.js.map +1 -0
  190. package/dist/settings/use-settings-route.d.ts +118 -0
  191. package/dist/settings/use-settings-route.js +120 -0
  192. package/dist/settings/use-settings-route.js.map +1 -0
  193. package/dist/shell/app-shell.d.ts +11 -1
  194. package/dist/shell/app-shell.js +3 -2
  195. package/dist/shell/app-shell.js.map +1 -1
  196. package/dist/shell/top-bar-brand.d.ts +5 -1
  197. package/dist/shell.d.ts +15 -1
  198. package/dist/shell.js +56 -0
  199. package/dist/shell.js.map +1 -1
  200. package/dist/wizard/stepper-nav.d.ts +5 -1
  201. package/dist/wizard.d.ts +5 -1
  202. package/package.json +2 -1
  203. package/src/account/data-export-setting.tsx +3 -2
  204. package/src/account/delete-account-setting.tsx +3 -2
  205. package/src/account/email-change-setting.tsx +4 -7
  206. package/src/account/sessions-setting.tsx +3 -2
  207. package/src/admin/invitations-panel.tsx +11 -4
  208. package/src/auth/auth-errors.ts +36 -5
  209. package/src/auth/sign-in-form.tsx +34 -3
  210. package/src/components/account-settings-labels.ts +13 -0
  211. package/src/components/account-settings.tsx +33 -12
  212. package/src/components/data-table.tsx +8 -0
  213. package/src/components/date-picker.tsx +9 -4
  214. package/src/components/passkeys-setting.tsx +2 -1
  215. package/src/components/settings-fields.tsx +50 -1
  216. package/src/demo/demo-banner.tsx +159 -0
  217. package/src/demo/demo-countdown.ts +141 -0
  218. package/src/demo/demo-ended.tsx +89 -0
  219. package/src/demo/demo-labels.ts +78 -0
  220. package/src/demo/demo-session.ts +32 -0
  221. package/src/demo/demo-start.tsx +150 -0
  222. package/src/feedback/feedback-inbox.tsx +21 -8
  223. package/src/feedback/feedback-labels.ts +2 -1
  224. package/src/feedback/feedback-record.ts +10 -7
  225. package/src/feedback/feedback-row-detail.tsx +28 -4
  226. package/src/feedback/feedback-swipe.tsx +1 -1
  227. package/src/i18n/defaults.ts +6 -0
  228. package/src/i18n/german.ts +86 -0
  229. package/src/i18n/kit-labels.tsx +9 -0
  230. package/src/i18n/locales/es.ts +83 -0
  231. package/src/i18n/locales/fr.ts +84 -0
  232. package/src/i18n/locales/hu.ts +84 -0
  233. package/src/i18n/locales/it.ts +83 -0
  234. package/src/i18n/locales/zh.ts +81 -0
  235. package/src/i18n/review.ts +13 -0
  236. package/src/index.ts +92 -1
  237. package/src/landing/access.ts +97 -0
  238. package/src/landing/landing-actions.tsx +92 -0
  239. package/src/landing/landing-labels.ts +59 -0
  240. package/src/landing/landing-sections.tsx +337 -0
  241. package/src/landing/page-seo.ts +251 -0
  242. package/src/landing/public-header.tsx +120 -0
  243. package/src/landing/routing.tsx +209 -0
  244. package/src/settings/settings-catalogue.ts +132 -0
  245. package/src/settings/settings-context.ts +33 -0
  246. package/src/settings/settings-heading.tsx +65 -0
  247. package/src/settings/settings-labels.ts +75 -0
  248. package/src/settings/settings-layout.tsx +379 -0
  249. package/src/settings/settings-section.tsx +108 -0
  250. package/src/settings/use-account-language.ts +146 -0
  251. package/src/settings/use-settings-focus.ts +120 -0
  252. package/src/settings/use-settings-route.ts +258 -0
  253. package/src/shell/app-shell.tsx +10 -2
  254. package/src/shell.ts +45 -0
@@ -12,7 +12,8 @@ import { hasMessage } from "../components/choice-parts";
12
12
  import { DangerConfirm } from "../components/danger-confirm";
13
13
  import { Skeleton } from "../components/skeleton";
14
14
  import { Button, Card } from "../components/ui";
15
- import { CARD_DESCRIPTION_CLASS, CARD_TITLE_CLASS, settle, useMounted } from "./account-parts";
15
+ import { CARD_DESCRIPTION_CLASS, settle, useMounted } from "./account-parts";
16
+ import { SettingsCardTitle } from "../settings/settings-heading";
16
17
 
17
18
  /* ── Labels ──────────────────────────────────────────────────────────────── */
18
19
 
@@ -206,7 +207,7 @@ export function SessionsSetting<Id extends SessionId = SessionId>({
206
207
  return (
207
208
  <Card {...rest} className={cn("p-4 space-y-3", className)}>
208
209
  <div>
209
- <div className={CARD_TITLE_CLASS}>{labels.title}</div>
210
+ <SettingsCardTitle>{labels.title}</SettingsCardTitle>
210
211
  <div className={CARD_DESCRIPTION_CLASS}>{labels.description}</div>
211
212
  </div>
212
213
  {showList &&
@@ -196,8 +196,10 @@ export interface InvitationsPanelProps<R extends string = string> {
196
196
  onInvite?: (draft: InvitationDraft<R>) => MaybePromise<InvitationSentAnswer | void>;
197
197
  /** Mints a new link and mails it. Left out: no resend button. */
198
198
  onResend?: (invitation: InvitationRow<R>) => MaybePromise<InvitationSentAnswer | void>;
199
- /** Revokes an open invitation (level `none`, §4.2: re-inviting undoes it). Left out:
200
- * no revoke button. */
199
+ /** Revokes an open or an expired invitation (level `none`, §4.2: re-inviting undoes
200
+ * it). An expired row can be resent, and since 0.31 also removed: in an app that
201
+ * keeps the rows it would otherwise stay listed for good (Kurvenschmiede's 0.30
202
+ * adoption). Left out: no revoke button. */
201
203
  onRevoke?: (invitation: InvitationRow<R>) => MaybePromise;
202
204
  /** The list is being fetched. */
203
205
  loading?: boolean;
@@ -205,6 +207,10 @@ export interface InvitationsPanelProps<R extends string = string> {
205
207
  describeError?: (error: unknown) => ReactNode | undefined;
206
208
  /** Over the form: who can be invited and what happens, in the app's words. */
207
209
  intro?: ReactNode;
210
+ /** The list's visible heading. Default: the `listTitle` label. `null` leaves it out,
211
+ * for a page whose card is already headed "Invitations" (Kurvenschmiede's 0.30
212
+ * adoption); the list keeps the label as its accessible name either way. */
213
+ listTitle?: ReactNode | null;
208
214
  className?: string;
209
215
  labels?: Partial<InvitationsLabels>;
210
216
  }
@@ -233,6 +239,7 @@ export function InvitationsPanel<R extends string = string>({
233
239
  loading = false,
234
240
  describeError,
235
241
  intro,
242
+ listTitle,
236
243
  className,
237
244
  labels: labelsProp,
238
245
  }: InvitationsPanelProps<R>) {
@@ -418,7 +425,7 @@ export function InvitationsPanel<R extends string = string>({
418
425
  )}
419
426
 
420
427
  <div className="space-y-2">
421
- <SectionLabel as="h4">{labels.listTitle}</SectionLabel>
428
+ {listTitle !== null && <SectionLabel as="h4">{listTitle ?? labels.listTitle}</SectionLabel>}
422
429
  {loading && invitations.length === 0 ? (
423
430
  <div className="flex justify-center py-3">
424
431
  <Spinner label={labels.loading} />
@@ -432,7 +439,7 @@ export function InvitationsPanel<R extends string = string>({
432
439
  const link = invitation.link ?? links[key];
433
440
  const open = invitation.status === "open";
434
441
  const resendable = onResend && (open || invitation.status === "expired");
435
- const revocable = onRevoke && open;
442
+ const revocable = onRevoke && (open || invitation.status === "expired");
436
443
  const scopeText = scopeLabel(invitation.scope);
437
444
  const by =
438
445
  typeof invitation.invited_by === "string" ? invitation.invited_by : nameOf(invitation.invited_by);
@@ -64,8 +64,31 @@ export type AccountErrorCode =
64
64
  | "confirmation_mismatch"
65
65
  | "password_incorrect";
66
66
 
67
- /** Every code the kit reads: the sign-in refusals and the account ones (0.30.0). */
68
- export type KitErrorCode = AuthErrorCode | AccountErrorCode;
67
+ /**
68
+ * 0.31.0: the demo's coded refusals (docs/landing-demo-harmonization.md §6.2) —
69
+ * server-kit 0.5's `DemoErrorCode`, value for value, answered as `{detail, code}` like
70
+ * the others and read by the same {@link authErrorCode}.
71
+ *
72
+ * - `demo_disabled` — `404`: the demo switch is off (it is "not there", not forbidden).
73
+ * - `demo_rate_limited` — `429` with `Retry-After`: the per-IP window of demo starts.
74
+ * - `demo_capacity` — `429`, no `Retry-After`: the cap of live demo users.
75
+ * - `demo_not_ready` — `503`: the demo data has not been seeded yet.
76
+ * - `demo_read_only` — `403`, model R: any write by a demo user. The app's write lock
77
+ * already explains it, so it shows no toast (§5.4).
78
+ * - `demo_refused` — `403`, both models: an action a demo never may — a way in or out,
79
+ * mail, uploads, the account export (§6.4).
80
+ */
81
+ export type DemoErrorCode =
82
+ | "demo_disabled"
83
+ | "demo_rate_limited"
84
+ | "demo_capacity"
85
+ | "demo_not_ready"
86
+ | "demo_read_only"
87
+ | "demo_refused";
88
+
89
+ /** Every code the kit reads: the sign-in refusals, the account ones (0.30.0) and the
90
+ * demo's (0.31.0). */
91
+ export type KitErrorCode = AuthErrorCode | AccountErrorCode | DemoErrorCode;
69
92
 
70
93
  const CODES: ReadonlySet<string> = new Set<KitErrorCode>([
71
94
  "invalid_credentials",
@@ -83,6 +106,12 @@ const CODES: ReadonlySet<string> = new Set<KitErrorCode>([
83
106
  "confirmation_required",
84
107
  "confirmation_mismatch",
85
108
  "password_incorrect",
109
+ "demo_disabled",
110
+ "demo_rate_limited",
111
+ "demo_capacity",
112
+ "demo_not_ready",
113
+ "demo_read_only",
114
+ "demo_refused",
86
115
  ]);
87
116
 
88
117
  type Bag = Record<string, unknown>;
@@ -102,8 +131,9 @@ function codeOfBody(body: unknown): KitErrorCode | undefined {
102
131
  }
103
132
 
104
133
  /**
105
- * The code an error carries — an {@link AuthErrorCode}, or since 0.30.0 an
106
- * {@link AccountErrorCode} — or `undefined` when it carries none.
134
+ * The code an error carries — an {@link AuthErrorCode}, since 0.30.0 an
135
+ * {@link AccountErrorCode}, since 0.31.0 a {@link DemoErrorCode} — or `undefined` when it
136
+ * carries none.
107
137
  *
108
138
  * Reads the parsed response body wherever the apps' HTTP clients put it, without
109
139
  * depending on any of them:
@@ -142,7 +172,8 @@ export function authErrorCode(err: unknown): KitErrorCode | undefined {
142
172
  * The kit's `SignInForm` and `RegisterForm` call it on what their callbacks reject
143
173
  * with, so an app's `onSubmit` can simply let its client's error through. See
144
174
  * {@link authErrorCode} for the shapes it reads. Since 0.30.0 it knows the account codes
145
- * too: `isAuthError(err, "last_admin")`.
175
+ * too: `isAuthError(err, "last_admin")`; since 0.31.0 the demo's:
176
+ * `isAuthError(err, "demo_read_only")`.
146
177
  */
147
178
  export function isAuthError(err: unknown, code?: KitErrorCode): boolean {
148
179
  const found = authErrorCode(err);
@@ -10,6 +10,9 @@ import { CurrentPasswordInput } from "../components/danger-confirm";
10
10
  import { OneTimeCodeInput } from "../components/one-time-code-input";
11
11
  import { TextLink } from "../components/text-link";
12
12
  import { Button, Input } from "../components/ui";
13
+ import { accessAction } from "../landing/access";
14
+ import type { AccessChoice } from "../landing/access";
15
+ import { useLandingLabels } from "../landing/landing-labels";
13
16
  import { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors";
14
17
  import type { KitErrorCode } from "./auth-errors";
15
18
  import { taggedEmail } from "./email-tag";
@@ -171,6 +174,9 @@ export interface SignInLabels {
171
174
  * wait its `Retry-After` asks for, in seconds ({@link retryAfterSeconds}), or
172
175
  * `undefined` (or `0`) without one. */
173
176
  rateLimited?: (seconds?: number) => string;
177
+ /** 0.31.0: the link after `noAccount` for an invitation-only app (`access` of kind
178
+ * `request`) — a mail to support, in place of `register`. */
179
+ requestAccess?: string;
174
180
  }
175
181
 
176
182
  /** `Required`: every key, the 0.30 ones included, has its English here. */
@@ -208,6 +214,7 @@ export const DEFAULT_SIGN_IN_LABELS: Required<SignInLabels> = {
208
214
  recoveryCodeHint: (length) => `${length} letters and digits. Dashes and spaces don’t matter.`,
209
215
  recoveryCodeInvalid: "This backup code is not valid, or it has been used already.",
210
216
  rateLimited: englishRateLimited,
217
+ requestAccess: "Request access",
211
218
  };
212
219
 
213
220
  /* ── Props ───────────────────────────────────────────────────────────────── */
@@ -252,6 +259,15 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
252
259
  forgotHref?: string;
253
260
  /** "No account yet? Create account" goes here — `/register`. Left out, no line. */
254
261
  registerHref?: string;
262
+ /**
263
+ * 0.31.0 (docs/landing-demo-harmonization.md §4.2): how a visitor gets an account —
264
+ * the same value the landing's `PublicHeader` and `Hero` take. `{kind: "request",
265
+ * email}` turns the line into "No account yet? Request access", a mail to support with
266
+ * the subject and body of `accessAction` (every app is invitation-only); `{kind:
267
+ * "register", href}` keeps "Create account", to `href`. Wins over `registerHref`; left
268
+ * out, `registerHref` works as before.
269
+ */
270
+ access?: AccessChoice;
255
271
  /** The email field's start value — the reset page ends on "Sign in" with the address
256
272
  * filled in (§6.3). */
257
273
  defaultEmail?: string;
@@ -408,6 +424,7 @@ export function SignInForm({
408
424
  passkeyAutofill,
409
425
  forgotHref,
410
426
  registerHref,
427
+ access,
411
428
  defaultEmail = "",
412
429
  emailTag,
413
430
  deactivatedContact,
@@ -424,6 +441,20 @@ export function SignInForm({
424
441
  ...rest
425
442
  }: SignInFormProps) {
426
443
  const labels = useKitLabels("signIn", DEFAULT_SIGN_IN_LABELS, labelsProp);
444
+ // The mail's subject and body are the landing's words (`landing` namespace), so the
445
+ // request reads the same from every page that offers it.
446
+ const landing = useLandingLabels();
447
+ const accountLink =
448
+ access === undefined
449
+ ? registerHref === undefined
450
+ ? null
451
+ : { href: registerHref, label: labels.register }
452
+ : access.kind === "register"
453
+ ? { href: access.href, label: labels.register }
454
+ : {
455
+ href: accessAction(access, landing).href,
456
+ label: labels.requestAccess ?? DEFAULT_SIGN_IN_LABELS.requestAccess,
457
+ };
427
458
  const [step, setStep] = useState<SignInStep>("credentials");
428
459
  const [email, setEmail] = useState(defaultEmail);
429
460
  const [password, setPassword] = useState("");
@@ -834,11 +865,11 @@ export function SignInForm({
834
865
  </TextLink>
835
866
  </p>
836
867
  )}
837
- {registerHref !== undefined && (
868
+ {accountLink !== null && (
838
869
  <p className="text-center text-sm text-[var(--text-secondary)]">
839
870
  {labels.noAccount}{" "}
840
- <TextLink href={registerHref} tone="primary">
841
- {labels.register}
871
+ <TextLink href={accountLink.href} tone="primary">
872
+ {accountLink.label}
842
873
  </TextLink>
843
874
  </p>
844
875
  )}
@@ -46,6 +46,12 @@ export interface PasswordSettingLabels {
46
46
  }
47
47
 
48
48
  export interface TwoFactorSettingLabels {
49
+ /**
50
+ * Before 0.31 the card's one line, "<status>: On". Since 0.31 the setting's name is the
51
+ * card's {@link title} and the state stands beside it, so this is read only where no
52
+ * `title` is given at the same level — an app that translated `status` keeps its word
53
+ * as the title.
54
+ */
49
55
  status: string;
50
56
  enabledText: string;
51
57
  disabledText: string;
@@ -70,6 +76,12 @@ export interface TwoFactorSettingLabels {
70
76
  secretHint?: string;
71
77
  /** The copy button beside that key. */
72
78
  copySecret?: string;
79
+ /**
80
+ * 0.31.0 (docs/settings-harmonization.md §3.7): the card's title, a heading inside a
81
+ * `SettingsLayout`. OPTIONAL for the reason `qrAlt` is. Unset, the same source's
82
+ * `status` stands in for it, then English.
83
+ */
84
+ title?: string;
73
85
  }
74
86
 
75
87
  export interface PasskeysSettingLabels {
@@ -160,6 +172,7 @@ export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
160
172
  qrAlt: "QR code",
161
173
  secretHint: "Can’t scan it? Enter this key in the app instead:",
162
174
  copySecret: "Copy key",
175
+ title: "Two-factor authentication",
163
176
  },
164
177
  passkeys: {
165
178
  title: "Passkeys",
@@ -13,8 +13,10 @@ import {
13
13
  PasswordStrengthMeter,
14
14
  } from "./password-strength";
15
15
  import type { PasswordStrengthMeterProps } from "./password-strength";
16
- import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
16
+ import { useKitLabelOverrides, useKitLabels } from "../i18n/kit-labels";
17
+ import { Chip } from "./chip";
17
18
  import { useAccountSettingsLabels } from "./account-settings-labels";
19
+ import { SettingsCardTitle } from "../settings/settings-heading";
18
20
  import type {
19
21
  PasswordSettingLabels,
20
22
  ProfileSettingLabels,
@@ -59,6 +61,8 @@ interface ProfileSettingCommonProps {
59
61
  saving?: boolean;
60
62
  /** Prop > `<UiKitProvider labels={{ accountSettings: { profile } }}>` > English. */
61
63
  labels?: Partial<ProfileSettingLabels>;
64
+ /** The card's DOM id — a settings catalogue `anchor`, so `?focus=` can ring it (0.31). */
65
+ id?: string;
62
66
  }
63
67
 
64
68
  /**
@@ -114,11 +118,11 @@ export type ProfileSettingProps = ProfileSettingDisplayNameProps | ProfileSettin
114
118
  * blank, and is a `commit` (a {@link WriteLockProvider} locks it, keksdose's demo).
115
119
  */
116
120
  export function ProfileSetting(props: ProfileSettingProps) {
117
- const { name, email, role, memberSince, labels: labelsProp } = props;
121
+ const { name, email, role, memberSince, labels: labelsProp, id } = props;
118
122
  const labels = useAccountSettingsLabels("profile", labelsProp);
119
123
  const names = props.firstName !== undefined || props.lastName !== undefined;
120
124
  return (
121
- <Card className="p-4 space-y-3">
125
+ <Card id={id} className="p-4 space-y-3">
122
126
  <div className="flex items-center gap-3">
123
127
  <UserAvatar
124
128
  name={name}
@@ -127,7 +131,9 @@ export function ProfileSetting(props: ProfileSettingProps) {
127
131
  size="lg"
128
132
  />
129
133
  <div className="min-w-0">
130
- <div className="text-sm font-medium">{labels.title}</div>
134
+ {/* A heading inside a SettingsLayout (0.31, docs/settings-harmonization.md
135
+ §3.7), the plain div it always was outside one. */}
136
+ <SettingsCardTitle>{labels.title}</SettingsCardTitle>
131
137
  <div className="truncate font-mono text-xs text-[var(--text-muted)]">
132
138
  {/* `email` was a required key that nothing rendered; it names the address
133
139
  for a screen reader, which otherwise hears a bare string under a title. */}
@@ -293,6 +299,7 @@ export function PasswordSetting({
293
299
  strength,
294
300
  maxBytes,
295
301
  labels: labelsProp,
302
+ id,
296
303
  }: {
297
304
  /** Called with the validated (current, new) pair; return a promise to auto-clear on success. */
298
305
  onSubmit: (currentPassword: string, newPassword: string) => void | Promise<unknown>;
@@ -323,6 +330,8 @@ export function PasswordSetting({
323
330
  maxBytes?: number;
324
331
  /** Prop > `<UiKitProvider labels={{ accountSettings: { password } }}>` > English. */
325
332
  labels?: Partial<PasswordSettingLabels>;
333
+ /** The card's DOM id — a settings catalogue `anchor`, so `?focus=` can ring it (0.31). */
334
+ id?: string;
326
335
  }) {
327
336
  const labels = useAccountSettingsLabels("password", labelsProp);
328
337
  const strengthOptions = typeof strength === "object" ? strength : undefined;
@@ -365,8 +374,8 @@ export function PasswordSetting({
365
374
  );
366
375
 
367
376
  return (
368
- <Card className="p-4 space-y-3">
369
- <div className="text-sm font-medium">{labels.title}</div>
377
+ <Card id={id} className="p-4 space-y-3">
378
+ <SettingsCardTitle>{labels.title}</SettingsCardTitle>
370
379
  <Input type="password" autoComplete="current-password" label={labels.current} value={current} onChange={(e) => setCurrent(e.target.value)} />
371
380
  {strength ? (
372
381
  // One box for the field and its meter, so the card's `space-y-3` spaces the pair
@@ -433,6 +442,7 @@ export function TwoFactorSetting({
433
442
  busy,
434
443
  renderQr,
435
444
  labels: labelsProp,
445
+ id,
436
446
  }: {
437
447
  enabled: boolean;
438
448
  /** What the app's setup call returned; null before setup starts. */
@@ -446,8 +456,16 @@ export function TwoFactorSetting({
446
456
  renderQr?: (otpauthUri: string) => ReactNode;
447
457
  /** Prop > `<UiKitProvider labels={{ accountSettings: { twoFactor } }}>` > English. */
448
458
  labels?: Partial<TwoFactorSettingLabels>;
459
+ /** The card's DOM id — a settings catalogue `anchor`, so `?focus=` can ring it (0.31). */
460
+ id?: string;
449
461
  }) {
450
462
  const labels = useAccountSettingsLabels("twoFactor", labelsProp);
463
+ // The title (0.31, docs/settings-harmonization.md §3.7) is new, and an app that
464
+ // translated this card before it named the setting in `status` — "2FA",
465
+ // "Zwei-Faktor-Authentifizierung". Each source's `title` first, then its `status`, so
466
+ // that app keeps its own word instead of meeting the English title.
467
+ const overrides = useKitLabelOverrides("accountSettings")?.twoFactor;
468
+ const title = labelsProp?.title ?? overrides?.title ?? labelsProp?.status ?? overrides?.status ?? labels.title;
451
469
  const [code, setCode] = useState("");
452
470
  const [password, setPassword] = useState("");
453
471
  // Both code fields are a OneTimeCodeInput (0.22.0) — the field this card wrote by hand
@@ -461,17 +479,20 @@ export function TwoFactorSetting({
461
479
  // pasted from an authenticator that groups its digits used to reach `onEnable`
462
480
  // with the space in it.
463
481
  // - one `code` state for both, as before.
464
- // Only for the "Status: Enabled" composition — the punctuation between a field's
465
- // name and its value is the language's (see `CommonLabels.fieldValue`).
466
- const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
467
482
  const secret = setup
468
483
  ? (setup.secret ?? ("otpauthUri" in setup ? otpauthSecret(setup.otpauthUri) : null))
469
484
  : null;
470
485
 
471
486
  return (
472
- <Card className="p-4 space-y-3">
473
- <div className="text-sm">
474
- {common.fieldValue(labels.status, enabled ? labels.enabledText : labels.disabledText)}
487
+ <Card id={id} className="p-4 space-y-3">
488
+ {/* The card had only "Two-factor authentication: Off". A settings page outlines its
489
+ cards by their titles (§3.7), so the setting's name is the title now, and the
490
+ state stands beside it as a chip instead of repeating the name. */}
491
+ <div className="flex flex-wrap items-center justify-between gap-2">
492
+ <SettingsCardTitle>{title}</SettingsCardTitle>
493
+ <Chip size="sm" tone={enabled ? "success" : "neutral"}>
494
+ {enabled ? labels.enabledText : labels.disabledText}
495
+ </Chip>
475
496
  </div>
476
497
 
477
498
  {!enabled && !setup && (
@@ -1725,6 +1725,14 @@ export function DataTable<T>({
1725
1725
  // flip stays instant.
1726
1726
  interactive &&
1727
1727
  "cursor-pointer transition-transform duration-100 active:scale-[0.995] active:bg-[var(--bg-active)] motion-reduce:transition-none motion-reduce:active:scale-100",
1728
+ // The keyboard's frame drawn INSIDE the card, as the desktop row's is (keksdose live
1729
+ // #394): the browser's own ring and tap highlight sit on the card's outer edge, and
1730
+ // three ancestors clip it — SwipeableRow's `overflow-hidden`, the card list, and
1731
+ // AppShell main's `overflow-x-clip` — so the frame went missing on one side. A press
1732
+ // keeps the fill and the squeeze above; an outline on every tap of a long list would
1733
+ // be louder than the acknowledgement it replaces.
1734
+ interactive &&
1735
+ "[-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--brand)]",
1728
1736
  );
1729
1737
  // The card's content is written once and worn by either tag below. Note that
1730
1738
  // the primary cell is rendered RAW here, never through `linkColumn` — the card
@@ -588,9 +588,6 @@ function StepButton({
588
588
  // px-3 would make it wider than it needs to be. twMerge lets these win.
589
589
  "relative flex h-full w-10 shrink-0 items-center justify-center px-0 text-[var(--text-muted)]",
590
590
  "hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]",
591
- // Raised while hovered or focused, so the shared border and the focus ring are
592
- // drawn on top of the neighbour they overlap by a pixel.
593
- "hover:z-10 focus-visible:z-10",
594
591
  // Dimmed ICON, not a dimmed box: at 40% opacity the whole button — border
595
592
  // included — faded out of the group and read as a broken, detached control.
596
593
  "disabled:cursor-not-allowed disabled:text-[var(--text-placeholder)] disabled:opacity-60 disabled:hover:bg-[var(--bg-surface)]",
@@ -1164,7 +1161,15 @@ export function DatePicker(props: DatePickerProps) {
1164
1161
  "flex items-stretch [&>*:not(:first-child)]:-ms-px",
1165
1162
  step && "[&_[role=combobox]]:rounded-s-none",
1166
1163
  (step || today) && "[&_[role=combobox]]:rounded-e-none",
1167
- "[&_[role=combobox]:focus-visible]:relative [&_[role=combobox]:focus-visible]:z-10",
1164
+ // The PART that is focused, pressed or invalid is raised over the neighbour it
1165
+ // overlaps by a pixel, so its blue (or red) border shows on all four sides
1166
+ // (keksdose live #394). `:focus-within`, not `:focus-visible`: a tap focuses
1167
+ // without it, as does the sheet handing focus back, and the next part — later in
1168
+ // the DOM — painted its grey left edge over the tapped one's right. Raising the
1169
+ // whole part (each is `relative`) keeps the floating label and the icon above
1170
+ // the trigger, which raising the trigger alone covered. Hover raises nothing: it
1171
+ // changes no border, and would let a hovered › cover a focused field's edge.
1172
+ "[&>*:focus-within]:z-20 [&>*:active]:z-20 [&>*:has([aria-invalid=true])]:z-10",
1168
1173
  className,
1169
1174
  )}
1170
1175
  >
@@ -6,6 +6,7 @@ import { useKitLocale } from "../i18n/kit-labels";
6
6
  import { Button, Card, EmptyState, Input } from "./ui";
7
7
  import { Skeleton } from "./skeleton";
8
8
  import { useAccountSettingsLabels } from "./account-settings-labels";
9
+ import { SettingsCardTitle } from "../settings/settings-heading";
9
10
  import type { PasskeysSettingLabels } from "./account-settings-labels";
10
11
 
11
12
  /** What a passkey's `id` may be: the app's own key type, string or numeric. */
@@ -243,7 +244,7 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
243
244
  // `rest` is only `id` and `data-*`: everything else the props name is destructured.
244
245
  <Card {...rest} className={cn("p-4 space-y-3", className)}>
245
246
  <div>
246
- <div className="text-sm font-medium">{labels.title}</div>
247
+ <SettingsCardTitle>{labels.title}</SettingsCardTitle>
247
248
  <div className="text-xs text-[var(--text-muted)]">
248
249
  {mode === "alongside"
249
250
  ? labels.descriptionAlongside
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Select } from "./ui";
3
3
  import type { SelectProps } from "./ui";
4
+ import { ToggleGroup } from "./toggle-group";
5
+ import type { ToggleGroupBaseProps } from "./toggle-group";
4
6
  import type { ThemePreference } from "../theme/theme-store";
5
7
 
6
8
  /**
@@ -20,6 +22,8 @@ import type { ThemePreference } from "../theme/theme-store";
20
22
  */
21
23
  export interface ThemeSettingProps
22
24
  extends Omit<SelectProps, "value" | "onChange" | "children"> {
25
+ /** The `Select` (default). See {@link ThemeSettingToggleProps} for `"toggle"`. */
26
+ variant?: "select";
23
27
  value: ThemePreference;
24
28
  onChange: (value: ThemePreference) => void;
25
29
  label: ReactNode;
@@ -28,7 +32,52 @@ export interface ThemeSettingProps
28
32
  id?: string;
29
33
  }
30
34
 
31
- export function ThemeSetting({ value, onChange, label, optionLabels, id, ...rest }: ThemeSettingProps) {
35
+ /**
36
+ * `variant="toggle"` (0.31.0, docs/settings-harmonization.md §2.8): the three choices as
37
+ * one segmented control instead of a select — system, light and dark side by side, the
38
+ * choice visible without opening anything, in every app (keksdose offered light and dark
39
+ * only; its store already knew "system"). A {@link ToggleGroup}'s props pass through —
40
+ * `labelPlacement`, `disabledReason`, `commit`, `size` — minus the options and the value,
41
+ * which this owns.
42
+ *
43
+ * Bind it to the PREFERENCE (`"system"` included), never to the resolved mode: on a
44
+ * system-dark device a control bound to the mode shows "dark" and cannot say "follow the
45
+ * system" (keksdose's control, §8).
46
+ */
47
+ export interface ThemeSettingToggleProps
48
+ extends Omit<ToggleGroupBaseProps<ThemePreference>, "options" | "label" | "ariaLabel"> {
49
+ variant: "toggle";
50
+ value: ThemePreference;
51
+ onChange: (value: ThemePreference) => void;
52
+ /**
53
+ * Names the group: the field's label, or the label above it (`labelPlacement`). Leave
54
+ * it out inside a card already titled "Theme" and name the group with `aria-label` or
55
+ * `aria-labelledby` instead, so the card does not say "Theme" twice.
56
+ */
57
+ label?: ReactNode;
58
+ /** Translated option labels. */
59
+ optionLabels: { system: string; light: string; dark: string };
60
+ id?: string;
61
+ }
62
+
63
+ export function ThemeSetting(props: ThemeSettingProps | ThemeSettingToggleProps) {
64
+ if (props.variant === "toggle") {
65
+ const { variant: _variant, value, onChange, label, optionLabels, ...rest } = props;
66
+ return (
67
+ <ToggleGroup<ThemePreference>
68
+ {...rest}
69
+ label={label}
70
+ value={value}
71
+ onChange={onChange}
72
+ options={[
73
+ { value: "system", label: optionLabels.system },
74
+ { value: "light", label: optionLabels.light },
75
+ { value: "dark", label: optionLabels.dark },
76
+ ]}
77
+ />
78
+ );
79
+ }
80
+ const { variant: _variant, value, onChange, label, optionLabels, id, ...rest } = props;
32
81
  return (
33
82
  <Select
34
83
  {...rest}