@eifi1/ui-kit 0.50.1 → 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 (110) hide show
  1. package/README.md +11 -9
  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/button-look.d.ts +4 -19
  10. package/dist/components/button-look.js +3 -30
  11. package/dist/components/button-look.js.map +1 -1
  12. package/dist/components/data-table-fit.js.map +1 -1
  13. package/dist/components/data-table-virtual.js +1 -0
  14. package/dist/components/data-table-virtual.js.map +1 -1
  15. package/dist/components/field-base.d.ts +31 -1
  16. package/dist/components/field-base.js +28 -0
  17. package/dist/components/field-base.js.map +1 -1
  18. package/dist/components/floating-panel.d.ts +6 -13
  19. package/dist/components/floating-panel.js +2 -9
  20. package/dist/components/floating-panel.js.map +1 -1
  21. package/dist/components/icon-button.js +3 -5
  22. package/dist/components/icon-button.js.map +1 -1
  23. package/dist/components/select.js +3 -2
  24. package/dist/components/select.js.map +1 -1
  25. package/dist/components/stat-tile.d.ts +8 -1
  26. package/dist/components/stat-tile.js +7 -3
  27. package/dist/components/stat-tile.js.map +1 -1
  28. package/dist/eslint/index.js +1 -1
  29. package/dist/i18n/german.js +2 -0
  30. package/dist/i18n/german.js.map +1 -1
  31. package/dist/i18n/kit-labels.d.ts +2 -2
  32. package/dist/i18n/kit-labels.js.map +1 -1
  33. package/dist/i18n/locales/en.js +2 -0
  34. package/dist/i18n/locales/en.js.map +2 -2
  35. package/dist/i18n/locales/es.js +2 -0
  36. package/dist/i18n/locales/es.js.map +1 -1
  37. package/dist/i18n/locales/fr.js +2 -0
  38. package/dist/i18n/locales/fr.js.map +1 -1
  39. package/dist/i18n/locales/hu.js +2 -0
  40. package/dist/i18n/locales/hu.js.map +1 -1
  41. package/dist/i18n/locales/it.js +2 -0
  42. package/dist/i18n/locales/it.js.map +1 -1
  43. package/dist/i18n/locales/zh.js +2 -0
  44. package/dist/i18n/locales/zh.js.map +1 -1
  45. package/dist/index.d.ts +3 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/landing/page-seo.d.ts +19 -11
  49. package/dist/landing/page-seo.js +11 -7
  50. package/dist/landing/page-seo.js.map +1 -1
  51. package/dist/landing/public-page.d.ts +57 -0
  52. package/dist/landing/public-page.js +45 -0
  53. package/dist/landing/public-page.js.map +1 -0
  54. package/dist/lib/server-wake.d.ts +3 -2
  55. package/dist/lib/server-wake.js +19 -13
  56. package/dist/lib/server-wake.js.map +1 -1
  57. package/dist/shell/app-shell.d.ts +2 -15
  58. package/dist/shell/app-shell.js +7 -26
  59. package/dist/shell/app-shell.js.map +1 -1
  60. package/dist/shell/dev-menu.d.ts +98 -0
  61. package/dist/shell/dev-menu.js +133 -0
  62. package/dist/shell/dev-menu.js.map +1 -0
  63. package/dist/shell/dev-switchers.d.ts +6 -53
  64. package/dist/shell/dev-switchers.js +5 -100
  65. package/dist/shell/dev-switchers.js.map +1 -1
  66. package/dist/shell/top-bar-actions.d.ts +4 -1
  67. package/dist/shell/top-bar-actions.js.map +1 -1
  68. package/dist/shell/top-bar-parts-labels.d.ts +18 -9
  69. package/dist/shell/top-bar-parts-labels.js +2 -0
  70. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  71. package/dist/shell/topbar-action-menu.d.ts +17 -2
  72. package/dist/shell/topbar-action-menu.js +3 -1
  73. package/dist/shell/topbar-action-menu.js.map +1 -1
  74. package/dist/shell.d.ts +3 -1
  75. package/dist/shell.js +3 -1
  76. package/dist/shell.js.map +1 -1
  77. package/dist/theme/palette-presets.d.ts +0 -3
  78. package/dist/theme/palette-presets.js +0 -2
  79. package/dist/theme/palette-presets.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/admin/user-roster.tsx +30 -10
  82. package/src/auth/verify-email.tsx +4 -0
  83. package/src/components/alert-banner.tsx +37 -10
  84. package/src/components/button-look.ts +7 -57
  85. package/src/components/data-table-fit.ts +4 -1
  86. package/src/components/data-table-virtual.ts +14 -1
  87. package/src/components/field-base.tsx +53 -1
  88. package/src/components/floating-panel.tsx +6 -28
  89. package/src/components/icon-button.tsx +6 -8
  90. package/src/components/select.tsx +3 -6
  91. package/src/components/stat-tile.tsx +27 -9
  92. package/src/i18n/german.ts +2 -0
  93. package/src/i18n/kit-labels.tsx +2 -2
  94. package/src/i18n/locales/es.ts +2 -0
  95. package/src/i18n/locales/fr.ts +2 -0
  96. package/src/i18n/locales/hu.ts +2 -0
  97. package/src/i18n/locales/it.ts +2 -0
  98. package/src/i18n/locales/zh.ts +2 -0
  99. package/src/index.ts +4 -1
  100. package/src/landing/page-seo.ts +28 -16
  101. package/src/landing/public-page.tsx +106 -0
  102. package/src/lib/server-wake.ts +41 -17
  103. package/src/shell/app-shell.tsx +11 -48
  104. package/src/shell/dev-menu.tsx +259 -0
  105. package/src/shell/dev-switchers.tsx +13 -174
  106. package/src/shell/top-bar-actions.tsx +4 -1
  107. package/src/shell/top-bar-parts-labels.ts +20 -9
  108. package/src/shell/topbar-action-menu.tsx +28 -2
  109. package/src/shell.ts +5 -1
  110. package/src/theme/palette-presets.ts +0 -4
