@eifi1/ui-kit 0.50.0 → 0.51.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 (143) hide show
  1. package/README.md +14 -12
  2. package/dist/admin/user-roster.js +31 -12
  3. package/dist/admin/user-roster.js.map +1 -1
  4. package/dist/auth/verify-email.d.ts +4 -0
  5. package/dist/auth/verify-email.js.map +1 -1
  6. package/dist/components/alert-banner.d.ts +9 -0
  7. package/dist/components/alert-banner.js +9 -5
  8. package/dist/components/alert-banner.js.map +1 -1
  9. package/dist/components/autocomplete.js +13 -0
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -19
  12. package/dist/components/button-look.js +3 -30
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox.js +3 -1
  15. package/dist/components/combobox.js.map +1 -1
  16. package/dist/components/data-table-fit.js.map +1 -1
  17. package/dist/components/data-table-virtual.js +1 -0
  18. package/dist/components/data-table-virtual.js.map +1 -1
  19. package/dist/components/field-base.d.ts +31 -1
  20. package/dist/components/field-base.js +28 -0
  21. package/dist/components/field-base.js.map +1 -1
  22. package/dist/components/floating-panel.d.ts +6 -13
  23. package/dist/components/floating-panel.js +2 -9
  24. package/dist/components/floating-panel.js.map +1 -1
  25. package/dist/components/icon-button.js +3 -5
  26. package/dist/components/icon-button.js.map +1 -1
  27. package/dist/components/inline-entity-combobox.js +12 -0
  28. package/dist/components/inline-entity-combobox.js.map +1 -1
  29. package/dist/components/select.js +3 -2
  30. package/dist/components/select.js.map +1 -1
  31. package/dist/components/stat-tile.d.ts +8 -1
  32. package/dist/components/stat-tile.js +7 -3
  33. package/dist/components/stat-tile.js.map +1 -1
  34. package/dist/eslint/index.js +1 -1
  35. package/dist/i18n/german.js +2 -0
  36. package/dist/i18n/german.js.map +1 -1
  37. package/dist/i18n/kit-labels.d.ts +2 -2
  38. package/dist/i18n/kit-labels.js.map +1 -1
  39. package/dist/i18n/locales/en.js +2 -0
  40. package/dist/i18n/locales/en.js.map +2 -2
  41. package/dist/i18n/locales/es.js +2 -0
  42. package/dist/i18n/locales/es.js.map +1 -1
  43. package/dist/i18n/locales/fr.js +2 -0
  44. package/dist/i18n/locales/fr.js.map +1 -1
  45. package/dist/i18n/locales/hu.js +2 -0
  46. package/dist/i18n/locales/hu.js.map +1 -1
  47. package/dist/i18n/locales/it.js +2 -0
  48. package/dist/i18n/locales/it.js.map +1 -1
  49. package/dist/i18n/locales/zh.js +2 -0
  50. package/dist/i18n/locales/zh.js.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.js +2 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/landing/page-seo.d.ts +19 -11
  55. package/dist/landing/page-seo.js +11 -7
  56. package/dist/landing/page-seo.js.map +1 -1
  57. package/dist/landing/public-page.d.ts +57 -0
  58. package/dist/landing/public-page.js +45 -0
  59. package/dist/landing/public-page.js.map +1 -0
  60. package/dist/lib/quiet-focus.d.ts +4 -0
  61. package/dist/lib/quiet-focus.js +19 -0
  62. package/dist/lib/quiet-focus.js.map +1 -0
  63. package/dist/lib/server-wake.d.ts +3 -2
  64. package/dist/lib/server-wake.js +19 -13
  65. package/dist/lib/server-wake.js.map +1 -1
  66. package/dist/shell/app-shell.d.ts +2 -15
  67. package/dist/shell/app-shell.js +7 -26
  68. package/dist/shell/app-shell.js.map +1 -1
  69. package/dist/shell/dev-menu.d.ts +98 -0
  70. package/dist/shell/dev-menu.js +133 -0
  71. package/dist/shell/dev-menu.js.map +1 -0
  72. package/dist/shell/dev-switchers.d.ts +6 -53
  73. package/dist/shell/dev-switchers.js +5 -100
  74. package/dist/shell/dev-switchers.js.map +1 -1
  75. package/dist/shell/top-bar-actions.d.ts +4 -1
  76. package/dist/shell/top-bar-actions.js.map +1 -1
  77. package/dist/shell/top-bar-parts-labels.d.ts +18 -9
  78. package/dist/shell/top-bar-parts-labels.js +2 -0
  79. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  80. package/dist/shell/topbar-action-menu.d.ts +17 -2
  81. package/dist/shell/topbar-action-menu.js +3 -1
  82. package/dist/shell/topbar-action-menu.js.map +1 -1
  83. package/dist/shell.d.ts +3 -1
  84. package/dist/shell.js +3 -1
  85. package/dist/shell.js.map +1 -1
  86. package/dist/testing/index.d.ts +1 -0
  87. package/dist/testing/index.js +4 -0
  88. package/dist/testing/index.js.map +1 -1
  89. package/dist/testing/tour.d.ts +31 -0
  90. package/dist/testing/tour.js +12 -0
  91. package/dist/testing/tour.js.map +1 -0
  92. package/dist/theme/palette-presets.d.ts +0 -3
  93. package/dist/theme/palette-presets.js +0 -2
  94. package/dist/theme/palette-presets.js.map +1 -1
  95. package/dist/tour/tour-engine.d.ts +12 -0
  96. package/dist/tour/tour-engine.js +31 -0
  97. package/dist/tour/tour-engine.js.map +1 -0
  98. package/dist/tour/tour.d.ts +14 -3
  99. package/dist/tour/tour.js +60 -45
  100. package/dist/tour/tour.js.map +1 -1
  101. package/dist/tour/use-tour-url-sync.d.ts +5 -3
  102. package/dist/tour/use-tour-url-sync.js +21 -1
  103. package/dist/tour/use-tour-url-sync.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/admin/user-roster.tsx +30 -10
  106. package/src/auth/verify-email.tsx +4 -0
  107. package/src/components/alert-banner.tsx +37 -10
  108. package/src/components/autocomplete.tsx +18 -0
  109. package/src/components/button-look.ts +7 -57
  110. package/src/components/combobox.tsx +5 -1
  111. package/src/components/data-table-fit.ts +4 -1
  112. package/src/components/data-table-virtual.ts +14 -1
  113. package/src/components/field-base.tsx +53 -1
  114. package/src/components/floating-panel.tsx +6 -28
  115. package/src/components/icon-button.tsx +6 -8
  116. package/src/components/inline-entity-combobox.tsx +17 -0
  117. package/src/components/select.tsx +3 -6
  118. package/src/components/stat-tile.tsx +27 -9
  119. package/src/i18n/german.ts +2 -0
  120. package/src/i18n/kit-labels.tsx +2 -2
  121. package/src/i18n/locales/es.ts +2 -0
  122. package/src/i18n/locales/fr.ts +2 -0
  123. package/src/i18n/locales/hu.ts +2 -0
  124. package/src/i18n/locales/it.ts +2 -0
  125. package/src/i18n/locales/zh.ts +2 -0
  126. package/src/index.ts +4 -1
  127. package/src/landing/page-seo.ts +28 -16
  128. package/src/landing/public-page.tsx +106 -0
  129. package/src/lib/quiet-focus.ts +32 -0
  130. package/src/lib/server-wake.ts +41 -17
  131. package/src/shell/app-shell.tsx +11 -48
  132. package/src/shell/dev-menu.tsx +259 -0
  133. package/src/shell/dev-switchers.tsx +13 -174
  134. package/src/shell/top-bar-actions.tsx +4 -1
  135. package/src/shell/top-bar-parts-labels.ts +20 -9
  136. package/src/shell/topbar-action-menu.tsx +28 -2
  137. package/src/shell.ts +5 -1
  138. package/src/testing/index.ts +8 -0
  139. package/src/testing/tour.ts +41 -0
  140. package/src/theme/palette-presets.ts +0 -4
  141. package/src/tour/tour-engine.ts +41 -0
  142. package/src/tour/tour.tsx +64 -24
  143. package/src/tour/use-tour-url-sync.ts +33 -4
package/README.md CHANGED
@@ -28,8 +28,8 @@ with its own Tailwind config.
28
28
  ## Compatibility with server-kit
29
29
 
30
30
  The backend half, [`eifi1-server-kit`](https://github.com/Eifi1/server-kit), is a separate
31
- package with its own version and changelog. **ui-kit 0.49 ↔ server-kit 0.19**: this release
32
- is tested against server-kit 0.19.0's API export (the showcase's Server kit pages and the
31
+ package with its own version and changelog. **ui-kit 0.51 ↔ server-kit 0.20**: this release
32
+ is tested against server-kit 0.20.0's API export (the showcase's Server kit pages and the
33
33
  vocabulary test, `showcase/src/__tests__/server-kit-vocabularies.test.ts`).
34
34
 
35
35
  ## Documentation
