@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.
- package/README.md +14 -12
- package/dist/admin/user-roster.js +31 -12
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/verify-email.d.ts +4 -0
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +9 -0
- package/dist/components/alert-banner.js +9 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/autocomplete.js +13 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -19
- package/dist/components/button-look.js +3 -30
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/combobox.js +3 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-virtual.js +1 -0
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/field-base.d.ts +31 -1
- package/dist/components/field-base.js +28 -0
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/floating-panel.d.ts +6 -13
- package/dist/components/floating-panel.js +2 -9
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/icon-button.js +3 -5
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +12 -0
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/select.js +3 -2
- package/dist/components/select.js.map +1 -1
- package/dist/components/stat-tile.d.ts +8 -1
- package/dist/components/stat-tile.js +7 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/german.js +2 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +2 -0
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +2 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +2 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +2 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +2 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +2 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/page-seo.d.ts +19 -11
- package/dist/landing/page-seo.js +11 -7
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/landing/public-page.d.ts +57 -0
- package/dist/landing/public-page.js +45 -0
- package/dist/landing/public-page.js.map +1 -0
- package/dist/lib/quiet-focus.d.ts +4 -0
- package/dist/lib/quiet-focus.js +19 -0
- package/dist/lib/quiet-focus.js.map +1 -0
- package/dist/lib/server-wake.d.ts +3 -2
- package/dist/lib/server-wake.js +19 -13
- package/dist/lib/server-wake.js.map +1 -1
- package/dist/shell/app-shell.d.ts +2 -15
- package/dist/shell/app-shell.js +7 -26
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-menu.d.ts +98 -0
- package/dist/shell/dev-menu.js +133 -0
- package/dist/shell/dev-menu.js.map +1 -0
- package/dist/shell/dev-switchers.d.ts +6 -53
- package/dist/shell/dev-switchers.js +5 -100
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-actions.d.ts +4 -1
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +18 -9
- package/dist/shell/top-bar-parts-labels.js +2 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +17 -2
- package/dist/shell/topbar-action-menu.js +3 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +3 -1
- package/dist/shell.js +3 -1
- package/dist/shell.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/tour.d.ts +31 -0
- package/dist/testing/tour.js +12 -0
- package/dist/testing/tour.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +0 -3
- package/dist/theme/palette-presets.js +0 -2
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/tour/tour-engine.d.ts +12 -0
- package/dist/tour/tour-engine.js +31 -0
- package/dist/tour/tour-engine.js.map +1 -0
- package/dist/tour/tour.d.ts +14 -3
- package/dist/tour/tour.js +60 -45
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +5 -3
- package/dist/tour/use-tour-url-sync.js +21 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/user-roster.tsx +30 -10
- package/src/auth/verify-email.tsx +4 -0
- package/src/components/alert-banner.tsx +37 -10
- package/src/components/autocomplete.tsx +18 -0
- package/src/components/button-look.ts +7 -57
- package/src/components/combobox.tsx +5 -1
- package/src/components/data-table-fit.ts +4 -1
- package/src/components/data-table-virtual.ts +14 -1
- package/src/components/field-base.tsx +53 -1
- package/src/components/floating-panel.tsx +6 -28
- package/src/components/icon-button.tsx +6 -8
- package/src/components/inline-entity-combobox.tsx +17 -0
- package/src/components/select.tsx +3 -6
- package/src/components/stat-tile.tsx +27 -9
- package/src/i18n/german.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -2
- package/src/i18n/locales/es.ts +2 -0
- package/src/i18n/locales/fr.ts +2 -0
- package/src/i18n/locales/hu.ts +2 -0
- package/src/i18n/locales/it.ts +2 -0
- package/src/i18n/locales/zh.ts +2 -0
- package/src/index.ts +4 -1
- package/src/landing/page-seo.ts +28 -16
- package/src/landing/public-page.tsx +106 -0
- package/src/lib/quiet-focus.ts +32 -0
- package/src/lib/server-wake.ts +41 -17
- package/src/shell/app-shell.tsx +11 -48
- package/src/shell/dev-menu.tsx +259 -0
- package/src/shell/dev-switchers.tsx +13 -174
- package/src/shell/top-bar-actions.tsx +4 -1
- package/src/shell/top-bar-parts-labels.ts +20 -9
- package/src/shell/topbar-action-menu.tsx +28 -2
- package/src/shell.ts +5 -1
- package/src/testing/index.ts +8 -0
- package/src/testing/tour.ts +41 -0
- package/src/theme/palette-presets.ts +0 -4
- package/src/tour/tour-engine.ts +41 -0
- package/src/tour/tour.tsx +64 -24
- 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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
|
|
647
|
+
variant === "overlay"
|
|
650
648
|
? ICON_BUTTON_OVERLAY
|
|
651
|
-
:
|
|
649
|
+
: variant === "shutter"
|
|
652
650
|
? ICON_BUTTON_SHUTTER
|
|
653
|
-
: buttonVariantClasses[
|
|
651
|
+
: buttonVariantClasses[variant],
|
|
654
652
|
shape === "round" && "rounded-full",
|
|
655
|
-
ICON_BUTTON_DISABLED_REST[
|
|
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" :
|
|
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 &&
|
|
547
|
-
<dl className="mt-0.5">
|
|
548
|
-
|
|
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 &&
|
|
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 &&
|
|
559
|
-
|
|
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
|
-
/**
|
|
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,
|
package/src/i18n/german.ts
CHANGED
|
@@ -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}`,
|
package/src/i18n/kit-labels.tsx
CHANGED
|
@@ -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`, `
|
|
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;
|
package/src/i18n/locales/es.ts
CHANGED
|
@@ -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}`,
|
package/src/i18n/locales/fr.ts
CHANGED
|
@@ -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}`,
|
package/src/i18n/locales/hu.ts
CHANGED
|
@@ -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}`,
|
package/src/i18n/locales/it.ts
CHANGED
|
@@ -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}`,
|
package/src/i18n/locales/zh.ts
CHANGED
|
@@ -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.
|
package/src/landing/page-seo.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
}
|