@@ -34,54 +34,7 @@ export type ButtonVariant =
34
34
  | "secondary"
35
35
  | "ghost"
36
36
  | "danger"
37
- | "link"
38
- /** @deprecated 0.49 — use `default` (same look); removed in 0.51. */
39
- | "primary"
40
- /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */
41
- | "brand";
42
-
43
- /** @internal A {@link ButtonVariant} without the deprecated aliases. */
44
- export type CanonicalButtonVariant = Exclude<ButtonVariant, "primary" | "brand">;
45
-
46
- /** The deprecated names and what they stand for — see {@link ButtonVariant}. */
47
- const DEPRECATED_BUTTON_VARIANTS: Readonly<Record<string, CanonicalButtonVariant>> = {
48
- primary: "default",
49
- brand: "accent",
50
- };
51
-
52
- /** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
53
- * production one (the consumer's bundler makes `import.meta.env.DEV` false) and never
54
- * under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */
55
- function devWarnings(): boolean {
56
- return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
57
- }
58
-
59
- const warnedVariants = new Set<string>();
60
-
61
- /**
62
- * @internal A deprecated variant name warns once (per `component` and name) in a
63
- * development build, then reads as the name it stands for. `component` is the prop's
64
- * owner in the message ("Button variant", "FormActions submitVariant", …).
65
- */
66
- export function warnDeprecatedVariant(component: string, name: string, replacement: string): void {
67
- if (!devWarnings()) return;
68
- const key = `${component}|${name}`;
69
- if (warnedVariants.has(key)) return;
70
- warnedVariants.add(key);
71
- console.warn(
72
- `[ui-kit] ${component}="${name}" is deprecated: use "${replacement}" (same look). ` +
73
- `"${name}" is removed in 0.51.`,
74
- );
75
- }
76
-
77
- /** @internal `primary` → `default`, `brand` → `accent` (warning once in development);
78
- * every other variant as it is. Runs before every lookup keyed by the variant. */
79
- export function resolveButtonVariant(variant: ButtonVariant, component = "Button variant"): CanonicalButtonVariant {
80
- const replacement = DEPRECATED_BUTTON_VARIANTS[variant];
81
- if (replacement === undefined) return variant as CanonicalButtonVariant;
82
- warnDeprecatedVariant(component, variant, replacement);
83
- return replacement;
84
- }
37
+ | "link";
85
38
 
86
39
  // Shared base ring for every button-styled element. Kept as a named const so the
87
40
  // <Button> component and the {@link buttonClasses} helper draw from one source and
@@ -155,9 +108,8 @@ export const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,tra
155
108
  // outline); `accent` is the solid brand fill for the rare strong CTA; `danger` takes the
156
109
  // semantic `--danger` family (destructive semantics), so a consumer can re-point the
157
110
  // destructive hue instead of inheriting a hard-coded red. Focus rings follow each