@@ -568,26 +568,26 @@ columns, strings and permissions (see the note at the top of `src/feedback/feedb
568
568
 
569
569
  <!-- BEGIN GENERATED: exports — node scripts/gen-export-inventory.mjs -->
570
570
 
571
- **2188 names from 361 modules** — 1014 values and 1174 types. _Italic_ is a type-only export.
571
+ **2193 names from 363 modules** — 1014 values and 1179 types. _Italic_ is a type-only export.
572
572
 
573
573
  Generated from `dist/index.d.ts` by `node scripts/gen-export-inventory.mjs`; the count
574
574
  is pinned by `src/__tests__/public-surface.test.ts`. Do not edit between the markers.
575
575
 
576
576
  | Entry point | Names |
577
577
  |---|---|
578
- | `@eifi1/ui-kit` | 2188 |
578
+ | `@eifi1/ui-kit` | 2193 |
579
579
  | `@eifi1/ui-kit/chart` | 100 |
580
- | `@eifi1/ui-kit/shell` | 117 |
580
+ | `@eifi1/ui-kit/shell` | 119 |
581
581
  | `@eifi1/ui-kit/data-table` | 56 |
582
582
  | `@eifi1/ui-kit/wizard` | 25 |
583
- | `@eifi1/ui-kit/tour` | 48 |
583
+ | `@eifi1/ui-kit/tour` | 49 |
584
584
  | `@eifi1/ui-kit/feedback` | 212 |
585
585
  | `@eifi1/ui-kit/search` | 23 |
586
586
  | `@eifi1/ui-kit/dates` | 22 |
587
587
  | `@eifi1/ui-kit/table-text` | 14 |
588
588
  | `@eifi1/ui-kit/currencies` | 3 |
589
589
  | `@eifi1/ui-kit/eslint` | 1 |
590
- | `@eifi1/ui-kit/testing` | 11 |
590
+ | `@eifi1/ui-kit/testing` | 15 |
591
591
  | `@eifi1/ui-kit/testing/react` | 2 |
592
592
  | `@eifi1/ui-kit/rhf` | 48 |
593
593
  | `@eifi1/ui-kit/offline` | 33 |
@@ -661,7 +661,7 @@ re-slicing of it, never a second API.
661
661
  |---|---|
662
662
  | `theme/color` | `contrast`, `deltaE`, `hexToOklch`, `luminance`, `oklchToHex`, `oklchToRgb`, `parseHex`, `rgbToOklch`, `simulateCvd`, `solveLightness`, `toHex`, _`CvdType`_, _`Oklch`_, _`Rgb`_ |
663
663
  | `theme/palette-derive` | `auditChartRamp`, `auditPalette`, `deriveChartRamp`, `derivePalette`, `describe`, _`AnchorRole`_, _`ChartRampReport`_, _`ContrastCheck`_, _`ContrastReport`_, _`DerivedPalette`_, _`DeriveOptions`_, _`PaletteAnchors`_, _`SemanticTokens`_ |
664
- | `theme/palette-presets` | `applyTokenSet`, `DEFAULT_PRESET`, `DERIVED_PRESETS`, `IMPRINT_PRESET`, `PALETTES`, `PAUL_TOL_PRESET`, `presetById`, _`PalettePreset`_, _`TokenSet`_ |
664
+ | `theme/palette-presets` | `applyTokenSet`, `DEFAULT_PRESET`, `DERIVED_PRESETS`, `PALETTES`, `PAUL_TOL_PRESET`, `presetById`, _`PalettePreset`_, _`TokenSet`_ |
665
665
  | `theme/theme-store` | `applyPersistedTheme`, `createThemeStore`, _`ThemeMode`_, _`ThemePreference`_, _`ThemeState`_, _`ThemeStore`_ |
666
666
  | `theme/palette-store` | `applyPersistedPalette`, `createPaletteStore`, _`PaletteStore`_ |
667
667
  | `theme/text-size` | `applyPersistedTextSize`, `applyTextSize`, `createTextSizeStore`, `isTextSize`, `readPersistedTextSize`, `readTextSize`, `resolveTextSize`, `TEXT_SCALE`, `TEXT_SIZE_INLINE_SCRIPT`, `TEXT_SIZES`, `useTextSize`, _`ApplyTextSizeOptions`_, _`PersistedAppearanceOptions`_, _`PersistedTextSize`_, _`TextSize`_, _`TextSizeInfo`_, _`TextSizeState`_, _`TextSizeStore`_ |
@@ -841,7 +841,7 @@ re-slicing of it, never a second API.
841
841
  |---|---|
842
842
  | `shell/topbar-controls` | `LanguageMenu`, `PaletteMenu`, `ThemeToggle`, `TOPBAR_MENU_ITEM_CLASS`, `TOPBAR_TRIGGER_CLASS`, _`LanguageMenuTrigger`_, _`LanguageOption`_ |
843
843
  | `shell/top-bar` | `TopBar`, _`TopBarProps`_ |
844
- | `shell/app-shell` | `AppShell`, `DEFAULT_APP_SHELL_MORE_LABELS`, `DEFAULT_MOBILE_BAR_MAX`, `DEFAULT_MOBILE_MAX_ITEMS`, `pickMobileBar`, `splitMobileBar`, _`AppShellMoreLabels`_, _`AppShellNavItem`_, _`AppShellProps`_, _`AppShellSidebarFooterItem`_, _`AppShellSubItem`_ |
844
+ | `shell/app-shell` | `AppShell`, `DEFAULT_APP_SHELL_MORE_LABELS`, `DEFAULT_MOBILE_MAX_ITEMS`, `pickMobileBar`, `splitMobileBar`, _`AppShellMoreLabels`_, _`AppShellNavItem`_, _`AppShellProps`_, _`AppShellSidebarFooterItem`_, _`AppShellSubItem`_ |
845
845
  | `shell/option-switcher-menu` | `OptionSwitcherMenu`, _`OptionSwitcherOption`_ |
846
846
  | `shell/role-switcher` | `RoleSwitcher`, _`RoleSwitcherProps`_ |
847
847
  | `shell/topbar-action-menu` | `TopBarActionMenu`, _`TopBarMenuEntry`_, _`TopBarMenuHeader`_ |
@@ -854,7 +854,8 @@ re-slicing of it, never a second API.
854
854
  | `shell/account-menu-text-size` | `AccountMenuTextSize`, _`AccountMenuTextSizeProps`_ |
855
855
  | `shell/tour-menu` | `TourMenu`, _`TourMenuProps`_, _`TourMenuTour`_ |
856
856
  | `shell/sync-status-indicator` | `syncState`, `SyncStatusIndicator`, _`SyncState`_, _`SyncStatus`_, _`SyncStatusIndicatorProps`_, _`SyncStatusScope`_ |
857
- | `shell/dev-switchers` | `DEV_ORIGINAL_ROLE_KEY`, `DevSwitchers`, _`DevSwitchersProps`_ |
857
+ | `shell/dev-menu` | `DEV_ORIGINAL_ROLE_KEY`, `DevMenu`, _`DevMenuProps`_, _`DevMenuSection`_ |
858
+ | `shell/dev-switchers` | `DevSwitchers`, _`DevSwitchersProps`_ |
858
859
  | `shell/top-bar-parts-labels` | `DEFAULT_TOP_BAR_PARTS_LABELS`, `useTopBarPartsLabels`, _`DevSwitchersLabels`_, _`SyncStatusLabels`_, _`TopBarPartsLabels`_, _`TourMenuLabels`_ |
859
860
  | `shell/company-switcher` | `CompanySwitcher`, `DEFAULT_COMPANY_SWITCHER_LABELS`, _`CompanySwitcherCompany`_, _`CompanySwitcherLabels`_, _`CompanySwitcherProps`_ |
860
861
 
@@ -884,7 +885,8 @@ re-slicing of it, never a second API.
884
885
  | `landing/access` | `accessAction`, `useAccessAction`, _`AccessChoice`_, _`AccessLabels`_, _`AccessLink`_ |
885
886
  | `landing/landing-actions` | `LandingActions`, _`LandingActionsProps`_, _`LandingSession`_ |
886
887
  | `landing/landing-sections` | `CtaBand`, `FeatureRow`, `FeatureRows`, `Hero`, `PublicFooter`, `TrustStrip`, _`CtaBandProps`_, _`FeatureRowProps`_, _`FeatureRowsProps`_, _`HeroProps`_, _`PublicFooterProps`_, _`TrustItem`_, _`TrustStripProps`_ |
887
- | `landing/page-seo` | `metaContent`, `seoCopyProblems`, `usePageSeo`, _`SeoCopy`_, _`SeoCopyLimits`_, _`SeoCopyProblem`_, _`SeoCopyProblemCode`_ |
888
+ | `landing/page-seo` | `metaContent`, `seoCopyProblems`, `usePageSeo`, _`PageSeo`_, _`SeoCopy`_, _`SeoCopyLimits`_, _`SeoCopyProblem`_, _`SeoCopyProblemCode`_ |
889
+ | `landing/public-page` | `PublicPage`, _`PublicPageProps`_ |
888
890
 
889
891
  ### feedback
890
892
 
@@ -930,7 +932,7 @@ re-slicing of it, never a second API.
930
932
 
931
933
  | Module | Exports |
932
934
  |---|---|
933
- | `tour/tour` | `DEFAULT_TOUR_LABELS`, `TourProvider`, `useTour`, `useTourOptional`, _`TourAt`_, _`TourCompletion`_, _`TourLabels`_, _`TourLauncher`_, _`TourPlacement`_, _`TourStart`_, _`TourStep`_, _`TourStepContext`_ |
935
+ | `tour/tour` | `DEFAULT_TOUR_LABELS`, `TourProvider`, `useTour`, `useTourOptional`, _`TourAt`_, _`TourCompletion`_, _`TourEngine`_, _`TourLabels`_, _`TourLauncher`_, _`TourPlacement`_, _`TourStart`_, _`TourStep`_, _`TourStepContext`_ |
934
936
  | `tour/tour-catalogue` | `DEFAULT_TOUR_CATALOGUE_LABELS`, `TourCatalogue`, `TourCatalogueCard`, _`TourCatalogueCardProps`_, _`TourCatalogueLabels`_, _`TourCatalogueProps`_ |
935
937
  | `tour/tour-info` | `doneCount`, `earnedPoints`, `isTourDone`, `isTourNew`, `isTourReset`, _`TourCompletions`_, _`TourGroup`_, _`TourInfo`_, _`TourUpdate`_ |
936
938
  | `tour/use-tour-progress` | `completeTour`, `resetTourProgress`, `signOutTourProgress`, `useTourProgress`, _`TourProgress`_, _`TourProgressAccount`_, _`TourProgressAdopt`_, _`TourProgressBody`_, _`UseTourProgressOptions`_ |
@@ -4,6 +4,7 @@ import { useMemo } from "react";
4
4
  import { cn } from "../lib/cn.js";
5
5
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
6
6
  import { AccountStateChip, RoleChip, dateColumn } from "../components/account-chips.js";
7
+ import { SelectWidthFloor } from "../components/field-base.js";
7
8
  import { encodeSorts } from "../components/data-table-sort.js";
8
9
  import { RowActions } from "../components/row-actions.js";
9
10
  import { UserAvatar } from "../components/user-avatar.js";
@@ -66,6 +67,10 @@ function rolesOf(row, roleOf) {
66
67
  if (raw === null || raw === void 0 || raw === "") return [];
67
68
  return typeof raw === "string" ? [raw] : [...raw];
68
69
  }
70
+ function roleWordsOf(roles, held) {
71
+ const words = Object.keys(roles).map((role) => roles[role].label);
72
+ return held !== void 0 && !(held in roles) ? [...words, held] : words;
73
+ }
69
74
  function RowName({ row, children }) {
70
75
  const locale = useKitLocale();
71
76
  const name = personLabel(
@@ -137,18 +142,32 @@ function userRosterColumns({
137
142
  const editing = held.length <= 1 ? editableRole?.(row) : false;
138
143
  if (editing && onRoleChange) {
139
144
  const rules = editing === true ? {} : editing;
140
- return /* @__PURE__ */ jsx(RowName, { row, children: (name) => /* @__PURE__ */ jsx(
141
- RoleSelect,
142
- {
143
- size: "sm",
144
- name,
145
- value: held[0] ?? null,
146
- roles,
147
- offered: rules.offered,
148
- disabledReason: rules.disabledReason,
149
- optionDisabledReason: rules.optionDisabledReason,
150
- onChange: (role) => onRoleChange(row, role)
151
- }
145
+ return /* @__PURE__ */ jsx(RowName, { row, children: (name) => (
146
+ // The column keeps room for the select's longest ROLE word in the app's
147
+ // language (0.51, Kurvenschmiede N5): beside the `grow` Name every other
148
+ // column is squeezed to its min-content, which a `w-full` select does not
149
+ // have, so the role select was the header's ~92px and read "Meml…",
150
+ // "Admi…". Every word of the vocabulary, not only the row's offered
151
+ // ones: the column is as wide as its widest row, so the rows line up, and
152
+ // a role held but not offered (KS's Reviewer) is still a selected value.
153
+ // Not the options' reasons ("Customer (owns work)"): they are never the
154
+ // selected value, and the open list is as wide as it needs anyway.
155
+ /* @__PURE__ */ jsxs("div", { children: [
156
+ /* @__PURE__ */ jsx(
157
+ RoleSelect,
158
+ {
159
+ size: "sm",
160
+ name,
161
+ value: held[0] ?? null,
162
+ roles,
163
+ offered: rules.offered,
164
+ disabledReason: rules.disabledReason,
165
+ optionDisabledReason: rules.optionDisabledReason,
166
+ onChange: (role) => onRoleChange(row, role)
167
+ }
168
+ ),
169
+ /* @__PURE__ */ jsx(SelectWidthFloor, { size: "sm", values: roleWordsOf(roles, held[0]) })
170
+ ] })
152
171
  ) });
153
172
  }
154
173
  if (held.length === 0) return null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/admin/user-roster.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport type { DateInput } from \"../lib/format\";\nimport type { PersonName } from \"../lib/person-name\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { AccountStateChip, RoleChip, dateColumn } from \"../components/account-chips\";\nimport type { AccountState, RoleVocabulary } from \"../components/account-chips\";\nimport type { ChipTone } from \"../components/chip\";\nimport type { DataTableColumn } from \"../components/data-table\";\nimport { encodeSorts } from \"../components/data-table-sort\";\nimport type { SortState } from \"../components/data-table-sort\";\nimport { RowActions } from \"../components/row-actions\";\nimport type { RowAction, RowActionList } from \"../components/row-actions\";\nimport { UserAvatar } from \"../components/user-avatar\";\nimport { personLabel } from \"./admin-parts\";\nimport { RoleSelect } from \"./role-select\";\nimport type { RoleLock } from \"./role-select\";\nimport { DEFAULT_USER_ROSTER_LABELS } from \"./user-roster-labels\";\n\n/**\n * The admin's user list (docs/user-admin-harmonization.md §3): a column preset for the\n * kit's `DataTable`, not a table of its own.\n *\n * keksdose built its roster by hand (1150 lines, its own Badge), Kurvenschmiede on the\n * kit `DataTable` with the 0.18 chips, kastlan as a settings page. What the three share\n * is the row — name over address, a role, the account's states, created, last login, the\n * app's own columns, and a handful of actions — so that is what the kit draws, and the\n * table, its paging, its sort and its filters stay the app's `DataTable` wiring:\n *\n * ```tsx\n * const columns = useUserRosterColumns<AdminUserRow, Role>({ roles: ROLES, actions, extra });\n * <DataTable rows={page.users} columns={columns} rowKey={(r) => r.id}\n * serverPagination={{ page, pageSize, total: page.total, onPageChange }}\n * sorts={sorts} onSortsChange={setSorts} />\n * // GET /admin/users?sort=${userRosterSort(sorts)}&…\n * ```\n *\n * The column keys ARE the server's sort keys (§3.1): `name`, `email`, `role`, `created`,\n * `last_login` — so {@link userRosterSort} turns the table's sort state into the query\n * as it is. Phone cards come from the same columns: the identity is the card's headline\n * with the account's states as chips beside it, the role, last login and the actions are\n * its three fields, created is left off.\n */\n\n/* ── Rows and states ────────────────────────────────────────────────────────── */\n\n/**\n * The fields of the contract's `AdminUserRow` the preset reads (§3.2, the auth\n * contract's `UserResponse` core). An app's row has more — its `extra` — and the\n * preset passes the whole row to every callback.\n */\nexport interface UserRosterRow extends AdminUserStateFields {\n id: string | number;\n email: string;\n first_name?: string | null;\n last_name?: string | null;\n /** The derived whole name — only the fallback when both parts are blank. */\n display_name?: string | null;\n /** keksdose, Kurvenschmiede: one role. */\n role?: string | null;\n /** kastlan: the account's roles in the current company. Wins over `role`. */\n roles?: readonly string[] | null;\n created_at?: DateInput;\n /** Account-wide; kastlan's comes from its session rows (§3.2). */\n last_login_at?: DateInput;\n}\n\n/** The state fields of the contract's row that {@link adminUserStates} maps (§3.2). */\nexport interface AdminUserStateFields {\n is_active?: boolean;\n email_verified?: boolean;\n password_change_required_at?: string | null;\n /** `after_days` mode: the day the account is erased (§6.4). */\n deletion_scheduled_at?: string | null;\n /** When the user asked. The only deletion field in kastlan's `operator` mode, where\n * nothing is scheduled. */\n deletion_requested_at?: string | null;\n}\n\n/** One state chip of a row: an {@link AccountState}, or one with its `date` (the\n * `deletion` day), a `tone` or the app's own words. */\nexport type RosterState =\n | AccountState\n | { state: AccountState; date?: DateInput; tone?: ChipTone; label?: ReactNode };\n\n/**\n * The contract's row as state chips (§3.2: \"map 1:1 to the kit's AccountStateChip\"):\n *\n * - marked for deletion → `deletion` with the scheduled day (none in `operator` mode),\n * IN PLACE of `inactive` — such an account is always deactivated (§2.1);\n * - else `active` / `inactive` from `is_active`;\n * - `unverified` when `email_verified` is `false` (not when it is missing);\n * - `passwordChange` while `password_change_required_at` is set.\n */\nexport function adminUserStates(row: AdminUserStateFields): RosterState[] {\n const out: RosterState[] = [];\n if (row.deletion_scheduled_at || row.deletion_requested_at) {\n out.push({ state: \"deletion\", date: row.deletion_scheduled_at ?? null });\n } else if (row.is_active !== undefined) {\n out.push(row.is_active ? \"active\" : \"inactive\");\n }\n if (row.email_verified === false) out.push(\"unverified\");\n if (row.password_change_required_at) out.push(\"passwordChange\");\n return out;\n}\n\n/** The server's sort keys, which are the preset's column keys (§3.1). */\nexport const USER_ROSTER_SORT_KEYS = [\"name\", \"email\", \"role\", \"created\", \"last_login\"] as const;\nexport type UserRosterSortKey = (typeof USER_ROSTER_SORT_KEYS)[number];\n\n/**\n * The table's sort state as the `sort` query parameter of `GET /admin/users` (§3.1), in\n * the DataTable's own encoding ({@link encodeSorts}: `\"last_login.desc\"`), which\n * server-kit 0.4 reads beside the `-key` form. Only the first criterion on one of the\n * server's keys: an app's own column (`owned`) is the client's to sort, and the query\n * takes one key — a shift-clicked second criterion is not sent. `undefined` when nothing\n * the server knows is sorted, so its default order applies.\n */\nexport function userRosterSort(sorts: readonly SortState[]): string | undefined {\n const first = sorts.find((s) => (USER_ROSTER_SORT_KEYS as readonly string[]).includes(s.key));\n return first ? (encodeSorts([first]) ?? undefined) : undefined;\n}\n\n/* ── Labels ─────────────────────────────────────────────────────────────────── */\n\n/** The `userRoster` namespace: the preset's headers and the action menu's words. */\nexport interface UserRosterLabels {\n /** The identity column — the name with the address under it. */\n name: string;\n /** The separate address column (`emailColumn`). */\n email: string;\n role: string;\n state: string;\n created: string;\n lastLogin: string;\n /** A last login that never happened. */\n never: string;\n /** The actions column's (hidden) header. */\n actions: string;\n /** The action menu's button and panel — \"Actions for Ada Example\". */\n actionsFor: (name: string) => string;\n}\n\n// Defined in ./user-roster-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_USER_ROSTER_LABELS };\n\n/* ── UserIdentityCell ───────────────────────────────────────────────────────── */\n\nexport interface UserIdentityCellProps {\n /** First and last name (auth §3.2), shown in the READER's order. */\n person?: PersonName | null;\n /** A whole name, for an API that still sends `display_name` only. */\n name?: string | null;\n email?: string | null;\n /** The initials avatar before the name. Default true. */\n avatar?: boolean;\n /** After the name, on its line — a \"You\" chip, a demo mark. */\n badge?: ReactNode;\n /** Over the provider's locale. */\n locale?: string;\n className?: string;\n}\n\n/**\n * Who an account is, in one cell (§3.3): the initials avatar, the name in the reader's\n * order ({@link formatPersonName} — \"Example Ada\" in Hungarian) and the address under it\n * — the cell both rosters drew by hand. An account with no name yet (a migrated row\n * before `CompleteNameDialog`, an invitee) shows its address once, as the name.\n *\n * Both lines carry `data-private`, so keksdose's demo blur reaches them, and both end\n * in \"…\" at Normal only: from Large up they wrap (`truncate-until-large`, 0.33,\n * docs/text-size-harmonization.md §10.17), since a cut name or address is a finding there.\n */\nexport function UserIdentityCell({\n person,\n name,\n email,\n avatar = true,\n badge,\n locale: localeProp,\n className,\n}: UserIdentityCellProps) {\n const locale = useKitLocale(localeProp);\n const named = personLabel({ first: person?.first, last: person?.last, name }, locale);\n const address = (email ?? \"\").trim();\n return (\n <div className={cn(\"flex min-w-0 items-center gap-2.5\", className)}>\n {avatar && <UserAvatar person={person} name={name} email={email} size=\"sm\" />}\n <div className=\"min-w-0\">\n <div className=\"flex min-w-0 items-center gap-1.5\">\n <span data-private className=\"truncate-until-large font-medium text-primary\">\n {named || address}\n </span>\n {badge}\n </div>\n {named && address && (\n <div data-private className=\"truncate-until-large text-xs text-muted\">\n {address}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n/* ── The row's actions ──────────────────────────────────────────────────────── */\n\n/** One entry of a row's action menu (§4) — the kit's {@link RowAction} since 0.32,\n * which generalised it for every dense row (docs/text-size-harmonization.md §10.8). */\nexport type UserRowAction = RowAction;\n\n/** What `actions(row)` may return: entries, and `false` / `null` for the ones a\n * condition left out (`row.is_active && {…}`). */\nexport type UserRowActionList = RowActionList;\n\nexport interface UserRowActionsProps {\n actions: UserRowActionList;\n /** The account's name, for the menu's name — \"Actions for Ada Example\". */\n name: string;\n labels?: LabelOverride<UserRosterLabels>;\n}\n\n/**\n * A row's actions as one \"…\" button and a menu of them: keksdose had a strip of icon\n * buttons in the expanded row, Kurvenschmiede five text buttons in a cell — neither fits\n * a phone card, and both grow with every action §2.4 adds. The menu is portalled, so a\n * table's scroller cannot clip it; Escape and an outside press close it, and focus goes\n * back to the button, which is where a confirmation dialog then returns it.\n *\n * Since 0.32 this is {@link RowActions} with `collapse=\"menu\"` — the menu at every size —\n * named by the roster's own `actionsFor`.\n */\nexport function UserRowActions({ actions, name, labels: labelsProp }: UserRowActionsProps) {\n const labels = useKitLabels(\"userRoster\", DEFAULT_USER_ROSTER_LABELS, labelsProp);\n return (\n <RowActions\n actions={actions}\n name={name}\n collapse=\"menu\"\n labels={{ actionsFor: labels.actionsFor }}\n />\n );\n}\n\n/* ── The preset ─────────────────────────────────────────────────────────────── */\n\n/** How a row's role may be changed inline: `true`, or with reasons. */\nexport interface RoleEditing<R extends string> {\n /** Why the whole select is locked for this row — `\"self\"`, `\"last_admin\"`, or words. */\n disabledReason?: RoleLock;\n /** Why one role cannot be chosen for this row (a string: it goes inside an option). */\n optionDisabledReason?: (role: R) => string | undefined;\n /** The roles that may be chosen. Default: the whole vocabulary. */\n offered?: readonly R[];\n}\n\nexport interface UserRosterColumnsOptions<T extends UserRosterRow, R extends string = string> {\n /** The app's roles, as `RoleChip` reads them. */\n roles: RoleVocabulary<R>;\n /** The row's state chips. Default {@link adminUserStates}, which reads the contract's\n * fields (`is_active`, `email_verified`, `password_change_required_at`,\n * `deletion_scheduled_at`, `deletion_requested_at`). */\n stateOf?: (row: T) => readonly RosterState[];\n /** The app's own columns — keksdose's plan and key custody, Kurvenschmiede's owned\n * counts — after the dates, before the actions. */\n extra?: readonly DataTableColumn<T>[];\n /** The row's action menu (§4). Left out: no actions column. */\n actions?: (row: T) => UserRowActionList;\n /** The actions column's heading (0.46.1, keksdose dev#598 rework — a VISIBLE \"Actions\"\n * on every table with an actions column). Default: `labels.actions`, shown. A node\n * replaces it; `false` keeps the pre-0.46.1 heading, a screen reader's name only. */\n actionsHeader?: ReactNode | false;\n /**\n * Whether this row's role is a select rather than a chip, and with which reasons.\n * Left out, or `false`: a chip. kastlan's several roles are never a select here — they\n * are a \"Manage roles\" action that opens `RolesEditor`.\n */\n editableRole?: (row: T) => boolean | RoleEditing<R>;\n /** A role was picked in a row's select. The app confirms (`acknowledge`) and sends it. */\n onRoleChange?: (row: T, role: R) => void;\n /** The row's role(s). Default `row.roles ?? row.role`. */\n roleOf?: (row: T) => R | readonly R[] | null | undefined;\n /** A separate, sortable \"Email\" column (the server's `email` sort key) beside the\n * identity. Default false: the address is under the name already. */\n emailColumn?: boolean;\n /** The words, already resolved. Default English; {@link useUserRosterColumns} reads\n * the provider's `userRoster` namespace. */\n labels?: LabelOverride<UserRosterLabels>;\n}\n\nfunction rolesOf<T extends UserRosterRow, R extends string>(\n row: T,\n roleOf: UserRosterColumnsOptions<T, R>[\"roleOf\"],\n): R[] {\n const raw = roleOf ? roleOf(row) : ((row.roles ?? row.role) as R | readonly R[] | null | undefined);\n if (raw === null || raw === undefined || raw === \"\") return [];\n return typeof raw === \"string\" ? [raw as R] : [...(raw as readonly R[])];\n}\n\n/** The row's name in the reader's order — a component, because the order is the\n * READER's and the locale comes from the provider. */\nfunction RowName<T extends UserRosterRow>({ row, children }: { row: T; children: (name: string) => ReactNode }) {\n const locale = useKitLocale();\n const name = personLabel(\n { first: row.first_name, last: row.last_name, name: row.display_name, email: row.email },\n locale,\n );\n return <>{children(name)}</>;\n}\n\nfunction StateChips({ states }: { states: readonly RosterState[] }) {\n if (states.length === 0) return null;\n return (\n <div className=\"flex flex-wrap gap-1\">\n {states.map((entry) => {\n const spec = typeof entry === \"string\" ? { state: entry } : entry;\n return (\n <AccountStateChip key={spec.state} state={spec.state} date={spec.date} tone={spec.tone} size=\"xs\">\n {spec.label}\n </AccountStateChip>\n );\n })}\n </div>\n );\n}\n\n/**\n * The roster's columns, in the contract's order (§3.3):\n *\n * | key | cell | sort | phone | fit (0.50) |\n * |---|---|---|---|---|\n * | `name` | {@link UserIdentityCell} | last, first | the card's headline | `grow` |\n * | `email` | the address (`emailColumn` only) | address | hidden | — |\n * | `role` | `RoleChip`(s), or {@link RoleSelect} where `editableRole` | role | field | — |\n * | `state` | `AccountStateChip`s from `stateOf` | — | chips beside the headline | — |\n * | `created` | `dateColumn` | instant | hidden | `fitPriority: 1` |\n * | `last_login` | `dateColumn`, relative, \"Never\" | instant | field | `fitPriority: 2` |\n * | …`extra` | the app's | the app's | off the card unless marked `mobile: \"secondary\"` | the app's |\n * | `actions` | {@link UserRowActions} | — | the card's actions row (`role: \"actions\"`) | — |\n *\n * The fit (0.50, kastlan K2): Name takes the rest of the row, and a table narrower than\n * its columns leaves out Created, then Last login (DataTable's `grow` / `fitPriority`).\n * The role and state chips still wrap in their cells (0.48), and the other cells keep one\n * line at Normal. `{ ...col, grow: undefined, fitPriority: undefined }` restores the 0.49\n * layout; an `extra` column takes a `fitPriority` of its own to leave too.\n *\n * The card holds three fields — `role`, `last_login` and one more: an `extra` column marked\n * `mobile: \"secondary\"` takes that place. The actions are `role: \"actions\"` (0.49), drawn\n * after the fields and never one of the three. Drop them from the card with\n * `mobile: \"hidden\"`, or draw a `mobileCard`.\n *\n * Pure: hand it resolved words, or use {@link useUserRosterColumns}. The keys are stable,\n * so an app may drop or move a column (`columns.filter((c) => c.key !== \"created\")`).\n * Under `serverPagination` the `sortBy`s only make the headers clickable; the app sends\n * the sort ({@link userRosterSort}).\n */\nexport function userRosterColumns<T extends UserRosterRow, R extends string = string>({\n roles,\n stateOf = adminUserStates,\n extra = [],\n actions,\n actionsHeader,\n editableRole,\n onRoleChange,\n roleOf,\n emailColumn = false,\n labels: labelsProp,\n}: UserRosterColumnsOptions<T, R>): DataTableColumn<T>[] {\n const labels: UserRosterLabels = { ...DEFAULT_USER_ROSTER_LABELS, ...labelsProp };\n const roleWords = (row: T) =>\n rolesOf(row, roleOf)\n .map((role) => roles[role]?.label ?? role)\n .join(\", \");\n const columns: DataTableColumn<T>[] = [\n {\n key: \"name\",\n header: labels.name,\n cell: (row) => (\n <UserIdentityCell\n person={{ first: row.first_name, last: row.last_name }}\n name={row.display_name}\n email={row.email}\n />\n ),\n // By last name, then first (auth §3.2), as the server sorts `name`.\n sortBy: (row) => `${row.last_name ?? \"\"} ${row.first_name ?? \"\"}`.trim().toLocaleLowerCase() || row.email,\n // Either part, in either order, or the address (auth §10.6).\n filterBy: (row) =>\n [row.first_name, row.last_name, row.last_name, row.first_name, row.display_name, row.email]\n .filter(Boolean)\n .join(\" \"),\n mobilePrimary: true,\n // Takes the rest of the row and ellipsises (0.50, kastlan K2): the roster fits its\n // pane — a settings column, an admin page — instead of scrolling sideways.\n grow: true,\n },\n ];\n if (emailColumn) {\n columns.push({\n key: \"email\",\n header: labels.email,\n className: \"whitespace-nowrap\",\n cell: (row) => (\n <span data-private className=\"text-secondary\">\n {row.email}\n </span>\n ),\n sortBy: (row) => row.email.toLowerCase(),\n filterBy: (row) => row.email,\n mobileHidden: true,\n });\n }\n columns.push(\n {\n key: \"role\",\n header: labels.role,\n cell: (row) => {\n const held = rolesOf(row, roleOf);\n const editing = held.length <= 1 ? editableRole?.(row) : false;\n if (editing && onRoleChange) {\n const rules: RoleEditing<R> = editing === true ? {} : editing;\n return (\n <RowName row={row}>\n {(name) => (\n <RoleSelect<R>\n size=\"sm\"\n name={name}\n value={held[0] ?? null}\n roles={roles}\n offered={rules.offered}\n disabledReason={rules.disabledReason}\n optionDisabledReason={rules.optionDisabledReason}\n onChange={(role) => onRoleChange(row, role)}\n />\n )}\n </RowName>\n );\n }\n if (held.length === 0) return null;\n return (\n <div className=\"flex flex-wrap gap-1\">\n {held.map((role) => (\n <RoleChip<R> key={role} value={role} roles={roles} size=\"xs\" />\n ))}\n </div>\n );\n },\n sortBy: roleWords,\n filter: {\n type: \"select\",\n getValue: (row) => rolesOf(row, roleOf)[0] ?? \"\",\n options: (Object.keys(roles) as R[]).map((value) => ({ value, label: roles[value].label })),\n },\n mobile: \"secondary\",\n },\n {\n key: \"state\",\n header: labels.state,\n // No states, no cell: the phone card then shows no chip beside the name.\n cell: (row) => {\n const states = stateOf(row);\n return states.length === 0 ? null : <StateChips states={states} />;\n },\n // Beside the name on the phone card, so the role, last login and the actions keep\n // the card's three fields.\n role: \"status\",\n },\n dateColumn<T>({\n key: \"created\",\n header: labels.created,\n value: (row) => row.created_at ?? null,\n // The first to leave a narrow table (0.50, kastlan K2), then last login.\n column: { mobileHidden: true, fitPriority: 1 },\n }),\n // How long ago rather than a date — the question is \"recently?\" — with the exact\n // time beside it (keksdose's last-seen column, Kurvenschmiede's; 0.32 §4: never only\n // in a tooltip).\n dateColumn<T>({\n key: \"last_login\",\n header: labels.lastLogin,\n value: (row) => row.last_login_at ?? null,\n display: \"relative\",\n empty: labels.never,\n column: { mobile: \"secondary\", fitPriority: 2 },\n }),\n ...extra,\n );\n if (actions) {\n columns.push({\n key: \"actions\",\n // On screen, as every actions column's heading is (0.46.1, keksdose dev#598 rework).\n header:\n actionsHeader === false ? <span className=\"sr-only\">{labels.actions}</span> : (actionsHeader ?? labels.actions),\n headerText: labels.actions,\n // The caller's own words only: the menu reads the provider's itself, and the\n // English defaults merged in above must not shadow them.\n cell: (row) => (\n <RowName row={row}>\n {(name) => <UserRowActions actions={actions(row)} name={name} labels={labelsProp} />}\n </RowName>\n ),\n className: \"w-px whitespace-nowrap text-end\",\n headClassName: \"w-px text-end\",\n // The menu button keeps its own click: a row link must not wrap it.\n noRowLink: true,\n // Always on the phone card, after the fields and never one of the three (0.49).\n role: \"actions\",\n });\n }\n return columns;\n}\n\n/**\n * {@link userRosterColumns} with the `userRoster` words from the provider, memoised on\n * the options — pass stable callbacks (`useCallback`) and a module-level vocabulary, or\n * the columns are rebuilt on every render.\n */\nexport function useUserRosterColumns<T extends UserRosterRow, R extends string = string>({\n roles,\n stateOf,\n extra,\n actions,\n actionsHeader,\n editableRole,\n onRoleChange,\n roleOf,\n emailColumn,\n labels: labelsProp,\n}: UserRosterColumnsOptions<T, R>): DataTableColumn<T>[] {\n const labels = useKitLabels(\"userRoster\", DEFAULT_USER_ROSTER_LABELS, labelsProp);\n return useMemo(\n () =>\n userRosterColumns<T, R>({\n roles,\n stateOf,\n extra,\n actions,\n actionsHeader,\n editableRole,\n onRoleChange,\n roleOf,\n emailColumn,\n labels,\n }),\n [roles, stateOf, extra, actions, actionsHeader, editableRole, onRoleChange, roleOf, emailColumn, labels],\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/admin/user-roster.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport type { DateInput } from \"../lib/format\";\nimport type { PersonName } from \"../lib/person-name\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { AccountStateChip, RoleChip, dateColumn } from \"../components/account-chips\";\nimport type { AccountState, RoleVocabulary } from \"../components/account-chips\";\nimport type { ChipTone } from \"../components/chip\";\nimport type { DataTableColumn } from \"../components/data-table\";\nimport { SelectWidthFloor } from \"../components/field-base\";\nimport { encodeSorts } from \"../components/data-table-sort\";\nimport type { SortState } from \"../components/data-table-sort\";\nimport { RowActions } from \"../components/row-actions\";\nimport type { RowAction, RowActionList } from \"../components/row-actions\";\nimport { UserAvatar } from \"../components/user-avatar\";\nimport { personLabel } from \"./admin-parts\";\nimport { RoleSelect } from \"./role-select\";\nimport type { RoleLock } from \"./role-select\";\nimport { DEFAULT_USER_ROSTER_LABELS } from \"./user-roster-labels\";\n\n/**\n * The admin's user list (docs/user-admin-harmonization.md §3): a column preset for the\n * kit's `DataTable`, not a table of its own.\n *\n * keksdose built its roster by hand (1150 lines, its own Badge), Kurvenschmiede on the\n * kit `DataTable` with the 0.18 chips, kastlan as a settings page. What the three share\n * is the row — name over address, a role, the account's states, created, last login, the\n * app's own columns, and a handful of actions — so that is what the kit draws, and the\n * table, its paging, its sort and its filters stay the app's `DataTable` wiring:\n *\n * ```tsx\n * const columns = useUserRosterColumns<AdminUserRow, Role>({ roles: ROLES, actions, extra });\n * <DataTable rows={page.users} columns={columns} rowKey={(r) => r.id}\n * serverPagination={{ page, pageSize, total: page.total, onPageChange }}\n * sorts={sorts} onSortsChange={setSorts} />\n * // GET /admin/users?sort=${userRosterSort(sorts)}&…\n * ```\n *\n * The column keys ARE the server's sort keys (§3.1): `name`, `email`, `role`, `created`,\n * `last_login` — so {@link userRosterSort} turns the table's sort state into the query\n * as it is. Phone cards come from the same columns: the identity is the card's headline\n * with the account's states as chips beside it, the role, last login and the actions are\n * its three fields, created is left off.\n */\n\n/* ── Rows and states ────────────────────────────────────────────────────────── */\n\n/**\n * The fields of the contract's `AdminUserRow` the preset reads (§3.2, the auth\n * contract's `UserResponse` core). An app's row has more — its `extra` — and the\n * preset passes the whole row to every callback.\n */\nexport interface UserRosterRow extends AdminUserStateFields {\n id: string | number;\n email: string;\n first_name?: string | null;\n last_name?: string | null;\n /** The derived whole name — only the fallback when both parts are blank. */\n display_name?: string | null;\n /** keksdose, Kurvenschmiede: one role. */\n role?: string | null;\n /** kastlan: the account's roles in the current company. Wins over `role`. */\n roles?: readonly string[] | null;\n created_at?: DateInput;\n /** Account-wide; kastlan's comes from its session rows (§3.2). */\n last_login_at?: DateInput;\n}\n\n/** The state fields of the contract's row that {@link adminUserStates} maps (§3.2). */\nexport interface AdminUserStateFields {\n is_active?: boolean;\n email_verified?: boolean;\n password_change_required_at?: string | null;\n /** `after_days` mode: the day the account is erased (§6.4). */\n deletion_scheduled_at?: string | null;\n /** When the user asked. The only deletion field in kastlan's `operator` mode, where\n * nothing is scheduled. */\n deletion_requested_at?: string | null;\n}\n\n/** One state chip of a row: an {@link AccountState}, or one with its `date` (the\n * `deletion` day), a `tone` or the app's own words. */\nexport type RosterState =\n | AccountState\n | { state: AccountState; date?: DateInput; tone?: ChipTone; label?: ReactNode };\n\n/**\n * The contract's row as state chips (§3.2: \"map 1:1 to the kit's AccountStateChip\"):\n *\n * - marked for deletion → `deletion` with the scheduled day (none in `operator` mode),\n * IN PLACE of `inactive` — such an account is always deactivated (§2.1);\n * - else `active` / `inactive` from `is_active`;\n * - `unverified` when `email_verified` is `false` (not when it is missing);\n * - `passwordChange` while `password_change_required_at` is set.\n */\nexport function adminUserStates(row: AdminUserStateFields): RosterState[] {\n const out: RosterState[] = [];\n if (row.deletion_scheduled_at || row.deletion_requested_at) {\n out.push({ state: \"deletion\", date: row.deletion_scheduled_at ?? null });\n } else if (row.is_active !== undefined) {\n out.push(row.is_active ? \"active\" : \"inactive\");\n }\n if (row.email_verified === false) out.push(\"unverified\");\n if (row.password_change_required_at) out.push(\"passwordChange\");\n return out;\n}\n\n/** The server's sort keys, which are the preset's column keys (§3.1). */\nexport const USER_ROSTER_SORT_KEYS = [\"name\", \"email\", \"role\", \"created\", \"last_login\"] as const;\nexport type UserRosterSortKey = (typeof USER_ROSTER_SORT_KEYS)[number];\n\n/**\n * The table's sort state as the `sort` query parameter of `GET /admin/users` (§3.1), in\n * the DataTable's own encoding ({@link encodeSorts}: `\"last_login.desc\"`), which\n * server-kit 0.4 reads beside the `-key` form. Only the first criterion on one of the\n * server's keys: an app's own column (`owned`) is the client's to sort, and the query\n * takes one key — a shift-clicked second criterion is not sent. `undefined` when nothing\n * the server knows is sorted, so its default order applies.\n */\nexport function userRosterSort(sorts: readonly SortState[]): string | undefined {\n const first = sorts.find((s) => (USER_ROSTER_SORT_KEYS as readonly string[]).includes(s.key));\n return first ? (encodeSorts([first]) ?? undefined) : undefined;\n}\n\n/* ── Labels ─────────────────────────────────────────────────────────────────── */\n\n/** The `userRoster` namespace: the preset's headers and the action menu's words. */\nexport interface UserRosterLabels {\n /** The identity column — the name with the address under it. */\n name: string;\n /** The separate address column (`emailColumn`). */\n email: string;\n role: string;\n state: string;\n created: string;\n lastLogin: string;\n /** A last login that never happened. */\n never: string;\n /** The actions column's (hidden) header. */\n actions: string;\n /** The action menu's button and panel — \"Actions for Ada Example\". */\n actionsFor: (name: string) => string;\n}\n\n// Defined in ./user-roster-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_USER_ROSTER_LABELS };\n\n/* ── UserIdentityCell ───────────────────────────────────────────────────────── */\n\nexport interface UserIdentityCellProps {\n /** First and last name (auth §3.2), shown in the READER's order. */\n person?: PersonName | null;\n /** A whole name, for an API that still sends `display_name` only. */\n name?: string | null;\n email?: string | null;\n /** The initials avatar before the name. Default true. */\n avatar?: boolean;\n /** After the name, on its line — a \"You\" chip, a demo mark. */\n badge?: ReactNode;\n /** Over the provider's locale. */\n locale?: string;\n className?: string;\n}\n\n/**\n * Who an account is, in one cell (§3.3): the initials avatar, the name in the reader's\n * order ({@link formatPersonName} — \"Example Ada\" in Hungarian) and the address under it\n * — the cell both rosters drew by hand. An account with no name yet (a migrated row\n * before `CompleteNameDialog`, an invitee) shows its address once, as the name.\n *\n * Both lines carry `data-private`, so keksdose's demo blur reaches them, and both end\n * in \"…\" at Normal only: from Large up they wrap (`truncate-until-large`, 0.33,\n * docs/text-size-harmonization.md §10.17), since a cut name or address is a finding there.\n */\nexport function UserIdentityCell({\n person,\n name,\n email,\n avatar = true,\n badge,\n locale: localeProp,\n className,\n}: UserIdentityCellProps) {\n const locale = useKitLocale(localeProp);\n const named = personLabel({ first: person?.first, last: person?.last, name }, locale);\n const address = (email ?? \"\").trim();\n return (\n <div className={cn(\"flex min-w-0 items-center gap-2.5\", className)}>\n {avatar && <UserAvatar person={person} name={name} email={email} size=\"sm\" />}\n <div className=\"min-w-0\">\n <div className=\"flex min-w-0 items-center gap-1.5\">\n <span data-private className=\"truncate-until-large font-medium text-primary\">\n {named || address}\n </span>\n {badge}\n </div>\n {named && address && (\n <div data-private className=\"truncate-until-large text-xs text-muted\">\n {address}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n/* ── The row's actions ──────────────────────────────────────────────────────── */\n\n/** One entry of a row's action menu (§4) — the kit's {@link RowAction} since 0.32,\n * which generalised it for every dense row (docs/text-size-harmonization.md §10.8). */\nexport type UserRowAction = RowAction;\n\n/** What `actions(row)` may return: entries, and `false` / `null` for the ones a\n * condition left out (`row.is_active && {…}`). */\nexport type UserRowActionList = RowActionList;\n\nexport interface UserRowActionsProps {\n actions: UserRowActionList;\n /** The account's name, for the menu's name — \"Actions for Ada Example\". */\n name: string;\n labels?: LabelOverride<UserRosterLabels>;\n}\n\n/**\n * A row's actions as one \"…\" button and a menu of them: keksdose had a strip of icon\n * buttons in the expanded row, Kurvenschmiede five text buttons in a cell — neither fits\n * a phone card, and both grow with every action §2.4 adds. The menu is portalled, so a\n * table's scroller cannot clip it; Escape and an outside press close it, and focus goes\n * back to the button, which is where a confirmation dialog then returns it.\n *\n * Since 0.32 this is {@link RowActions} with `collapse=\"menu\"` — the menu at every size —\n * named by the roster's own `actionsFor`.\n */\nexport function UserRowActions({ actions, name, labels: labelsProp }: UserRowActionsProps) {\n const labels = useKitLabels(\"userRoster\", DEFAULT_USER_ROSTER_LABELS, labelsProp);\n return (\n <RowActions\n actions={actions}\n name={name}\n collapse=\"menu\"\n labels={{ actionsFor: labels.actionsFor }}\n />\n );\n}\n\n/* ── The preset ─────────────────────────────────────────────────────────────── */\n\n/** How a row's role may be changed inline: `true`, or with reasons. */\nexport interface RoleEditing<R extends string> {\n /** Why the whole select is locked for this row — `\"self\"`, `\"last_admin\"`, or words. */\n disabledReason?: RoleLock;\n /** Why one role cannot be chosen for this row (a string: it goes inside an option). */\n optionDisabledReason?: (role: R) => string | undefined;\n /** The roles that may be chosen. Default: the whole vocabulary. */\n offered?: readonly R[];\n}\n\nexport interface UserRosterColumnsOptions<T extends UserRosterRow, R extends string = string> {\n /** The app's roles, as `RoleChip` reads them. */\n roles: RoleVocabulary<R>;\n /** The row's state chips. Default {@link adminUserStates}, which reads the contract's\n * fields (`is_active`, `email_verified`, `password_change_required_at`,\n * `deletion_scheduled_at`, `deletion_requested_at`). */\n stateOf?: (row: T) => readonly RosterState[];\n /** The app's own columns — keksdose's plan and key custody, Kurvenschmiede's owned\n * counts — after the dates, before the actions. */\n extra?: readonly DataTableColumn<T>[];\n /** The row's action menu (§4). Left out: no actions column. */\n actions?: (row: T) => UserRowActionList;\n /** The actions column's heading (0.46.1, keksdose dev#598 rework — a VISIBLE \"Actions\"\n * on every table with an actions column). Default: `labels.actions`, shown. A node\n * replaces it; `false` keeps the pre-0.46.1 heading, a screen reader's name only. */\n actionsHeader?: ReactNode | false;\n /**\n * Whether this row's role is a select rather than a chip, and with which reasons.\n * Left out, or `false`: a chip. kastlan's several roles are never a select here — they\n * are a \"Manage roles\" action that opens `RolesEditor`.\n */\n editableRole?: (row: T) => boolean | RoleEditing<R>;\n /** A role was picked in a row's select. The app confirms (`acknowledge`) and sends it. */\n onRoleChange?: (row: T, role: R) => void;\n /** The row's role(s). Default `row.roles ?? row.role`. */\n roleOf?: (row: T) => R | readonly R[] | null | undefined;\n /** A separate, sortable \"Email\" column (the server's `email` sort key) beside the\n * identity. Default false: the address is under the name already. */\n emailColumn?: boolean;\n /** The words, already resolved. Default English; {@link useUserRosterColumns} reads\n * the provider's `userRoster` namespace. */\n labels?: LabelOverride<UserRosterLabels>;\n}\n\nfunction rolesOf<T extends UserRosterRow, R extends string>(\n row: T,\n roleOf: UserRosterColumnsOptions<T, R>[\"roleOf\"],\n): R[] {\n const raw = roleOf ? roleOf(row) : ((row.roles ?? row.role) as R | readonly R[] | null | undefined);\n if (raw === null || raw === undefined || raw === \"\") return [];\n return typeof raw === \"string\" ? [raw as R] : [...(raw as readonly R[])];\n}\n\n/** What a row's role select can show as its value: every role's word, and a held key\n * the vocabulary does not know (RoleSelect shows it as the key). */\nfunction roleWordsOf<R extends string>(roles: RoleVocabulary<R>, held: R | undefined): string[] {\n const words = (Object.keys(roles) as R[]).map((role) => roles[role].label);\n return held !== undefined && !(held in roles) ? [...words, held] : words;\n}\n\n/** The row's name in the reader's order — a component, because the order is the\n * READER's and the locale comes from the provider. */\nfunction RowName<T extends UserRosterRow>({ row, children }: { row: T; children: (name: string) => ReactNode }) {\n const locale = useKitLocale();\n const name = personLabel(\n { first: row.first_name, last: row.last_name, name: row.display_name, email: row.email },\n locale,\n );\n return <>{children(name)}</>;\n}\n\nfunction StateChips({ states }: { states: readonly RosterState[] }) {\n if (states.length === 0) return null;\n return (\n <div className=\"flex flex-wrap gap-1\">\n {states.map((entry) => {\n const spec = typeof entry === \"string\" ? { state: entry } : entry;\n return (\n <AccountStateChip key={spec.state} state={spec.state} date={spec.date} tone={spec.tone} size=\"xs\">\n {spec.label}\n </AccountStateChip>\n );\n })}\n </div>\n );\n}\n\n/**\n * The roster's columns, in the contract's order (§3.3):\n *\n * | key | cell | sort | phone | fit (0.50) |\n * |---|---|---|---|---|\n * | `name` | {@link UserIdentityCell} | last, first | the card's headline | `grow` |\n * | `email` | the address (`emailColumn` only) | address | hidden | — |\n * | `role` | `RoleChip`(s), or {@link RoleSelect} where `editableRole` | role | field | — |\n * | `state` | `AccountStateChip`s from `stateOf` | — | chips beside the headline | — |\n * | `created` | `dateColumn` | instant | hidden | `fitPriority: 1` |\n * | `last_login` | `dateColumn`, relative, \"Never\" | instant | field | `fitPriority: 2` |\n * | …`extra` | the app's | the app's | off the card unless marked `mobile: \"secondary\"` | the app's |\n * | `actions` | {@link UserRowActions} | — | the card's actions row (`role: \"actions\"`) | — |\n *\n * The fit (0.50, kastlan K2): Name takes the rest of the row, and a table narrower than\n * its columns leaves out Created, then Last login (DataTable's `grow` / `fitPriority`).\n * The role and state chips still wrap in their cells (0.48), and the other cells keep one\n * line at Normal. `{ ...col, grow: undefined, fitPriority: undefined }` restores the 0.49\n * layout; an `extra` column takes a `fitPriority` of its own to leave too.\n *\n * The card holds three fields — `role`, `last_login` and one more: an `extra` column marked\n * `mobile: \"secondary\"` takes that place. The actions are `role: \"actions\"` (0.49), drawn\n * after the fields and never one of the three. Drop them from the card with\n * `mobile: \"hidden\"`, or draw a `mobileCard`.\n *\n * Pure: hand it resolved words, or use {@link useUserRosterColumns}. The keys are stable,\n * so an app may drop or move a column (`columns.filter((c) => c.key !== \"created\")`).\n * Under `serverPagination` the `sortBy`s only make the headers clickable; the app sends\n * the sort ({@link userRosterSort}).\n */\nexport function userRosterColumns<T extends UserRosterRow, R extends string = string>({\n roles,\n stateOf = adminUserStates,\n extra = [],\n actions,\n actionsHeader,\n editableRole,\n onRoleChange,\n roleOf,\n emailColumn = false,\n labels: labelsProp,\n}: UserRosterColumnsOptions<T, R>): DataTableColumn<T>[] {\n const labels: UserRosterLabels = { ...DEFAULT_USER_ROSTER_LABELS, ...labelsProp };\n const roleWords = (row: T) =>\n rolesOf(row, roleOf)\n .map((role) => roles[role]?.label ?? role)\n .join(\", \");\n const columns: DataTableColumn<T>[] = [\n {\n key: \"name\",\n header: labels.name,\n cell: (row) => (\n <UserIdentityCell\n person={{ first: row.first_name, last: row.last_name }}\n name={row.display_name}\n email={row.email}\n />\n ),\n // By last name, then first (auth §3.2), as the server sorts `name`.\n sortBy: (row) => `${row.last_name ?? \"\"} ${row.first_name ?? \"\"}`.trim().toLocaleLowerCase() || row.email,\n // Either part, in either order, or the address (auth §10.6).\n filterBy: (row) =>\n [row.first_name, row.last_name, row.last_name, row.first_name, row.display_name, row.email]\n .filter(Boolean)\n .join(\" \"),\n mobilePrimary: true,\n // Takes the rest of the row and ellipsises (0.50, kastlan K2): the roster fits its\n // pane — a settings column, an admin page — instead of scrolling sideways.\n grow: true,\n },\n ];\n if (emailColumn) {\n columns.push({\n key: \"email\",\n header: labels.email,\n className: \"whitespace-nowrap\",\n cell: (row) => (\n <span data-private className=\"text-secondary\">\n {row.email}\n </span>\n ),\n sortBy: (row) => row.email.toLowerCase(),\n filterBy: (row) => row.email,\n mobileHidden: true,\n });\n }\n columns.push(\n {\n key: \"role\",\n header: labels.role,\n cell: (row) => {\n const held = rolesOf(row, roleOf);\n const editing = held.length <= 1 ? editableRole?.(row) : false;\n if (editing && onRoleChange) {\n const rules: RoleEditing<R> = editing === true ? {} : editing;\n return (\n <RowName row={row}>\n {(name) => (\n // The column keeps room for the select's longest ROLE word in the app's\n // language (0.51, Kurvenschmiede N5): beside the `grow` Name every other\n // column is squeezed to its min-content, which a `w-full` select does not\n // have, so the role select was the header's ~92px and read \"Meml…\",\n // \"Admi…\". Every word of the vocabulary, not only the row's offered\n // ones: the column is as wide as its widest row, so the rows line up, and\n // a role held but not offered (KS's Reviewer) is still a selected value.\n // Not the options' reasons (\"Customer (owns work)\"): they are never the\n // selected value, and the open list is as wide as it needs anyway.\n <div>\n <RoleSelect<R>\n size=\"sm\"\n name={name}\n value={held[0] ?? null}\n roles={roles}\n offered={rules.offered}\n disabledReason={rules.disabledReason}\n optionDisabledReason={rules.optionDisabledReason}\n onChange={(role) => onRoleChange(row, role)}\n />\n <SelectWidthFloor size=\"sm\" values={roleWordsOf(roles, held[0])} />\n </div>\n )}\n </RowName>\n );\n }\n if (held.length === 0) return null;\n return (\n <div className=\"flex flex-wrap gap-1\">\n {held.map((role) => (\n <RoleChip<R> key={role} value={role} roles={roles} size=\"xs\" />\n ))}\n </div>\n );\n },\n sortBy: roleWords,\n filter: {\n type: \"select\",\n getValue: (row) => rolesOf(row, roleOf)[0] ?? \"\",\n options: (Object.keys(roles) as R[]).map((value) => ({ value, label: roles[value].label })),\n },\n mobile: \"secondary\",\n },\n {\n key: \"state\",\n header: labels.state,\n // No states, no cell: the phone card then shows no chip beside the name.\n cell: (row) => {\n const states = stateOf(row);\n return states.length === 0 ? null : <StateChips states={states} />;\n },\n // Beside the name on the phone card, so the role, last login and the actions keep\n // the card's three fields.\n role: \"status\",\n },\n dateColumn<T>({\n key: \"created\",\n header: labels.created,\n value: (row) => row.created_at ?? null,\n // The first to leave a narrow table (0.50, kastlan K2), then last login.\n column: { mobileHidden: true, fitPriority: 1 },\n }),\n // How long ago rather than a date — the question is \"recently?\" — with the exact\n // time beside it (keksdose's last-seen column, Kurvenschmiede's; 0.32 §4: never only\n // in a tooltip).\n dateColumn<T>({\n key: \"last_login\",\n header: labels.lastLogin,\n value: (row) => row.last_login_at ?? null,\n display: \"relative\",\n empty: labels.never,\n column: { mobile: \"secondary\", fitPriority: 2 },\n }),\n ...extra,\n );\n if (actions) {\n columns.push({\n key: \"actions\",\n // On screen, as every actions column's heading is (0.46.1, keksdose dev#598 rework).\n header:\n actionsHeader === false ? <span className=\"sr-only\">{labels.actions}</span> : (actionsHeader ?? labels.actions),\n headerText: labels.actions,\n // The caller's own words only: the menu reads the provider's itself, and the\n // English defaults merged in above must not shadow them.\n cell: (row) => (\n <RowName row={row}>\n {(name) => <UserRowActions actions={actions(row)} name={name} labels={labelsProp} />}\n </RowName>\n ),\n className: \"w-px whitespace-nowrap text-end\",\n headClassName: \"w-px text-end\",\n // The menu button keeps its own click: a row link must not wrap it.\n noRowLink: true,\n // Always on the phone card, after the fields and never one of the three (0.49).\n role: \"actions\",\n });\n }\n return columns;\n}\n\n/**\n * {@link userRosterColumns} with the `userRoster` words from the provider, memoised on\n * the options — pass stable callbacks (`useCallback`) and a module-level vocabulary, or\n * the columns are rebuilt on every render.\n */\nexport function useUserRosterColumns<T extends UserRosterRow, R extends string = string>({\n roles,\n stateOf,\n extra,\n actions,\n actionsHeader,\n editableRole,\n onRoleChange,\n roleOf,\n emailColumn,\n labels: labelsProp,\n}: UserRosterColumnsOptions<T, R>): DataTableColumn<T>[] {\n const labels = useKitLabels(\"userRoster\", DEFAULT_USER_ROSTER_LABELS, labelsProp);\n return useMemo(\n () =>\n userRosterColumns<T, R>({\n roles,\n stateOf,\n extra,\n actions,\n actionsHeader,\n editableRole,\n onRoleChange,\n roleOf,\n emailColumn,\n labels,\n }),\n [roles, stateOf, extra, actions, actionsHeader, editableRole, onRoleChange, roleOf, emailColumn, labels],\n );\n}\n"],"mappings":"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","names":[]}
@@ -120,6 +120,10 @@ export interface EmailVerificationBannerProps {
120
120
  * of a 390px phone). The resend's outcome ("Sent", the error) then moves out of the
121
121
  * message to sit after "Send again", at every width, so the line that answers the press
122
122
  * is never hidden behind "More". Default `false`: the outcome stays in the message.
123
+ *
124
+ * 0.51 (kastlan K1, KX-5): in the `strip` variant "Send again" then stays on the clamped
125
+ * message's line on a phone — one line, 43px, on a 390px phone in all seven catalogues —
126
+ * where it took a line of its own (71px). The box's equivalent is `actionPlacement`.
123
127
  */
124
128
  clampMobile?: boolean;
125
129
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/verify-email.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, MailWarning, TimerOff, XCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { LoadingState } from \"../components/loading-state\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from \"./status-parts\";\nimport { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from \"./auth-errors\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\nimport { DEFAULT_VERIFY_EMAIL_LABELS } from \"./verify-email-labels\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the verification page and banner render — the `verifyEmail` namespace.\n * keksdose's words (`verify_email.*`, and its server's \"has expired\"). */\nexport interface VerifyEmailLabels {\n /** The page's heading. */\n title: string;\n /** While the link is redeemed. */\n verifying: string;\n verified: string;\n /** The way on from the page — into the app, or to sign-in. */\n continue: string;\n invalid: string;\n expired: string;\n /** The page was opened without a `token`. */\n noToken: string;\n /** Under `invalid`: why a link stops working. */\n invalidHint: string;\n /** Under a dead link, signed out: where a new one comes from. */\n requestInApp: string;\n /** Under a dead link, signed in, beside \"Send again\". */\n requestHere: string;\n /** The resend button, on the page and in the banner. */\n resend: string;\n /** The resend button while a just-sent mail cools down, given the seconds left. */\n resendIn: (seconds: number) => string;\n /** Said (and shown) after a resend went out. */\n resent: string;\n /** Said (and shown) after a resend failed. */\n resendError: string;\n /** 0.31.1: a resend refused as throttled — HTTP 429 (`isRateLimited`) — given the\n * `Retry-After` seconds, when the app's `describeError` has no words of its own\n * (keksdose borrowed the sign-in namespace's). */\n rateLimited?: (seconds?: number) => string;\n /** The banner's message. */\n banner: string;\n /** The banner's ×: it defers, it does not close anything. */\n dismiss: string;\n}\n\n// Defined in ./verify-email-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_VERIFY_EMAIL_LABELS };\n\n/** A minute: long enough that an impatient second click does not mail twice, short\n * enough that a mail lost to a spam filter can be re-sent without leaving the page. The\n * server's own limit is the real one (kastlan: 3 an hour per address). */\nconst DEFAULT_COOLDOWN = 60;\n\n/* ── Resend ──────────────────────────────────────────────────────────────── */\n\ntype ResendPhase = \"idle\" | \"sending\" | \"sent\" | \"failed\";\n\n/**\n * \"Send again\" with its own memory: busy while the request runs, then — after a mail\n * went out — held for `cooldown` seconds with the countdown on the button, so a second\n * click cannot mail twice and a throttled server is not asked to refuse. A failure frees\n * it at once. The outcome is a polite live region that is always rendered (a region that\n * mounts with its message is often not read), and visible.\n */\nfunction useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {\n const [phase, setPhase] = useState<ResendPhase>(\"idle\");\n // The refusal itself, for `describeError` (0.30.0): the server's own sentence — keksdose's\n // per-address throttle, \"try again in N minutes\" — beats a fixed \"could not send\".\n const [error, setError] = useState<unknown>(undefined);\n const [remaining, setRemaining] = useState(0);\n const counting = remaining > 0;\n useEffect(() => {\n if (!counting) return;\n const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);\n return () => clearInterval(id);\n }, [counting]);\n\n const sent = () => {\n setPhase(\"sent\");\n setRemaining(Math.max(0, Math.round(cooldown)));\n };\n const send = () => {\n if (!onResend || phase === \"sending\" || counting) return;\n let result: unknown;\n try {\n result = onResend();\n } catch (e) {\n setError(e);\n setPhase(\"failed\");\n return;\n }\n if (!isThenable(result)) {\n sent();\n return;\n }\n setPhase(\"sending\");\n result.then(sent, (e: unknown) => {\n setError(e);\n setPhase(\"failed\");\n });\n };\n return { phase, remaining, send, error };\n}\n\nfunction ResendButton({\n state,\n labels,\n variant,\n}: {\n state: ReturnType<typeof useResend>;\n labels: VerifyEmailLabels;\n variant: \"secondary\" | \"accent\";\n}) {\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={variant === \"secondary\" ? \"sm\" : undefined}\n onClick={state.send}\n pending={state.phase === \"sending\"}\n // `disabledReason`, not `disabled`: the button just pressed has the focus, and a\n // native `disabled` would drop it to <body>. The reason is what just happened.\n disabledReason={state.remaining > 0 ? labels.resent : undefined}\n >\n {state.remaining > 0 ? labels.resendIn(state.remaining) : labels.resend}\n </Button>\n );\n}\n\nfunction ResendStatus({\n phase,\n error,\n describeError,\n labels,\n className,\n}: {\n phase: ResendPhase;\n error?: unknown;\n describeError?: (error: unknown) => string | undefined;\n labels: VerifyEmailLabels;\n className?: string;\n}) {\n // The app's words first, then the throttle's, then the kit's catch-all.\n const failed =\n phase === \"failed\"\n ? (describeError?.(error) ??\n (isRateLimited(error)\n ? (labels.rateLimited ?? englishRateLimited)(retryAfterSeconds(error))\n : labels.resendError))\n : \"\";\n return (\n <span\n role=\"status\"\n className={cn(phase === \"failed\" ? \"text-danger\" : \"text-success\", className)}\n >\n {phase === \"sent\" ? labels.resent : failed}\n </span>\n );\n}\n\n/* ── The page ────────────────────────────────────────────────────────────── */\n\n/** What became of a confirmation link that did not verify. */\nexport type VerifyEmailFailure = \"invalid\" | \"expired\";\n\n/** The default when the app gives no `classifyError`: the coded refusal decides. */\nfunction defaultVerifyFailure(error: unknown): VerifyEmailFailure {\n return authErrorCode(error) === \"token_expired\" ? \"expired\" : \"invalid\";\n}\n\nexport interface VerifyEmailStatusProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The link's token — the app's `?token=`. Empty: the link was cut short, and the\n * page says so without asking anyone. */\n token: string;\n /**\n * Redeem it — `POST /auth/verify-email {token}`, once per token, on mount. A POST and\n * not the link's own GET (§10.10): a mail scanner that opens every link must not\n * confirm an address nobody read. Resolve when it verified (an address that already\n * was counts: keksdose's redeem is idempotent); reject with the client's error.\n */\n onVerify: (token: string) => Promise<unknown>;\n /** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers\n * answer one `token_invalid`; an app whose server tells expiry apart maps it here. */\n classifyError?: (error: unknown) => VerifyEmailFailure;\n /** 0.30.0: the words for a refused resend — the server's own sentence (a throttle's\n * \"try again in N minutes\"), else the kit's `resendError`. */\n describeError?: (error: unknown) => string | undefined;\n /** Someone is signed in on this device: \"Continue\" goes into the app, and a dead link\n * offers \"Send again\" (with `onResend`). */\n signedIn?: boolean;\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`) — offered on a dead\n * link while signed in. */\n onResend?: () => Promise<unknown> | void;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** Where \"Continue\" goes. Default `/` signed in, `/login` signed out. */\n continueHref?: string;\n /** App content under \"confirmed\" — keksdose's \"Others can now share a budget with you\n * directly.\" */\n verifiedContent?: ReactNode;\n /** The heading. Default `labels.title`; `null` draws none, for a page that puts it in\n * `AuthLayout`'s `title` instead. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<VerifyEmailLabels>;\n}\n\ntype VerifyOutcome = \"verified\" | VerifyEmailFailure;\n\n/**\n * Redeem an email-confirmation link (`/verify-email?token=…`, docs/auth-harmonization.md\n * §7) — keksdose's `VerifyEmailPage` (features/auth/verify-email-page.tsx), with\n * kastlan's outcome blocks and its separate \"expired\".\n *\n * Opened from an inbox, often in a browser with no session, so the token is the only\n * credential and the page renders signed-out. It redeems once per token on mount\n * (StrictMode's double effect included — the token is single-use) and shows one of:\n * confirmed; not valid; expired; or the link had no token at all. Verification gates\n * nothing (§2.8), so every outcome ends on \"Continue\". Signed in, a dead link also\n * offers \"Send again\", which waits `cooldown` seconds after a mail went out.\n *\n * The outcome replaces the spinner, which says nothing to a screen reader on its own,\n * so it is also announced.\n */\nexport function VerifyEmailStatus({\n token,\n onVerify,\n classifyError,\n describeError,\n signedIn = false,\n onResend,\n cooldown = DEFAULT_COOLDOWN,\n continueHref,\n verifiedContent,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: VerifyEmailStatusProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const [settled, setSettled] = useState<{ token: string; outcome: VerifyOutcome } | null>(null);\n const outcome: VerifyOutcome | \"verifying\" =\n token === \"\" ? \"invalid\" : settled?.token === token ? settled.outcome : \"verifying\";\n const { announce, regionProps } = useAnnounce();\n const resend = useResend(onResend, cooldown);\n\n useOncePerKey(token === \"\" ? null : token, onVerify, (key, result) => {\n // Without a classifier, the coded refusal decides (0.30.0): `token_expired` is\n // \"expired\", anything else \"invalid\".\n const next: VerifyOutcome = result.ok\n ? \"verified\"\n : (classifyError ?? defaultVerifyFailure)(result.error);\n setSettled({ token: key, outcome: next });\n announce(labels[next]);\n });\n\n const href = continueHref ?? (signedIn ? \"/\" : \"/login\");\n // `replace`: the token is spent, so Back must not land on this page again (keksdose F1).\n const continueButton = (\n <Button href={href} replace variant={outcome === \"verified\" ? \"accent\" : \"secondary\"}>\n {labels.continue}\n </Button>\n );\n const canResend = signedIn && onResend !== undefined;\n\n let body: ReactNode;\n if (outcome === \"verifying\") {\n body = <LoadingState size=\"sm\" label={labels.verifying} />;\n } else if (outcome === \"verified\") {\n body = (\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={labels.verified}\n hint={verifiedContent}\n action={continueButton}\n className={OUTCOME_CLASS}\n />\n );\n } else {\n const hint = (\n <>\n {outcome === \"invalid\" && token !== \"\" ? `${labels.invalidHint} ` : \"\"}\n {canResend ? labels.requestHere : labels.requestInApp}\n </>\n );\n body = (\n <EmptyState\n tone=\"danger\"\n icon={outcome === \"expired\" ? <TimerOff /> : <XCircle />}\n title={token === \"\" ? labels.noToken : labels[outcome]}\n hint={hint}\n action={\n <>\n {canResend && <ResendButton state={resend} labels={labels} variant=\"accent\" />}\n {continueButton}\n </>\n }\n className={OUTCOME_CLASS}\n />\n );\n }\n\n return (\n <div\n {...rest}\n data-state={outcome}\n aria-busy={outcome === \"verifying\" || undefined}\n className={cn(\"space-y-3\", className)}\n >\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}\n {body}\n {canResend && (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"block text-center text-xs\"\n />\n )}\n {/* Rendered from the start, so the outcome is read when it arrives. */}\n <div {...regionProps} />\n </div>\n );\n}\n\n/* ── The banner ──────────────────────────────────────────────────────────── */\n\nexport interface EmailVerificationBannerProps {\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it\n * went out; reject and the banner says it could not. */\n onResend: () => Promise<unknown> | void;\n /** 0.30.0: the words for a refused resend — the server's own sentence, else the kit's. */\n describeError?: (error: unknown) => string | undefined;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** The ×, \"Not now\". The banner does not hide itself: the app stops rendering it —\n * keksdose for the rest of the session. Left out, no ×. */\n onDismiss?: () => void;\n /** `strip` (default): keksdose's band across the top of the shell. `box`: kastlan's\n * callout inside the page. */\n variant?: \"strip\" | \"box\";\n /** The app's sentence after the kit's — keksdose's \"Until you do, nobody can share a\n * budget with you directly.\" */\n children?: ReactNode;\n className?: string;\n labels?: LabelOverride<VerifyEmailLabels>;\n /**\n * 0.48: on a phone hold the message to one line with a More / Less toggle —\n * {@link AlertBanner}'s `clampMobile`, passed through (kastlan S2: its banner took ~110px\n * of a 390px phone). The resend's outcome (\"Sent\", the error) then moves out of the\n * message to sit after \"Send again\", at every width, so the line that answers the press\n * is never hidden behind \"More\". Default `false`: the outcome stays in the message.\n */\n clampMobile?: boolean;\n /**\n * 0.50 (kastlan K1): where \"Send again\" goes on a phone in the `box` variant —\n * {@link AlertBanner}'s `actionPlacement`, passed through. `\"flow\"` keeps it on the\n * message's line while the message keeps 10rem, else at the end of the next line,\n * instead of a line of its own (kastlan's ≤ 56px banner on a 390px phone, KX-5).\n * Default `\"own-line\"`, as before. The `strip` variant flows like that already.\n */\n actionPlacement?: \"own-line\" | \"flow\";\n}\n\n/**\n * The nudge for a signed-in account whose address is not confirmed — keksdose's\n * `VerifyEmailBanner` (features/auth/verify-email-banner.tsx), kastlan's copy of it.\n *\n * A prompt, not a wall: verification gates nothing (§2.8). The app renders it while\n * `/auth/me` says `email_verified: false`, and stops when the user dismisses it or the\n * page above confirms the address. \"Send again\" waits `cooldown` seconds after a mail\n * went out (the button says how long), and its outcome is written into the banner and\n * read out — no toast needed.\n */\nexport function EmailVerificationBanner({\n onResend,\n describeError,\n cooldown = DEFAULT_COOLDOWN,\n onDismiss,\n variant = \"strip\",\n children,\n className,\n labels: labelsProp,\n clampMobile = false,\n actionPlacement,\n}: EmailVerificationBannerProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const resend = useResend(onResend, cooldown);\n // One live region either way, mounted from the start (a region added later is often not\n // read out): in the message, or — clamped — after the button, outside the clamp.\n const status = (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"font-medium\"\n />\n );\n const button = <ResendButton state={resend} labels={labels} variant=\"secondary\" />;\n return (\n <AlertBanner\n tone=\"warning\"\n variant={variant}\n icon={<MailWarning />}\n action={\n clampMobile ? (\n <>\n {button}\n {status}\n </>\n ) : (\n button\n )\n }\n onDismiss={onDismiss}\n dismissLabel={onDismiss ? labels.dismiss : undefined}\n clampMobile={clampMobile}\n actionPlacement={actionPlacement}\n className={className}\n >\n {labels.banner}\n {children != null && children !== false && <> {children}</>}\n {!clampMobile && <> {status}</>}\n </AlertBanner>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/auth/verify-email.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, MailWarning, TimerOff, XCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { LoadingState } from \"../components/loading-state\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from \"./status-parts\";\nimport { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from \"./auth-errors\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\nimport { DEFAULT_VERIFY_EMAIL_LABELS } from \"./verify-email-labels\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the verification page and banner render — the `verifyEmail` namespace.\n * keksdose's words (`verify_email.*`, and its server's \"has expired\"). */\nexport interface VerifyEmailLabels {\n /** The page's heading. */\n title: string;\n /** While the link is redeemed. */\n verifying: string;\n verified: string;\n /** The way on from the page — into the app, or to sign-in. */\n continue: string;\n invalid: string;\n expired: string;\n /** The page was opened without a `token`. */\n noToken: string;\n /** Under `invalid`: why a link stops working. */\n invalidHint: string;\n /** Under a dead link, signed out: where a new one comes from. */\n requestInApp: string;\n /** Under a dead link, signed in, beside \"Send again\". */\n requestHere: string;\n /** The resend button, on the page and in the banner. */\n resend: string;\n /** The resend button while a just-sent mail cools down, given the seconds left. */\n resendIn: (seconds: number) => string;\n /** Said (and shown) after a resend went out. */\n resent: string;\n /** Said (and shown) after a resend failed. */\n resendError: string;\n /** 0.31.1: a resend refused as throttled — HTTP 429 (`isRateLimited`) — given the\n * `Retry-After` seconds, when the app's `describeError` has no words of its own\n * (keksdose borrowed the sign-in namespace's). */\n rateLimited?: (seconds?: number) => string;\n /** The banner's message. */\n banner: string;\n /** The banner's ×: it defers, it does not close anything. */\n dismiss: string;\n}\n\n// Defined in ./verify-email-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_VERIFY_EMAIL_LABELS };\n\n/** A minute: long enough that an impatient second click does not mail twice, short\n * enough that a mail lost to a spam filter can be re-sent without leaving the page. The\n * server's own limit is the real one (kastlan: 3 an hour per address). */\nconst DEFAULT_COOLDOWN = 60;\n\n/* ── Resend ──────────────────────────────────────────────────────────────── */\n\ntype ResendPhase = \"idle\" | \"sending\" | \"sent\" | \"failed\";\n\n/**\n * \"Send again\" with its own memory: busy while the request runs, then — after a mail\n * went out — held for `cooldown` seconds with the countdown on the button, so a second\n * click cannot mail twice and a throttled server is not asked to refuse. A failure frees\n * it at once. The outcome is a polite live region that is always rendered (a region that\n * mounts with its message is often not read), and visible.\n */\nfunction useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {\n const [phase, setPhase] = useState<ResendPhase>(\"idle\");\n // The refusal itself, for `describeError` (0.30.0): the server's own sentence — keksdose's\n // per-address throttle, \"try again in N minutes\" — beats a fixed \"could not send\".\n const [error, setError] = useState<unknown>(undefined);\n const [remaining, setRemaining] = useState(0);\n const counting = remaining > 0;\n useEffect(() => {\n if (!counting) return;\n const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);\n return () => clearInterval(id);\n }, [counting]);\n\n const sent = () => {\n setPhase(\"sent\");\n setRemaining(Math.max(0, Math.round(cooldown)));\n };\n const send = () => {\n if (!onResend || phase === \"sending\" || counting) return;\n let result: unknown;\n try {\n result = onResend();\n } catch (e) {\n setError(e);\n setPhase(\"failed\");\n return;\n }\n if (!isThenable(result)) {\n sent();\n return;\n }\n setPhase(\"sending\");\n result.then(sent, (e: unknown) => {\n setError(e);\n setPhase(\"failed\");\n });\n };\n return { phase, remaining, send, error };\n}\n\nfunction ResendButton({\n state,\n labels,\n variant,\n}: {\n state: ReturnType<typeof useResend>;\n labels: VerifyEmailLabels;\n variant: \"secondary\" | \"accent\";\n}) {\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={variant === \"secondary\" ? \"sm\" : undefined}\n onClick={state.send}\n pending={state.phase === \"sending\"}\n // `disabledReason`, not `disabled`: the button just pressed has the focus, and a\n // native `disabled` would drop it to <body>. The reason is what just happened.\n disabledReason={state.remaining > 0 ? labels.resent : undefined}\n >\n {state.remaining > 0 ? labels.resendIn(state.remaining) : labels.resend}\n </Button>\n );\n}\n\nfunction ResendStatus({\n phase,\n error,\n describeError,\n labels,\n className,\n}: {\n phase: ResendPhase;\n error?: unknown;\n describeError?: (error: unknown) => string | undefined;\n labels: VerifyEmailLabels;\n className?: string;\n}) {\n // The app's words first, then the throttle's, then the kit's catch-all.\n const failed =\n phase === \"failed\"\n ? (describeError?.(error) ??\n (isRateLimited(error)\n ? (labels.rateLimited ?? englishRateLimited)(retryAfterSeconds(error))\n : labels.resendError))\n : \"\";\n return (\n <span\n role=\"status\"\n className={cn(phase === \"failed\" ? \"text-danger\" : \"text-success\", className)}\n >\n {phase === \"sent\" ? labels.resent : failed}\n </span>\n );\n}\n\n/* ── The page ────────────────────────────────────────────────────────────── */\n\n/** What became of a confirmation link that did not verify. */\nexport type VerifyEmailFailure = \"invalid\" | \"expired\";\n\n/** The default when the app gives no `classifyError`: the coded refusal decides. */\nfunction defaultVerifyFailure(error: unknown): VerifyEmailFailure {\n return authErrorCode(error) === \"token_expired\" ? \"expired\" : \"invalid\";\n}\n\nexport interface VerifyEmailStatusProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The link's token — the app's `?token=`. Empty: the link was cut short, and the\n * page says so without asking anyone. */\n token: string;\n /**\n * Redeem it — `POST /auth/verify-email {token}`, once per token, on mount. A POST and\n * not the link's own GET (§10.10): a mail scanner that opens every link must not\n * confirm an address nobody read. Resolve when it verified (an address that already\n * was counts: keksdose's redeem is idempotent); reject with the client's error.\n */\n onVerify: (token: string) => Promise<unknown>;\n /** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers\n * answer one `token_invalid`; an app whose server tells expiry apart maps it here. */\n classifyError?: (error: unknown) => VerifyEmailFailure;\n /** 0.30.0: the words for a refused resend — the server's own sentence (a throttle's\n * \"try again in N minutes\"), else the kit's `resendError`. */\n describeError?: (error: unknown) => string | undefined;\n /** Someone is signed in on this device: \"Continue\" goes into the app, and a dead link\n * offers \"Send again\" (with `onResend`). */\n signedIn?: boolean;\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`) — offered on a dead\n * link while signed in. */\n onResend?: () => Promise<unknown> | void;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** Where \"Continue\" goes. Default `/` signed in, `/login` signed out. */\n continueHref?: string;\n /** App content under \"confirmed\" — keksdose's \"Others can now share a budget with you\n * directly.\" */\n verifiedContent?: ReactNode;\n /** The heading. Default `labels.title`; `null` draws none, for a page that puts it in\n * `AuthLayout`'s `title` instead. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<VerifyEmailLabels>;\n}\n\ntype VerifyOutcome = \"verified\" | VerifyEmailFailure;\n\n/**\n * Redeem an email-confirmation link (`/verify-email?token=…`, docs/auth-harmonization.md\n * §7) — keksdose's `VerifyEmailPage` (features/auth/verify-email-page.tsx), with\n * kastlan's outcome blocks and its separate \"expired\".\n *\n * Opened from an inbox, often in a browser with no session, so the token is the only\n * credential and the page renders signed-out. It redeems once per token on mount\n * (StrictMode's double effect included — the token is single-use) and shows one of:\n * confirmed; not valid; expired; or the link had no token at all. Verification gates\n * nothing (§2.8), so every outcome ends on \"Continue\". Signed in, a dead link also\n * offers \"Send again\", which waits `cooldown` seconds after a mail went out.\n *\n * The outcome replaces the spinner, which says nothing to a screen reader on its own,\n * so it is also announced.\n */\nexport function VerifyEmailStatus({\n token,\n onVerify,\n classifyError,\n describeError,\n signedIn = false,\n onResend,\n cooldown = DEFAULT_COOLDOWN,\n continueHref,\n verifiedContent,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: VerifyEmailStatusProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const [settled, setSettled] = useState<{ token: string; outcome: VerifyOutcome } | null>(null);\n const outcome: VerifyOutcome | \"verifying\" =\n token === \"\" ? \"invalid\" : settled?.token === token ? settled.outcome : \"verifying\";\n const { announce, regionProps } = useAnnounce();\n const resend = useResend(onResend, cooldown);\n\n useOncePerKey(token === \"\" ? null : token, onVerify, (key, result) => {\n // Without a classifier, the coded refusal decides (0.30.0): `token_expired` is\n // \"expired\", anything else \"invalid\".\n const next: VerifyOutcome = result.ok\n ? \"verified\"\n : (classifyError ?? defaultVerifyFailure)(result.error);\n setSettled({ token: key, outcome: next });\n announce(labels[next]);\n });\n\n const href = continueHref ?? (signedIn ? \"/\" : \"/login\");\n // `replace`: the token is spent, so Back must not land on this page again (keksdose F1).\n const continueButton = (\n <Button href={href} replace variant={outcome === \"verified\" ? \"accent\" : \"secondary\"}>\n {labels.continue}\n </Button>\n );\n const canResend = signedIn && onResend !== undefined;\n\n let body: ReactNode;\n if (outcome === \"verifying\") {\n body = <LoadingState size=\"sm\" label={labels.verifying} />;\n } else if (outcome === \"verified\") {\n body = (\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={labels.verified}\n hint={verifiedContent}\n action={continueButton}\n className={OUTCOME_CLASS}\n />\n );\n } else {\n const hint = (\n <>\n {outcome === \"invalid\" && token !== \"\" ? `${labels.invalidHint} ` : \"\"}\n {canResend ? labels.requestHere : labels.requestInApp}\n </>\n );\n body = (\n <EmptyState\n tone=\"danger\"\n icon={outcome === \"expired\" ? <TimerOff /> : <XCircle />}\n title={token === \"\" ? labels.noToken : labels[outcome]}\n hint={hint}\n action={\n <>\n {canResend && <ResendButton state={resend} labels={labels} variant=\"accent\" />}\n {continueButton}\n </>\n }\n className={OUTCOME_CLASS}\n />\n );\n }\n\n return (\n <div\n {...rest}\n data-state={outcome}\n aria-busy={outcome === \"verifying\" || undefined}\n className={cn(\"space-y-3\", className)}\n >\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}\n {body}\n {canResend && (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"block text-center text-xs\"\n />\n )}\n {/* Rendered from the start, so the outcome is read when it arrives. */}\n <div {...regionProps} />\n </div>\n );\n}\n\n/* ── The banner ──────────────────────────────────────────────────────────── */\n\nexport interface EmailVerificationBannerProps {\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it\n * went out; reject and the banner says it could not. */\n onResend: () => Promise<unknown> | void;\n /** 0.30.0: the words for a refused resend — the server's own sentence, else the kit's. */\n describeError?: (error: unknown) => string | undefined;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** The ×, \"Not now\". The banner does not hide itself: the app stops rendering it —\n * keksdose for the rest of the session. Left out, no ×. */\n onDismiss?: () => void;\n /** `strip` (default): keksdose's band across the top of the shell. `box`: kastlan's\n * callout inside the page. */\n variant?: \"strip\" | \"box\";\n /** The app's sentence after the kit's — keksdose's \"Until you do, nobody can share a\n * budget with you directly.\" */\n children?: ReactNode;\n className?: string;\n labels?: LabelOverride<VerifyEmailLabels>;\n /**\n * 0.48: on a phone hold the message to one line with a More / Less toggle —\n * {@link AlertBanner}'s `clampMobile`, passed through (kastlan S2: its banner took ~110px\n * of a 390px phone). The resend's outcome (\"Sent\", the error) then moves out of the\n * message to sit after \"Send again\", at every width, so the line that answers the press\n * is never hidden behind \"More\". Default `false`: the outcome stays in the message.\n *\n * 0.51 (kastlan K1, KX-5): in the `strip` variant \"Send again\" then stays on the clamped\n * message's line on a phone — one line, 43px, on a 390px phone in all seven catalogues —\n * where it took a line of its own (71px). The box's equivalent is `actionPlacement`.\n */\n clampMobile?: boolean;\n /**\n * 0.50 (kastlan K1): where \"Send again\" goes on a phone in the `box` variant —\n * {@link AlertBanner}'s `actionPlacement`, passed through. `\"flow\"` keeps it on the\n * message's line while the message keeps 10rem, else at the end of the next line,\n * instead of a line of its own (kastlan's ≤ 56px banner on a 390px phone, KX-5).\n * Default `\"own-line\"`, as before. The `strip` variant flows like that already.\n */\n actionPlacement?: \"own-line\" | \"flow\";\n}\n\n/**\n * The nudge for a signed-in account whose address is not confirmed — keksdose's\n * `VerifyEmailBanner` (features/auth/verify-email-banner.tsx), kastlan's copy of it.\n *\n * A prompt, not a wall: verification gates nothing (§2.8). The app renders it while\n * `/auth/me` says `email_verified: false`, and stops when the user dismisses it or the\n * page above confirms the address. \"Send again\" waits `cooldown` seconds after a mail\n * went out (the button says how long), and its outcome is written into the banner and\n * read out — no toast needed.\n */\nexport function EmailVerificationBanner({\n onResend,\n describeError,\n cooldown = DEFAULT_COOLDOWN,\n onDismiss,\n variant = \"strip\",\n children,\n className,\n labels: labelsProp,\n clampMobile = false,\n actionPlacement,\n}: EmailVerificationBannerProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const resend = useResend(onResend, cooldown);\n // One live region either way, mounted from the start (a region added later is often not\n // read out): in the message, or — clamped — after the button, outside the clamp.\n const status = (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"font-medium\"\n />\n );\n const button = <ResendButton state={resend} labels={labels} variant=\"secondary\" />;\n return (\n <AlertBanner\n tone=\"warning\"\n variant={variant}\n icon={<MailWarning />}\n action={\n clampMobile ? (\n <>\n {button}\n {status}\n </>\n ) : (\n button\n )\n }\n onDismiss={onDismiss}\n dismissLabel={onDismiss ? labels.dismiss : undefined}\n clampMobile={clampMobile}\n actionPlacement={actionPlacement}\n className={className}\n >\n {labels.banner}\n {children != null && children !== false && <> {children}</>}\n {!clampMobile && <> {status}</>}\n </AlertBanner>\n );\n}\n"],"mappings":"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","names":[]}
@@ -107,6 +107,15 @@ interface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "on
107
107
  * every phone screen, and the app built the clamp and the button around the banner's
