@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
@@ -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
  );
@@ -8,6 +8,7 @@ import { cn } from "../lib/cn";
8
8
  import { useDropdown } from "./dropdown";
9
9
  import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
10
10
  import { isImeEnter } from "../lib/ime";
11
+ import { isQuietFocus } from "../lib/quiet-focus";
11
12
  import { usePhoneLayout } from "../hooks/use-breakpoint";
12
13
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
13
14
  import {
@@ -255,6 +256,8 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
255
256
  // Escape layer is this field's own.
256
257
  const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone, escapeLayer: false });
257
258
  const primaryOnly = usePrimaryPressOnly();
259
+ // The field took the tour's quiet focus (§11.8) and has not opened since.
260
+ const quietRef = useRef(false);
258
261
  const [active, setActive] = useState(-1);
259
262
  // null = not editing → the input shows the selected option's label.
260
263
  const [text, setText] = useState<string | null>(null);
@@ -508,13 +511,27 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
508
511
  // say why, and opens nothing.
509
512
  if (primaryOnly.fromAuxButton() || locked) return;
510
513
  setText(shown);
514
+ // The tour's focus on arrival (§11.8) opens nothing: the list would cover
515
+ // its card (0.50.1). A click, ArrowDown or typing opens it.
516
+ if (isQuietFocus(e.currentTarget)) {
517
+ quietRef.current = true;
518
+ return;
519
+ }
511
520
  e.currentTarget.select();
512
521
  setOpen(true);
513
522
  // The sheet has its own input; the field behind it must not also pull up
514
523
  // the keyboard and scroll the page under the dialog.
515
524
  if (isPhone) sheetInputRef.current?.focus();
516
525
  }}