158
- // variant's own accent: brand for the four neutral ones, danger for `danger`. Keyed by
159
- // the canonical names: {@link resolveButtonVariant} maps the deprecated ones first.
160
- export const buttonVariantClasses: Record<CanonicalButtonVariant, string> = {
111
+ // variant's own accent: brand for the four neutral ones, danger for `danger`.
112
+ export const buttonVariantClasses: Record<ButtonVariant, string> = {
161
113
  default:
162
114
  // Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the
163
115
  // border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.
@@ -223,12 +175,12 @@ const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
223
175
  };
224
176
 
225
177
  /** Only the two transparent variants take every tone; see {@link ButtonTone}. */
226
- const TONED_VARIANTS = new Set<CanonicalButtonVariant>(["link", "ghost"]);
178
+ const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
227
179
 
228
180
  // `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
229
181
  // restates every colour the variant sets (border, fill, text, hover, ring) so none of
230
182
  // the neutral ones survives the merge.
231
- const BUTTON_BOXED_DANGER: Partial<Record<CanonicalButtonVariant, string>> = {
183
+ const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
232
184
  // Not boxed, but its hover is a fill: the danger wash under the danger text, as
233
185
  // IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1
234
186
  // over a light page.
@@ -266,12 +218,11 @@ export interface ButtonClassesOptions {
266
218
  * compact toolbar links.
267
219
  */
268
220
  export function buttonClasses(
269
- given: ButtonVariant = "default",
221
+ variant: ButtonVariant = "default",
270
222
  classNameOrOptions?: string | ButtonClassesOptions,
271
223
  ): string {
272
224
  const { size = "md", className } =
273
225
  typeof classNameOrOptions === "object" ? classNameOrOptions : { className: classNameOrOptions };
274
- const variant = resolveButtonVariant(given, "buttonClasses variant");
275
226
  return cn(
276
227
  BUTTON_BASE,
277
228
  BUTTON_SIZES[size],
@@ -291,8 +242,7 @@ export const ICON_BUTTON_PRESSED =
291
242
  "bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand";
292
243
 
293
244
  /** @internal The button look shared by the `<button>` and the `<a>` form. */
294
- export function buttonLook(given: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
295
- const variant = resolveButtonVariant(given);
245
+ export function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
296
246
  return cn(
297
247
  BUTTON_BASE,
298
248
  BUTTON_SIZES[size],
@@ -183,7 +183,10 @@ export function useColumnFit<T>({
183
183
  // update limit (#185). So a column whose return the next measure undid, at the same
184
184
  // scroller width, waits for more room than it had then: a wider scroller or narrower
185
185
  // content. Whatever the error in its width, that is one invisible flip per room it is
186
- // tried at.
186
+ // tried at. (0.51, keksdose N2: under `virtualize` that excess was the table coming
187
+ // back at an OLD lock's ratcheted widths, not the ones measured here; the lock now goes
188
+ // when the columns change, data-table-virtual.ts. The guard stays for the rest: a
189
+ // column whose drawn cells are wider than its header.)
187
190
  let n = current.length;
188
191
  const rooms = new Map<string, number>();
189
192
  while (n > 0) {
@@ -30,7 +30,9 @@ import type { RefObject } from "react";
30
30
  // nothing). Re-measured from scratch when the data, the columns, a user width, the text
31
31
  // size or the scroller's width change — and when a grown column would push the table
32
32
  // past its scroller, which it fit before. A `grow` column and a user-resized one are not
33
- // locked (the first takes the rest, the second is pinned already).
33
+ // locked (the first takes the rest, the second is pinned already). A lock replaced is
34
+ // gone: changing back (a column the fit leaves out and brings back) measures again
35
+ // (0.51, keksdose N2).
34
36
 
35
37
  /** {@link DataTableProps.virtualize}'s options. */
36
38
  export interface DataTableVirtualize {
@@ -302,6 +304,17 @@ export function useRowWindow<T>({
302
304
  );
303
305
 
304
306
  const lockSig = `${resetKey}|${scrollerWidth}|${colKeys.join(",")}`;
307
+ // A lock is the table it was measured on, so it goes as soon as the signature moves on
308
+ // — not merely set aside until the signature comes BACK (0.51, keksdose N2). The fit
309
+ // leaves a column out and, measuring the table afresh without it, brings it back in the
310
+ // same frame, before this hook measures anything: the signature was then the old one
311
+ // again, and the column came back at the widths that had ratcheted up while rows
312
+ // scrolled (a wider cell widens its column once), 80–110px more than the fit had just
313
+ // measured (606 + 93 estimated, 778–812 drawn, at 757), and B2's guard kept it out
314
+ // although it fitted. Dropped here, the table it comes back to is drawn as measured,
315
+ // and the next frame locks that. Adjusted during render, as React's "storing
316
+ // information from previous renders": an effect would draw the stale widths first.
317
+ if (lock !== null && lock.sig !== lockSig) setLock(null);
305
318
  const colWidths = lock && lock.sig === lockSig ? lock.widths : null;
306
319
 
307
320
  // What the scroll handler and the frame's measure read: this render's model and lock.
@@ -21,7 +21,8 @@ import { DEFAULT_CHARACTER_COUNT_LABELS, DEFAULT_PASSWORD_REVEAL_LABELS } from "
21
21
  * `useFieldError`, every field's.
22
22
  *
23
23
  * Public through ./ui by name. `FieldGroup`, the counter and the placeholder helpers are
24
- * the three fields' shared parts (`@internal`).
24
+ * the three fields' shared parts (`@internal`), as are the Select's own classes and its
25
+ * `SelectWidthFloor` (0.51).
25
26
  */
26
27
 
27
28
  /**
@@ -197,6 +198,57 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
197
198
  );
198
199
  }
199
200
 
201
+ /** A native dropdown Select's dress: no native arrow, and `pe-9`, the room the
202
+ * {@link FieldChevron} takes. Shared with {@link SelectWidthFloor}. `@internal` */
203
+ export const SELECT_MENU_DRESS = "appearance-none pe-9";
204
+
205
+ /** The compact Select (`size="sm"`): the field's colours, a toolbar button's height.
206
+ * `py-0` and a fixed height rather than a smaller padding, so the box is 28px whatever
207
+ * line height the caller's type brings with it. Shared with {@link SelectWidthFloor}.
208
+ * `@internal` */
209
+ export const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
210
+
211
+ /**
212
+ * The width an UNLABELLED Select needs to show the widest of `values` whole: an invisible,
213
+ * zero-height line under it, in the select's own type, padding, chevron room and border
214
+ * (the same classes), one value per line. Placed beside the Select in the box that sizes
215
+ * it, it holds that box to the widest value, and the `w-full` select fills it. `@internal`
216
+ *
217
+ * Why it takes a sizer (0.51, Kurvenschmiede N5). A `<select>` with a percentage width
218
+ * is a "compressible" element: its min-content contribution is ZERO (CSS Sizing 3
219
+ * §5.2.2), so the auto table layout squeezes its column to the header, and beside a
220
+ * `grow` column every other column IS squeezed to its min-content (data-table-fit.ts).
221
+ * The user roster's role select stood 92px and read "Meml…" and "Admi…" (KS review,
222
+ * 0.49 and 0.50). `min-width: max-content` on the select would hold it instead — but to
223
+ * its widest OPTION, and an unavailable role's option carries its reason ("Customer (owns
224
+ * work)", RoleSelect's `optionDisabledReason`): the column would widen for a text that is
225
+ * never the selected value. So the caller says which values can be selected, in the
226
+ * current language.
227
+ */
228
+ export function SelectWidthFloor({ values, size = "md" }: { values: readonly string[]; size?: "sm" | "md" }) {
229
+ if (values.length === 0) return null;
230
+ return (
231
+ <span
232
+ aria-hidden
233
+ data-select-floor=""
234
+ className={cn(
235
+ FIELD_BASE,
236
+ SELECT_MENU_DRESS,
237
+ size === "sm" && SELECT_SM,
238
+ // The horizontal box only: no height, no vertical padding or border, not painted.
239
+ "invisible h-0 overflow-hidden border-y-0 py-0 whitespace-nowrap shadow-none",
240
+ )}
241
+ >
242
+ {values.map((value, i) => (
243
+ // The value as generated content, not as text: it is laid out (and so measured)
244
+ // the same, but a test's `getByText("Member")` still finds the one option, and a
245
+ // find-in-page or a copied row does not meet a word nobody sees.
246
+ <span key={i} data-value={value} className="block before:content-[attr(data-value)]" />
247
+ ))}
248
+ </span>
249
+ );
250
+ }
251
+
200
252
  // Animated label that starts centred (as a placeholder) in an empty field and
201
253
  // floats up INSIDE the top strip on focus or once the field has a value. Sits on
202
254
  // the field's own surface, so no background chip and nothing to mismatch the card.
@@ -15,7 +15,6 @@ import { X } from "lucide-react";
15
15
 
16
16
  import { cn } from "../lib/cn";
17
17
  import { FOCUS_RING } from "./focus-ring";
18
- import { warnDeprecatedVariant } from "./button-look";
19
18
  import { dirOf, type Direction } from "../lib/direction";
20
19
  import { registerFloating } from "../lib/floating-stack";
21
20
  import { useFloatClearance } from "../lib/float-clearance";
@@ -142,14 +141,9 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
142
141
  * `accent`. `surface`: the page's surface with a hairline border — for a FAB that is a
143
142
  * status rather than a call to action, as kastlan's offline pill is (a brand pill would
144
143
  * shout "press me" at a user who is merely offline). Ignored while `pressed` is set; a
145
- * toggle has its own two looks. `primary` is a deprecated alias of `accent` (0.49; a
146
- * development build warns once), removed in 0.51.
144
+ * toggle has its own two looks.
147
145
  */
148
- variant?:
149
- | "accent"
150
- | "surface"
151
- /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */
152
- | "primary";
146
+ variant?: "accent" | "surface";
153
147
  /**
154
148
  * Hover and focus label in the kit {@link Tooltip}, instead of the native `title` —
155
149
  * keksdose has ONE tooltip (dev#523) and wrapped its assistant launcher by hand,
@@ -173,14 +167,6 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
173
167
  ref?: Ref<HTMLButtonElement>;
174
168
  }
175
169
 
176
- /** FloatingActionButton's and FloatingAction's `primary` was always the solid brand
177
- * fill: it reads as `accent` (warning once in development), never as Button's
178
- * `default`. */
179
- function deprecatedPrimary(component: string): "accent" {
180
- warnDeprecatedVariant(component, "primary", "accent");
181
- return "accent";
182
- }
183
-
184
170
  /**
185
171
  * A round, icon-only button pinned to a corner of the viewport — the trigger of a
186
172
  * {@link FloatingPanel}, and usable alone for a page's one primary action.
@@ -215,7 +201,7 @@ export function FloatingActionButton({
215
201
  pressed,
216
202
  extended = false,
217
203
  live = false,
218
- variant: givenVariant = "accent",
204
+ variant = "accent",
219
205
  tooltip,
220
206
  tooltipSide = "top",
221
207
  hideWhenOverlay = true,
@@ -230,7 +216,6 @@ export function FloatingActionButton({
230
216
  const overlay = useOverlayOpen(hideWhenOverlay);
231
217
  const hidden = hiddenProp || overlay || undefined;
232
218
  const [marker, dir] = usePortalDir();
233
- const variant = givenVariant === "primary" ? deprecatedPrimary("FloatingActionButton variant") : givenVariant;
234
219
  const toggle = pressed !== undefined;
235
220
  const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
236
221
  const wrapped = tip !== undefined && tip !== null;
@@ -700,15 +685,9 @@ export interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButto
700
685
  pressedStyle?: "tint" | "plain";
701
686
  /**
702
687
  * `accent`: the brand-filled member, for the group's main action (the /transactions
703
- * "+"), as Button's `accent`. `default` (default): the quiet one. `primary` is a
704
- * deprecated alias of `accent` (0.49; a development build warns once), removed no
705
- * earlier than 0.51.
688
+ * "+"), as Button's `accent`. `default` (default): the quiet one.
706
689
  */
707
- variant?:
708
- | "default"
709
- | "accent"
710
- /** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */
711
- | "primary";
690
+ variant?: "default" | "accent";
712
691
  /**
713
692
  * A count in a dot at the member's top end corner — the /transactions invoices link's
714
693
  * pending count. Nothing is drawn for `0` or less; above 99 it reads `99+`. The count
@@ -754,7 +733,7 @@ export function FloatingAction({
754
733
  icon,
755
734
  pressed,
756
735
  pressedStyle = "tint",
757
- variant: givenVariant = "default",
736
+ variant = "default",
758
737
  badge,
759
738
  badgeLabel,
760
739
  badgeTone = "warning",
@@ -773,7 +752,6 @@ export function FloatingAction({
773
752
  const counted = badge !== undefined && badge > 0;
774
753
  const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;
775
754
  const isLink = href !== undefined;
776
- const variant = givenVariant === "primary" ? deprecatedPrimary("FloatingAction variant") : givenVariant;
777
755
  const look = cn(
778
756
  "relative flex size-12 items-center justify-center transition-colors",
779
757
  "outline-none focus-visible:ring-inset",
@@ -28,9 +28,8 @@ import {
28
28
  HIT_AREA_LARGE,
29
29
  TOUCH_TARGET_LARGE,
30
30
  buttonVariantClasses,
31
- resolveButtonVariant,
32
31
  } from "./button-look";
33
- import type { ButtonVariant, CanonicalButtonVariant } from "./button-look";
32
+ import type { ButtonVariant } from "./button-look";
34
33
 
35
34
  // Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2
36
35
  // gap-2 — so rendering a bare glyph through it gives a small icon in a wide,
@@ -200,7 +199,7 @@ function iconButtonToneClass(tone: IconButtonTone, quiet: boolean | undefined):
200
199
  // and would out-rank a caller's plain `hover:` override), each variant pins its
201
200
  // RESTING look under `disabled:hover:`, which out-ranks any `hover:` by specificity
202
201
  // and only ever matches a disabled button.
203
- const ICON_BUTTON_DISABLED_REST: Record<CanonicalButtonVariant | "overlay" | "shutter", string> = {
202
+ const ICON_BUTTON_DISABLED_REST: Record<ButtonVariant | "overlay" | "shutter", string> = {
204
203
  default: "disabled:hover:bg-surface-2",
205
204
  secondary: "disabled:hover:bg-transparent",
206
205
  ghost: "disabled:hover:bg-transparent",
@@ -634,7 +633,6 @@ function iconButtonLook({
634
633
  /** The label is drawn beside the glyph (see `labelVisible`). */
635
634
  withText?: boolean;
636
635
  }) {
637
- const look = variant === "overlay" || variant === "shutter" ? variant : resolveButtonVariant(variant, "IconButton variant");
638
636
  return cn(
639
637
  ICON_BUTTON_BASE,
640
638
  withText
@@ -646,13 +644,13 @@ function iconButtonLook({
646
644
  withText ? TOUCH_TARGET_LARGE : ICON_BUTTON_TOUCH_LARGE[size],
647
645
  glyphSize !== undefined && ICON_BUTTON_GLYPH_SIZES[glyphSize],
648
646
  // After the size, so the overlay's `rounded-full` beats the small sizes' `rounded`.
649
- look === "overlay"
647
+ variant === "overlay"
650
648
  ? ICON_BUTTON_OVERLAY
651
- : look === "shutter"
649
+ : variant === "shutter"
652
650
  ? ICON_BUTTON_SHUTTER
653
- : buttonVariantClasses[look],
651
+ : buttonVariantClasses[variant],
654
652
  shape === "round" && "rounded-full",
655
- ICON_BUTTON_DISABLED_REST[look],
653
+ ICON_BUTTON_DISABLED_REST[variant],
656
654
  iconButtonToneClass(tone, quiet),
657
655
  toneColor !== undefined && typeof toneColor !== "string" && ICON_BUTTON_TONE_PAIR,
658
656
  );
@@ -18,6 +18,8 @@ import {
18
18
  FieldChevron,
19
19
  FieldGroup,
20
20
  FloatingField,
21
+ SELECT_MENU_DRESS,
22
+ SELECT_SM,
21
23
  fieldBelow,
22
24
  } from "./field-base";
23
25
 
@@ -88,11 +90,6 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
88
90
  ref?: Ref<HTMLSelectElement>;
89
91
  }
90
92
 
91
- // The compact select: the field's colours, a toolbar button's height. `py-0` and a
92
- // fixed height rather than a smaller padding, so the box is 28px whatever line
93
- // height the caller's type brings with it.
94
- const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
95
-
96
93
  /** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every
97
94
  * other key would open the list or step the value. */
98
95
  const LOCKED_SELECT_KEYS = new Set(["Tab", "Escape", "Shift"]);
@@ -171,7 +168,7 @@ export function Select(props: SelectProps) {
171
168
  // moves onto the rows instead (option padding counts toward the row height the
172
169
  // browser sizes the box by), and the box keeps no bottom padding: exactly `size`
173
170
  // whole rows, at any width.
174
- const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
171
+ const dress = listBox ? "overflow-y-auto [&_option]:py-1" : SELECT_MENU_DRESS;
175
172
  // Locked: focusable, but nothing opens the list or moves the value, and nothing is
176
173
  // reported. The no-op `onChange` keeps a controlled select's value where React put
177
174
  // it (and keeps React from warning about a `value` with no handler).
@@ -209,8 +209,15 @@ export interface StatTileProps
209
209
  * the whole list up to bring one row with it would put them between the headline
210
210
  * and its delta. Same shape as a sub-value, so the `tone` rules are the same.
211
211
  * Withheld while `loading`, like the sub-values.
212
+ *
213
+ * 0.51: or several, in order (keksdose #633). Its figure tiles carry two forward rows,
214
+ * "Committed <date>" (today plus what is scheduled) and "Projected <date>" (that plus the
215
+ * usual variable money), both of which belong with the headline and neither with the
216
+ * history below. The tile draws what it is given; which rows to give is the app's rule
217
+ * (keksdose shows both on every tile, live #455, even where Committed equals the figure
218
+ * so far).
212
219
  */
213
- projection?: StatTileSubValue;
220
+ projection?: StatTileSubValue | readonly StatTileSubValue[];
214
221
  /**
215
222
  * Smaller figures under the headline, label left and figure right.
216
223
  *
@@ -349,6 +356,9 @@ export function StatTile({
349
356
  const valueId = `${id}-value`;
350
357
  const deltaId = `${id}-delta`;
351
358
  const priv = sensitive ? "" : undefined;
359
+ // One projection or several (0.51), as a list either way.
360
+ const projections: readonly StatTileSubValue[] =
361
+ projection === undefined ? [] : isFigureList(projection) ? projection : [projection];
352
362
 
353
363
  const fmt =
354
364
  format ??
@@ -543,9 +553,11 @@ export function StatTile({
543
553
 
544
554
  {/* At Large the delta and description follow the figure at once, and the
545
555
  projection moves down with the other figures (see `subValues`). */}
546
- {!large && !loading && projection && (
547
- <dl className="mt-0.5">
548
- <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} />
556
+ {!large && !loading && projections.length > 0 && (
557
+ <dl className="mt-0.5 space-y-0.5">
558
+ {projections.map((p, i) => (
559
+ <FigureRow key={i} figure={p} tileTone={tone} show={show} priv={priv} />
560
+ ))}
549
561
  </dl>
550
562
  )}
551
563
 
@@ -553,11 +565,12 @@ export function StatTile({
553
565
  {description != null && <div className="mt-1 text-xs text-muted">{description}</div>}
554
566
  {!large && graphic && <div className="mt-2">{graphic}</div>}
555
567
 
556
- {!loading && ((large && projection) || (subValues && subValues.length > 0)) && (
568
+ {!loading && ((large && projections.length > 0) || (subValues && subValues.length > 0)) && (
557
569
  <dl data-slot="stat-tile-figures" className={cn("mt-1.5", large ? "space-y-1.5" : "space-y-0.5")}>
558
- {large && projection && (
559
- <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} stacked />
560
- )}
570
+ {large &&
571
+ projections.map((p, i) => (
572
+ <FigureRow key={`projection-${i}`} figure={p} tileTone={tone} show={show} priv={priv} stacked />
573
+ ))}
561
574
  {subValues?.map((sv, i) => (
562
575
  <FigureRow key={i} figure={sv} tileTone={tone} show={show} priv={priv} stacked={large} />
563
576
  ))}
@@ -575,7 +588,12 @@ export function StatTile({
575
588
  );
576
589
  }
577
590
 
578
- /** One label/value row of a `<dl>`: a sub-value, or the projection. */
591
+ /** `Array.isArray` narrows a readonly array to `any[]`; this keeps the element type. */
592
+ function isFigureList(p: StatTileSubValue | readonly StatTileSubValue[]): p is readonly StatTileSubValue[] {
593
+ return Array.isArray(p);
594
+ }
595
+
596
+ /** One label/value row of a `<dl>`: a sub-value, or a projection. */
579
597
  function FigureRow({
580
598
  figure,
581
599
  tileTone,
@@ -2036,6 +2036,8 @@ export function germanLabels(numberLocale: string): UiKitLabels {
2036
2036
  saving: "Wird gespeichert…",
2037
2037
  },
2038
2038
  dev: {
2039
+ menu: "Dev-Werkzeuge",
2040
+ overridesOn: "Überschreibungen aktiv",
2039
2041
  roleHeading: "Dev · Rolle",
2040
2042
  switchPlan: "Tarif wechseln",
2041
2043
  plan: (value) => `Tarif: ${value}`,
@@ -498,8 +498,8 @@ export interface UiKitLabels {
498
498
  * 403 page, the shared sections, the overview's tiles and the Tools list
499
499
  * (docs/admin-harmonization.md §3.1). */
500
500
  adminArea: AdminAreaLabels;
501
- /** 0.42.0: the top bar's shared parts — `TourMenu`, `SyncStatusIndicator`, `DevSwitchers`
502
- * (docs/top-bar-harmonization.md §3.1). */
501
+ /** 0.42.0: the top bar's shared parts — `TourMenu`, `SyncStatusIndicator`, `DevMenu`
502
+ * (0.42's `DevSwitchers`; docs/top-bar-harmonization.md §3.1, §2.7.1). */
503
503
  topBarParts: TopBarPartsLabels;
504
504
  /** 0.45.0: "Send a test mail to me" (docs/admin-harmonization.md §9.2.4). */
505
505
  mailTestCard: MailTestCardLabels;
@@ -1972,6 +1972,8 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
1972
1972
  saving: "Guardando…",
1973
1973
  },
1974
1974
  dev: {
1975
+ menu: "Herramientas de desarrollo",
1976
+ overridesOn: "Anulaciones activas",
1975
1977
  roleHeading: "Dev · rol",
1976
1978
  switchPlan: "Cambiar de plan",
1977
1979
  plan: (value) => `Plan: ${value}`,
@@ -2011,6 +2011,8 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
2011
2011
  saving: "Enregistrement…",
2012
2012
  },
2013
2013
  dev: {
2014
+ menu: "Outils de dev",
2015
+ overridesOn: "Forçages actifs",
2014
2016
  roleHeading: "Dev · rôle",
2015
2017
  switchPlan: "Changer de formule",
2016
2018
  plan: (value) => `Formule\u00a0: ${value}`,
@@ -1976,6 +1976,8 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
1976
1976
  saving: "Mentés…",
1977
1977
  },
1978
1978
  dev: {
1979
+ menu: "Fejlesztői eszközök",
1980
+ overridesOn: "Felülbírálások bekapcsolva",
1979
1981
  roleHeading: "Dev · szerepkör",
1980
1982
  switchPlan: "Csomag váltása",
1981
1983
  plan: (value) => `Csomag: ${value}`,
@@ -1991,6 +1991,8 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
1991
1991
  saving: "Salvataggio…",
1992
1992
  },
1993
1993
  dev: {
1994
+ menu: "Strumenti di sviluppo",
1995
+ overridesOn: "Override attivi",
1994
1996
  roleHeading: "Dev · ruolo",
1995
1997
  switchPlan: "Cambia piano",
1996
1998
  plan: (value) => `Piano: ${value}`,
@@ -1894,6 +1894,8 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1894
1894
  saving: "正在保存…",
1895
1895
  },
1896
1896
  dev: {
1897
+ menu: "开发工具",
1898
+ overridesOn: "覆盖已开启",
1897
1899
  roleHeading: "开发 · 角色",
1898
1900
  switchPlan: "切换套餐",
1899
1901
  plan: (value) => `套餐:${value}`,
package/src/index.ts CHANGED
@@ -1075,7 +1075,10 @@ export type {
1075
1075
  PublicFooterProps,
1076
1076
  } from "./landing/landing-sections";
1077
1077
  export { usePageSeo, seoCopyProblems, metaContent } from "./landing/page-seo";
1078
- export type { SeoCopy, SeoCopyLimits, SeoCopyProblem, SeoCopyProblemCode } from "./landing/page-seo";
1078
+ export type { PageSeo, SeoCopy, SeoCopyLimits, SeoCopyProblem, SeoCopyProblemCode } from "./landing/page-seo";
1079
+ // 0.51.0 (Marcel 2026-10-26): the frame of a public page that is not the landing or a legal page.
1080
+ export { PublicPage } from "./landing/public-page";
1081
+ export type { PublicPageProps } from "./landing/public-page";
1079
1082
 
1080
1083
  // ── demo ─────────────────────────────────────────────────────────────────────
1081
1084
  // 0.31.0 (docs/landing-demo-harmonization.md): the demo's start, banner, countdown and end.
@@ -6,6 +6,19 @@ import { useEffect } from "react";
6
6
  * and the two pure helpers each app's SEO test is built from.
7
7
  */
8
8
 
9
+ /** What {@link usePageSeo} declares for a page. */
10
+ export interface PageSeo {
11
+ canonicalPath: string;
12
+ title?: string;
13
+ description?: string;
14
+ /** Structured data for this page, as the object (not a string). Injected as an
15
+ * `application/ld+json` DATA BLOCK — a script element the browser never executes,
16
+ * which is also why it can be added here rather than in `index.html`: the CSP
17
+ * carries a HASH of the one inline script that file is allowed to have, and
18
+ * `csp-inline-script.test.ts` fails the build on a second one (live #250). */
19
+ jsonLd?: Record<string, unknown>;
20
+ }
21
+
9
22
  /**
10
23
  * Declares the current page's canonical URL — and optionally its title and meta
11
24
  * description — while the calling component is mounted (feedback #129).
@@ -28,23 +41,22 @@ import { useEffect } from "react";
28
41
  * landing calls it with `canonicalPath: "/"` on both `/` and `/welcome`, so the two URLs
29
42
  * of one page are one result; there are no per-language URLs and no hreflang.
30
43
  */
31
- export function usePageSeo({
32
- canonicalPath,
33
- title,
34
- description,
35
- jsonLd,
36
- }: {
37
- canonicalPath: string;
38
- title?: string;
39
- description?: string;
40
- /** Structured data for this page, as the object (not a string). Injected as an
41
- * `application/ld+json` DATA BLOCK — a script element the browser never executes,
42
- * which is also why it can be added here rather than in `index.html`: the CSP
43
- * carries a HASH of the one inline script that file is allowed to have, and
44
- * `csp-inline-script.test.ts` fails the build on a second one (live #250). */
45
- jsonLd?: Record<string, unknown>;
46
- }): void {
44
+ export function usePageSeo(seo: PageSeo): void {
45
+ usePageSeoIf(seo);
46
+ }
47
+
48
+ /**
49
+ * @internal {@link usePageSeo}, or nothing for `null` — for a component that declares a
50
+ * page only sometimes (`PublicPage`'s `seo`): hooks cannot be called conditionally, so the
51
+ * switch is the argument, as `useNoIndex(active)` has it.
52
+ */
53
+ export function usePageSeoIf(seo: PageSeo | null): void {
54
+ const canonicalPath = seo?.canonicalPath;
55
+ const title = seo?.title;
56
+ const description = seo?.description;
57
+ const jsonLd = seo?.jsonLd;
47
58
  useEffect(() => {
59
+ if (canonicalPath === undefined) return;
48
60
  const link = document.createElement("link");
49
61
  link.rel = "canonical";
50
62
  link.href = new URL(canonicalPath, window.location.origin).toString();