108
108
  * children by hand. Ignored on a whole-row banner (`href` / `onClick`): a toggle cannot
109
109
  * sit inside the row's own link or button.
110
+ *
111
+ * 0.51 (kastlan K1): on a `strip` the clamped message claims 7rem instead of 12rem below
112
+ * `md`, so the {@link action} stays on its line rather than taking one of its own.
113
+ *
114
+ * For a one-line MESSAGE (a sentence or two). Not for a body with a list or a control in
115
+ * it: the clamp cuts the box to its first line, so a Disclosure's or a link's trigger
116
+ * past it is clipped out of sight and still takes the keyboard's focus, and "More" gives
117
+ * back the whole body at once. Shorten such a banner instead — one sentence, its action,
118
+ * and the rest behind a Disclosure of its own (keksdose's agree-again notice, 0.51).
110
119
  */
111
120
  clampMobile?: boolean;
112
121
  /**
@@ -117,25 +117,29 @@ function AlertBanner({
117
117
  sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm"
118
118
  );
119
119
  const grow = strip ? "flex-[1_1_12rem]" : "flex-1";
120
+ const clampBasis = strip && clamp ? "max-md:basis-28" : void 0;
120
121
  const nested = interactive && (onDismiss !== void 0 || action !== void 0 && action !== null);
121
122
  const frame = inline ? "" : cn(
122
123
  strip ? stripFrameClass(tone, size) : alertFrameClass(tone, size),
123
124
  elevated && cn(ELEVATED, TONE_ELEVATED[tone])
124
125
  );
125
126
  const focusRing = cn(FOCUS_RING, "focus-visible:ring-offset-1 focus-visible:ring-offset-surface");
127
+ const Body = interactive ? "span" : "div";
126
128
  const contentBody = clamp ? (
127
129
  // Below `md` a row: the message, clamped, then the toggle on its baseline. From
128
- // `md` up a plain span with the message in it, as without the prop.
130
+ // `md` up a plain box with the message in it, as without the prop. (Never a whole-row
131
+ // banner, so always a `<div>`.)
129
132
  /* @__PURE__ */ jsxs(
130
- "span",
133
+ "div",
131
134
  {
132
135
  className: cn(
133
136
  !inlineRow && grow,
137
+ clampBasis,
134
138
  "min-w-0 text-start max-md:flex max-md:items-baseline max-md:gap-2"
135
139
  ),
136
140
  children: [
137
141
  /* @__PURE__ */ jsx(
138
- "span",
142
+ "div",
139
143
  {
140
144
  id: bodyId,
141
145
  ref: bodyRef,
@@ -161,7 +165,7 @@ function AlertBanner({
161
165
  ]
162
166
  }
163
167
  )
164
- ) : /* @__PURE__ */ jsx("span", { className: cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start"), children });
168
+ ) : /* @__PURE__ */ jsx(Body, { className: cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start"), children });
165
169
  const content = /* @__PURE__ */ jsxs(Fragment, { children: [
166
170
  glyph,
167
171
  contentBody,
@@ -216,7 +220,7 @@ function AlertBanner({
216
220
  className: cn(row, frame, TONE_TEXT[tone], className),
217
221
  children: [
218
222
  flow ? /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", { className: flowRow, "data-action-flow": "", children: [
219
- /* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2"), children: [
223
+ /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2"), children: [
220
224
  glyph,
221
225
  contentBody
222
226
  ] }),