526
+ // After a quiet focus, the person's click on the already-focused field
527
+ // fires no `focus`: the click is their "open" instead.
528
+ onClick={() => {
529
+ if (!quietRef.current || locked) return;
530
+ quietRef.current = false;
531
+ setOpen(true);
532
+ }}
517
533
  onBlur={() => {
534
+ quietRef.current = false;
518
535
  // On a phone the blur is the SHEET taking focus, not the user leaving the
519
536
  // field — reconciling there would close the sheet the instant it opened.
520
537
  if (!isPhone) reconcile();
@@ -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();
@@ -0,0 +1,106 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+ import { useNoIndex } from "../hooks/use-noindex";
5
+ import { PublicFooter } from "./landing-sections";
6
+ import { usePageSeoIf, type PageSeo } from "./page-seo";
7
+
8
+ export interface PublicPageProps extends Omit<ComponentPropsWithRef<"div">, "title" | "children"> {
9
+ /** The app's public header — the one a signed-out visitor already sees on the landing
10
+ * (`PublicHeader`, keksdose's `LandingHeader`, Kurvenschmiede's). */
11
+ header?: ReactNode;
12
+ /** Default: `<PublicFooter />`, the three legal links (§3.3: one click away from every
13
+ * page a visitor can reach). Pass your own `PublicFooter` for a tagline; `null` for none. */
14
+ footer?: ReactNode;
15
+ /** The page's heading. */
16
+ title: ReactNode;
17
+ /** One or two sentences under the title: what the page is. */
18
+ description?: ReactNode;
19
+ /** Above the title — the app's "← Back" link (`<TextLink icon={ArrowLeft}>`). */
20
+ back?: ReactNode;
21
+ /** Beside the title from `sm` up, under it on a phone: the page's own controls (a
22
+ * region and a month picker on keksdose's /prices). */
23
+ actions?: ReactNode;
24
+ /**
25
+ * What search engines are told, and it has no default on purpose: a public page is
26
+ * either one to find — then it names its canonical path (and title, description,
27
+ * structured data), through {@link usePageSeo} — or one to keep out of the index
28
+ * (`"noindex"`: a 404, a page a link was sent for). `null` declares nothing, for a page
29
+ * that is not the page: a preview inside another (the showcase).
30
+ */
31
+ seo: PageSeo | "noindex" | null;
32
+ /** The column. `"wide"` (default) is the landing's sections' width, for tiles and
33
+ * tables; `"prose"` the legal pages' reading column. */
34
+ width?: "wide" | "prose";
35
+ /** Default `h1`; a lower level for a page shown inside another (a preview), as the
36
+ * landing's sections take one. */
37
+ headingAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
38
+ /** `false` draws the column as a `<div>` instead of `<main>`, for a host that has its
39
+ * own `<main>`. Default true. */
40
+ landmark?: boolean;
41
+ children: ReactNode;
42
+ }
43
+
44
+ const WIDTH = { wide: "max-w-6xl", prose: "max-w-3xl" } as const;
45
+
46
+ /**
47
+ * A public page that is not the landing and not a legal page (0.51, Marcel 2026-10-26):
48
+ * keksdose's regional price snapshot at /prices, and Kurvenschmiede's public pages. The
49
+ * frame every public page has — the app's public header, a column, the public footer — is
50
+ * {@link LegalPage}'s, without the legal parts: no numbered sections, no beta notice, no
51
+ * "Back to home" of its own, and indexed unless it says otherwise.
52
+ *
53
+ * keksdose was about to compose it by hand (`PublicHeader` + a column + `PublicFooter` +
54
+ * `usePageSeo`) for /prices, and Kurvenschmiede will have its next public page outside the
55
+ * shell; each app would have drawn the frame its own way. (KS's visitor pages — /dashboard,
56
+ * /control, /dynamics, /steering — are working screens and stay in the app shell, with
57
+ * `VisitorLegalFooter`; this frame is not for them.)
58
+ * The width is the landing's (`max-w-6xl`), so a link from a feature row lands on a page
59
+ * whose edges line up with the one it came from.
60
+ *
61
+ * `seo` is required: a public page is found or it is not, and forgetting the canonical on
62
+ * a page meant to be found is silent. The kit's words are none — the title, the copy and
63
+ * the back link are the app's.
64
+ */
65
+ export function PublicPage({
66
+ header,
67
+ footer,
68
+ title,
69
+ description,
70
+ back,
71
+ actions,
72
+ seo,
73
+ width = "wide",
74
+ headingAs: Heading = "h1",
75
+ landmark = true,
76
+ className,
77
+ children,
78
+ ...rest
79
+ }: PublicPageProps) {
80
+ useNoIndex(seo === "noindex");
81
+ usePageSeoIf(seo === "noindex" ? null : seo);
82
+ const Main = landmark ? "main" : "div";
83
+ return (
84
+ <div {...rest} data-slot="public-page" className={cn("flex min-h-full flex-col", className)}>
85
+ {header}
86
+ <Main className="flex-1">
87
+ <div data-slot="public-page-column" className={cn("mx-auto w-full px-4 py-12", WIDTH[width])}>
88
+ {back != null && <div className="mb-4 text-sm">{back}</div>}
89
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
90
+ <div className="min-w-0">
91
+ <Heading className="text-3xl font-bold tracking-tight break-words text-primary">{title}</Heading>
92
+ {description != null && <p className="mt-3 max-w-2xl text-lg text-secondary">{description}</p>}
93
+ </div>
94
+ {actions != null && (
95
+ <div data-slot="public-page-actions" className="flex shrink-0 flex-wrap items-center gap-2">
96
+ {actions}
97
+ </div>
98
+ )}
99
+ </div>
100
+ <div className="mt-8 space-y-8">{children}</div>
101
+ </div>
102
+ </Main>
103
+ {footer === undefined ? <PublicFooter /> : footer}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Focus that is not a request to open anything (0.50.1, keksdose B1).
3
+ *
4
+ * A guided tour focuses a field step's first control on arrival (tours §11.8). The kit's
5
+ * pickers that open their list on focus — Combobox (and the category pickers built on it),
6
+ * Autocomplete, InlineEntityCombobox — opened it then too, and the list, a later layer
7
+ * (§11.6), covered the tour card with its Next and "Leave empty".
8
+ *
9
+ * {@link focusQuietly} marks the element for the length of its synchronous `focus()` call;
10
+ * a picker's `onFocus` asks {@link isQuietFocus} and, when it is, takes the focus without
11
+ * opening. React dispatches `onFocus` inside the native `focusin`, which `focus()` fires
12
+ * synchronously, so the mark is seen by exactly that focus and by no later one. A click or
13
+ * Tab of the person's own opens as before; from a quiet focus, ArrowDown, typing or a click
14
+ * opens the list.
15
+ */
16
+ let quiet: Element | null = null;
17
+
18
+ /** `el.focus(options)`, telling the kit's open-on-focus pickers not to open. */
19
+ export function focusQuietly(el: HTMLElement, options?: FocusOptions): void {
20
+ const before = quiet;
21
+ quiet = el;
22
+ try {
23
+ el.focus(options);
24
+ } finally {
25
+ quiet = before;
26
+ }
27
+ }
28
+
29
+ /** True while `el` is being focused by {@link focusQuietly}. */
30
+ export function isQuietFocus(el: Element): boolean {
31
+ return quiet === el;
32
+ }