@eifi1/ui-kit 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/README.md +42 -5
  2. package/dist/account/data-export-setting.js +3 -2
  3. package/dist/account/data-export-setting.js.map +1 -1
  4. package/dist/account/delete-account-setting.d.ts +5 -1
  5. package/dist/account/delete-account-setting.js +3 -2
  6. package/dist/account/delete-account-setting.js.map +1 -1
  7. package/dist/account/email-change-setting.js +4 -3
  8. package/dist/account/email-change-setting.js.map +1 -1
  9. package/dist/account/sessions-setting.js +3 -2
  10. package/dist/account/sessions-setting.js.map +1 -1
  11. package/dist/admin/admin-action-confirm.d.ts +5 -1
  12. package/dist/admin/invitations-panel.d.ts +5 -1
  13. package/dist/admin/invitations-panel.js +3 -2
  14. package/dist/admin/invitations-panel.js.map +1 -1
  15. package/dist/admin/reviewer-scope-editor.d.ts +5 -1
  16. package/dist/admin/role-select.d.ts +5 -1
  17. package/dist/admin/transfer-ownership-dialog.d.ts +5 -1
  18. package/dist/admin/user-roster.d.ts +5 -1
  19. package/dist/auth/auth-errors.d.ts +24 -6
  20. package/dist/auth/auth-errors.js +7 -1
  21. package/dist/auth/auth-errors.js.map +1 -1
  22. package/dist/auth/register-form.d.ts +5 -1
  23. package/dist/auth/sign-in-form.d.ts +15 -1
  24. package/dist/auth/sign-in-form.js +12 -3
  25. package/dist/auth/sign-in-form.js.map +1 -1
  26. package/dist/chart.d.ts +5 -1
  27. package/dist/components/account-settings-labels.d.ts +12 -0
  28. package/dist/components/account-settings-labels.js +2 -1
  29. package/dist/components/account-settings-labels.js.map +1 -1
  30. package/dist/components/account-settings.d.ts +8 -2
  31. package/dist/components/account-settings.js +19 -11
  32. package/dist/components/account-settings.js.map +1 -1
  33. package/dist/components/amount-input.d.ts +5 -1
  34. package/dist/components/button-group.d.ts +5 -1
  35. package/dist/components/calculator.d.ts +5 -1
  36. package/dist/components/confirm-dialog.d.ts +5 -1
  37. package/dist/components/copy-button.d.ts +5 -1
  38. package/dist/components/danger-confirm.d.ts +5 -1
  39. package/dist/components/data-table.js +8 -1
  40. package/dist/components/data-table.js.map +1 -1
  41. package/dist/components/date-picker.js +9 -4
  42. package/dist/components/date-picker.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +5 -1
  44. package/dist/components/file-button.d.ts +5 -1
  45. package/dist/components/file-dropzone.d.ts +5 -1
  46. package/dist/components/form-actions.d.ts +5 -1
  47. package/dist/components/iban-input.d.ts +5 -1
  48. package/dist/components/language-select.d.ts +5 -1
  49. package/dist/components/legal-page.d.ts +5 -1
  50. package/dist/components/legal.d.ts +5 -1
  51. package/dist/components/money-field.d.ts +5 -1
  52. package/dist/components/number-field.d.ts +5 -1
  53. package/dist/components/number-input.d.ts +5 -1
  54. package/dist/components/numpad-sheet.d.ts +5 -1
  55. package/dist/components/passkeys-setting.js +2 -1
  56. package/dist/components/passkeys-setting.js.map +1 -1
  57. package/dist/components/phone-input.d.ts +5 -1
  58. package/dist/components/series-chart.d.ts +5 -1
  59. package/dist/components/settings-fields.d.ts +40 -3
  60. package/dist/components/settings-fields.js +20 -1
  61. package/dist/components/settings-fields.js.map +1 -1
  62. package/dist/components/share-card.d.ts +5 -1
  63. package/dist/components/text-link.d.ts +5 -1
  64. package/dist/components/time-input.d.ts +5 -1
  65. package/dist/components/ui.d.ts +5 -1
  66. package/dist/{delete-account-setting-CGVMckMw.d.ts → delete-account-setting-DCFS5Vof.d.ts} +52 -23
  67. package/dist/demo/demo-banner.d.ts +51 -0
  68. package/dist/demo/demo-banner.js +97 -0
  69. package/dist/demo/demo-banner.js.map +1 -0
  70. package/dist/demo/demo-countdown.d.ts +58 -0
  71. package/dist/demo/demo-countdown.js +79 -0
  72. package/dist/demo/demo-countdown.js.map +1 -0
  73. package/dist/demo/demo-ended.d.ts +45 -0
  74. package/dist/demo/demo-ended.js +42 -0
  75. package/dist/demo/demo-ended.js.map +1 -0
  76. package/dist/demo/demo-labels.d.ts +53 -0
  77. package/dist/demo/demo-labels.js +29 -0
  78. package/dist/demo/demo-labels.js.map +1 -0
  79. package/dist/demo/demo-session.d.ts +30 -0
  80. package/dist/demo/demo-session.js +8 -0
  81. package/dist/demo/demo-session.js.map +1 -0
  82. package/dist/demo/demo-start.d.ts +54 -0
  83. package/dist/demo/demo-start.js +68 -0
  84. package/dist/demo/demo-start.js.map +1 -0
  85. package/dist/feedback/feedback-attachment.d.ts +5 -1
  86. package/dist/feedback/feedback-context.d.ts +5 -1
  87. package/dist/feedback/feedback-crash.d.ts +5 -1
  88. package/dist/feedback/feedback-dialog.d.ts +5 -1
  89. package/dist/feedback/feedback-inbox.d.ts +5 -1
  90. package/dist/feedback/feedback-inbox.js +12 -2
  91. package/dist/feedback/feedback-inbox.js.map +1 -1
  92. package/dist/feedback/feedback-labels.d.ts +5 -1
  93. package/dist/feedback/feedback-labels.js +1 -0
  94. package/dist/feedback/feedback-labels.js.map +1 -1
  95. package/dist/feedback/feedback-menu.d.ts +5 -1
  96. package/dist/feedback/feedback-record.d.ts +5 -1
  97. package/dist/feedback/feedback-record.js +2 -0
  98. package/dist/feedback/feedback-record.js.map +1 -1
  99. package/dist/feedback/feedback-row-detail.d.ts +5 -1
  100. package/dist/feedback/feedback-row-detail.js +7 -0
  101. package/dist/feedback/feedback-row-detail.js.map +1 -1
  102. package/dist/feedback/feedback-status-undo.d.ts +5 -1
  103. package/dist/feedback/feedback-submit.d.ts +5 -1
  104. package/dist/feedback/feedback-swipe.d.ts +6 -2
  105. package/dist/feedback/feedback-swipe.js.map +1 -1
  106. package/dist/feedback/feedback-table.d.ts +5 -1
  107. package/dist/feedback/feedback-thread.d.ts +5 -1
  108. package/dist/feedback.d.ts +5 -1
  109. package/dist/hooks/use-file-drop.d.ts +5 -1
  110. package/dist/i18n/defaults.d.ts +5 -1
  111. package/dist/i18n/defaults.js +6 -0
  112. package/dist/i18n/defaults.js.map +1 -1
  113. package/dist/i18n/german.d.ts +5 -1
  114. package/dist/i18n/german.js +84 -2
  115. package/dist/i18n/german.js.map +1 -1
  116. package/dist/i18n/kit-labels.d.ts +5 -1
  117. package/dist/i18n/kit-labels.js.map +1 -1
  118. package/dist/i18n/languages.d.ts +5 -1
  119. package/dist/i18n/locales/de-CH.d.ts +5 -1
  120. package/dist/i18n/locales/en.d.ts +5 -1
  121. package/dist/i18n/locales/es.d.ts +5 -1
  122. package/dist/i18n/locales/es.js +81 -2
  123. package/dist/i18n/locales/es.js.map +1 -1
  124. package/dist/i18n/locales/fr.d.ts +5 -1
  125. package/dist/i18n/locales/fr.js +82 -2
  126. package/dist/i18n/locales/fr.js.map +1 -1
  127. package/dist/i18n/locales/hu.d.ts +5 -1
  128. package/dist/i18n/locales/hu.js +82 -2
  129. package/dist/i18n/locales/hu.js.map +1 -1
  130. package/dist/i18n/locales/it.d.ts +5 -1
  131. package/dist/i18n/locales/it.js +81 -2
  132. package/dist/i18n/locales/it.js.map +1 -1
  133. package/dist/i18n/locales/zh.d.ts +5 -1
  134. package/dist/i18n/locales/zh.js +80 -2
  135. package/dist/i18n/locales/zh.js.map +1 -1
  136. package/dist/i18n/review.d.ts +5 -1
  137. package/dist/i18n/review.js +14 -1
  138. package/dist/i18n/review.js.map +1 -1
  139. package/dist/index.d.ts +25 -3
  140. package/dist/index.js +95 -0
  141. package/dist/index.js.map +1 -1
  142. package/dist/landing/access.d.ts +48 -0
  143. package/dist/landing/access.js +38 -0
  144. package/dist/landing/access.js.map +1 -0
  145. package/dist/landing/landing-actions.d.ts +138 -0
  146. package/dist/landing/landing-actions.js +39 -0
  147. package/dist/landing/landing-actions.js.map +1 -0
  148. package/dist/landing/landing-labels.d.ts +43 -0
  149. package/dist/landing/landing-labels.js +23 -0
  150. package/dist/landing/landing-labels.js.map +1 -0
  151. package/dist/landing/landing-sections.d.ts +218 -0
  152. package/dist/landing/landing-sections.js +167 -0
  153. package/dist/landing/landing-sections.js.map +1 -0
  154. package/dist/landing/page-seo.d.ts +99 -0
  155. package/dist/landing/page-seo.js +121 -0
  156. package/dist/landing/page-seo.js.map +1 -0
  157. package/dist/landing/public-header.d.ts +148 -0
  158. package/dist/landing/public-header.js +49 -0
  159. package/dist/landing/public-header.js.map +1 -0
  160. package/dist/landing/routing.d.ts +218 -0
  161. package/dist/landing/routing.js +81 -0
  162. package/dist/landing/routing.js.map +1 -0
  163. package/dist/rhf/fields.d.ts +5 -1
  164. package/dist/rhf/form.d.ts +5 -1
  165. package/dist/rhf.d.ts +5 -1
  166. package/dist/settings/settings-catalogue.d.ts +84 -0
  167. package/dist/settings/settings-catalogue.js +47 -0
  168. package/dist/settings/settings-catalogue.js.map +1 -0
  169. package/dist/settings/settings-context.d.ts +31 -0
  170. package/dist/settings/settings-context.js +11 -0
  171. package/dist/settings/settings-context.js.map +1 -0
  172. package/dist/settings/settings-heading.d.ts +50 -0
  173. package/dist/settings/settings-heading.js +27 -0
  174. package/dist/settings/settings-heading.js.map +1 -0
  175. package/dist/settings/settings-labels.d.ts +41 -0
  176. package/dist/settings/settings-labels.js +38 -0
  177. package/dist/settings/settings-labels.js.map +1 -0
  178. package/dist/settings/settings-layout.d.ts +77 -0
  179. package/dist/settings/settings-layout.js +242 -0
  180. package/dist/settings/settings-layout.js.map +1 -0
  181. package/dist/settings/settings-section.d.ts +133 -0
  182. package/dist/settings/settings-section.js +63 -0
  183. package/dist/settings/settings-section.js.map +1 -0
  184. package/dist/settings/use-account-language.d.ts +83 -0
  185. package/dist/settings/use-account-language.js +62 -0
  186. package/dist/settings/use-account-language.js.map +1 -0
  187. package/dist/settings/use-settings-focus.d.ts +34 -0
  188. package/dist/settings/use-settings-focus.js +77 -0
  189. package/dist/settings/use-settings-focus.js.map +1 -0
  190. package/dist/settings/use-settings-route.d.ts +118 -0
  191. package/dist/settings/use-settings-route.js +120 -0
  192. package/dist/settings/use-settings-route.js.map +1 -0
  193. package/dist/shell/app-shell.d.ts +11 -1
  194. package/dist/shell/app-shell.js +3 -2
  195. package/dist/shell/app-shell.js.map +1 -1
  196. package/dist/shell/top-bar-brand.d.ts +5 -1
  197. package/dist/shell.d.ts +15 -1
  198. package/dist/shell.js +56 -0
  199. package/dist/shell.js.map +1 -1
  200. package/dist/wizard/stepper-nav.d.ts +5 -1
  201. package/dist/wizard.d.ts +5 -1
  202. package/package.json +2 -1
  203. package/src/account/data-export-setting.tsx +3 -2
  204. package/src/account/delete-account-setting.tsx +3 -2
  205. package/src/account/email-change-setting.tsx +4 -7
  206. package/src/account/sessions-setting.tsx +3 -2
  207. package/src/admin/invitations-panel.tsx +11 -4
  208. package/src/auth/auth-errors.ts +36 -5
  209. package/src/auth/sign-in-form.tsx +34 -3
  210. package/src/components/account-settings-labels.ts +13 -0
  211. package/src/components/account-settings.tsx +33 -12
  212. package/src/components/data-table.tsx +8 -0
  213. package/src/components/date-picker.tsx +9 -4
  214. package/src/components/passkeys-setting.tsx +2 -1
  215. package/src/components/settings-fields.tsx +50 -1
  216. package/src/demo/demo-banner.tsx +159 -0
  217. package/src/demo/demo-countdown.ts +141 -0
  218. package/src/demo/demo-ended.tsx +89 -0
  219. package/src/demo/demo-labels.ts +78 -0
  220. package/src/demo/demo-session.ts +32 -0
  221. package/src/demo/demo-start.tsx +150 -0
  222. package/src/feedback/feedback-inbox.tsx +21 -8
  223. package/src/feedback/feedback-labels.ts +2 -1
  224. package/src/feedback/feedback-record.ts +10 -7
  225. package/src/feedback/feedback-row-detail.tsx +28 -4
  226. package/src/feedback/feedback-swipe.tsx +1 -1
  227. package/src/i18n/defaults.ts +6 -0
  228. package/src/i18n/german.ts +86 -0
  229. package/src/i18n/kit-labels.tsx +9 -0
  230. package/src/i18n/locales/es.ts +83 -0
  231. package/src/i18n/locales/fr.ts +84 -0
  232. package/src/i18n/locales/hu.ts +84 -0
  233. package/src/i18n/locales/it.ts +83 -0
  234. package/src/i18n/locales/zh.ts +81 -0
  235. package/src/i18n/review.ts +13 -0
  236. package/src/index.ts +92 -1
  237. package/src/landing/access.ts +97 -0
  238. package/src/landing/landing-actions.tsx +92 -0
  239. package/src/landing/landing-labels.ts +59 -0
  240. package/src/landing/landing-sections.tsx +337 -0
  241. package/src/landing/page-seo.ts +251 -0
  242. package/src/landing/public-header.tsx +120 -0
  243. package/src/landing/routing.tsx +209 -0
  244. package/src/settings/settings-catalogue.ts +132 -0
  245. package/src/settings/settings-context.ts +33 -0
  246. package/src/settings/settings-heading.tsx +65 -0
  247. package/src/settings/settings-labels.ts +75 -0
  248. package/src/settings/settings-layout.tsx +379 -0
  249. package/src/settings/settings-section.tsx +108 -0
  250. package/src/settings/use-account-language.ts +146 -0
  251. package/src/settings/use-settings-focus.ts +120 -0
  252. package/src/settings/use-settings-route.ts +258 -0
  253. package/src/shell/app-shell.tsx +10 -2
  254. package/src/shell.ts +45 -0
@@ -0,0 +1,59 @@
1
+ import { useKitLabels } from "../i18n/kit-labels";
2
+
3
+ /**
4
+ * The kit's words on the public landing page (docs/landing-demo-harmonization.md §4.1,
5
+ * §4.2) — the `landing` namespace of `<UiKitProvider labels>`.
6
+ *
7
+ * Only the GENERIC words are the kit's: the actions every app's header, hero and CTA band
8
+ * share, the "Beta" badge, and the template of the request-access mail. The marketing
9
+ * copy (the hero's title, the feature rows, the trust strip, the tagline) is each app's,
10
+ * in every language it ships (§4.1 "Words").
11
+ *
12
+ * The demo parts and `SignInForm` read the access words from here too, so "Request
13
+ * access" and its mail are said the same way on the landing, the sign-in page, the demo
14
+ * start, the banner and the end page.
15
+ */
16
+ export interface LandingLabels {
17
+ /** The header's secondary action without a session; also the demo banner's and the
18
+ * end page's way to an existing account. */
19
+ signIn: string;
20
+ /** The access action of an invitation-only app: a mail to support (§2.2, §4.2). */
21
+ requestAccess: string;
22
+ /** The access action once an app opens registration (`{kind: "register"}`, §4.2). */
23
+ getStarted: string;
24
+ /** The hero's and the CTA band's second action without a session. */
25
+ tryDemo: string;
26
+ /** The one action for a real session: into the app, at the resume target. */
27
+ openApp: string;
28
+ /** The secondary action for a demo session: back into the live demo. */
29
+ continueDemo: string;
30
+ /** The hero's optional badge. */
31
+ beta: string;
32
+ /** The request mail's subject, given the app's name (`access.app`): "Access to
33
+ * Kastlan". `undefined` when the app named none. */
34
+ accessSubject: (app?: string) => string;
35
+ /** The request mail's body, one prompt per line; the sender writes after each. */
36
+ accessName: string;
37
+ /** Asked only where the app says so (`askCompany`, kastlan). */
38
+ accessCompany: string;
39
+ accessUse: string;
40
+ }
41
+
42
+ export const DEFAULT_LANDING_LABELS: LandingLabels = {
43
+ signIn: "Sign in",
44
+ requestAccess: "Request access",
45
+ getStarted: "Get started",
46
+ tryDemo: "Try the demo",
47
+ openApp: "Open app",
48
+ continueDemo: "Continue the demo",
49
+ beta: "Beta",
50
+ accessSubject: (app) => (app ? `Access to ${app}` : "Access request"),
51
+ accessName: "Name:",
52
+ accessCompany: "Company:",
53
+ accessUse: "What would you use it for:",
54
+ };
55
+
56
+ /** The `landing` namespace, resolved: English, then the provider, then `labels`. */
57
+ export function useLandingLabels(labels?: Partial<LandingLabels>): LandingLabels {
58
+ return useKitLabels("landing", DEFAULT_LANDING_LABELS, labels);
59
+ }
@@ -0,0 +1,337 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+ import { Chip } from "../components/chip";
5
+ import { LegalFooter } from "../components/legal";
6
+ import type { LegalFooterProps } from "../components/legal";
7
+ import { LandingActions } from "./landing-actions";
8
+ import type { LandingActionsProps } from "./landing-actions";
9
+ import { useLandingLabels } from "./landing-labels";
10
+ import type { LandingLabels } from "./landing-labels";
11
+
12
+ /**
13
+ * The sections of the public landing page (docs/landing-demo-harmonization.md §4.1),
14
+ * top to bottom: `Hero`, `FeatureRows` of `FeatureRow`, `TrustStrip`, `CtaBand`,
15
+ * `PublicFooter` — keksdose's landing-page.tsx, taken apart.
16
+ *
17
+ * The kit draws the frame; every word of marketing copy is the app's. Colours are the
18
+ * kit's tokens (`--brand`, `--text-*`, `--bg-surface`, `--border`), so keksdose's ten
19
+ * hard-coded teal sites follow the palette now (§2.11).
20
+ *
21
+ * **Visuals are decorative** (§4.1): the `visual` slots are `aria-hidden` and `inert` —
22
+ * mock UI drawn with kit tokens, never screenshots with real data, and never anything a
23
+ * keyboard could land on. The copy beside each one carries the information. A mock's
24
+ * amounts use the demo data's currency, so the landing shows what "Try the demo" opens.
25
+ */
26
+
27
+ type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
28
+
29
+ /** The decorative slot: hidden from assistive tech and out of the tab order. */
30
+ function Visual({ children, className }: { children: ReactNode; className?: string }) {
31
+ return (
32
+ <div aria-hidden inert data-slot="landing-visual" className={cn("mx-auto w-full select-none", className)}>
33
+ {children}
34
+ </div>
35
+ );
36
+ }
37
+
38
+ /** The action pair's props, as `Hero` and `CtaBand` pass them on. */
39
+ type ActionProps = Pick<LandingActionsProps, "session" | "access" | "demoHref" | "openAppHref">;
40
+
41
+ /* ── Hero ────────────────────────────────────────────────────────────────── */
42
+
43
+ export interface HeroProps extends Omit<ComponentPropsWithoutRef<"section">, "title" | "children">, ActionProps {
44
+ /** The page's `h1`: what the app does, in a line. */
45
+ title: ReactNode;
46
+ /** Under it: for whom, and why. */
47
+ subtitle?: ReactNode;
48
+ /** The kit's "Beta" chip above the title. */
49
+ beta?: boolean;
50
+ /** A badge of the app's own in that place; wins over `beta`. */
51
+ badge?: ReactNode;
52
+ /** Under the actions, quiet: "Free while in beta. Your data stays yours." */
53
+ trust?: ReactNode;
54
+ /** The product glimpse beside the copy (below it on a phone). Decorative. */
55
+ visual?: ReactNode;
56
+ /** Replaces the action pair ({@link LandingActions}). */
57
+ actions?: ReactNode;
58
+ /** Default `h1`; lower where the hero is embedded in another page. */
59
+ headingAs?: HeadingLevel;
60
+ labels?: Partial<LandingLabels>;
61
+ }
62
+
63
+ /**
64
+ * The top of the landing (§4.1.2): an optional "Beta" badge, the `h1`, a subtitle, the
65
+ * action pair for the visitor's state ({@link LandingActions}: "Request access" and "Try
66
+ * the demo", or "Open app" for a session), a trust line, and a visual. Copy left and
67
+ * visual right from `lg` up, stacked below.
68
+ */
69
+ export function Hero({
70
+ title,
71
+ subtitle,
72
+ beta = false,
73
+ badge,
74
+ trust,
75
+ visual,
76
+ actions,
77
+ headingAs: Heading = "h1",
78
+ session,
79
+ access,
80
+ demoHref,
81
+ openAppHref,
82
+ labels: labelsProp,
83
+ className,
84
+ ...rest
85
+ }: HeroProps) {
86
+ const labels = useLandingLabels(labelsProp);
87
+ const shownBadge =
88
+ badge ??
89
+ (beta ? (
90
+ // keksdose's chip, padded out to the hero's scale.
91
+ <Chip size="sm" tone="warning" className="px-3 py-1 font-medium">
92
+ {labels.beta}
93
+ </Chip>
94
+ ) : null);
95
+ return (
96
+ <section
97
+ {...rest}
98
+ data-slot="landing-hero"
99
+ className={cn(
100
+ "mx-auto grid max-w-6xl items-center gap-12 px-4 py-16 sm:py-24",
101
+ visual != null && "lg:grid-cols-2",
102
+ className,
103
+ )}
104
+ >
105
+ <div>
106
+ {shownBadge != null && <div className="mb-4">{shownBadge}</div>}
107
+ <Heading className="text-4xl font-bold tracking-tight text-balance text-[var(--text-primary)] sm:text-5xl">
108
+ {title}
109
+ </Heading>
110
+ {subtitle != null && <p className="mt-4 max-w-xl text-lg text-[var(--text-secondary)]">{subtitle}</p>}
111
+ <div className="mt-8">
112
+ {actions ?? (
113
+ <LandingActions
114
+ session={session}
115
+ access={access}
116
+ demoHref={demoHref}
117
+ openAppHref={openAppHref}
118
+ labels={labelsProp}
119
+ />
120
+ )}
121
+ </div>
122
+ {trust != null && <p className="mt-4 text-sm text-[var(--text-muted)]">{trust}</p>}
123
+ </div>
124
+ {visual != null && <Visual className="max-w-sm">{visual}</Visual>}
125
+ </section>
126
+ );
127
+ }
128
+
129
+ /* ── Feature rows ────────────────────────────────────────────────────────── */
130
+
131
+ export type FeatureRowsProps = ComponentPropsWithoutRef<"section">;
132
+
133
+ /**
134
+ * The band that holds the feature rows (§4.1.3): two to four {@link FeatureRow}s on the
135
+ * surface colour, between hairlines. The rows alternate sides by themselves — the copy
136
+ * of every second row moves to the end from `lg` up — so the app lists them in order
137
+ * and nothing else.
138
+ */
139
+ export function FeatureRows({ className, children, ...rest }: FeatureRowsProps) {
140
+ return (
141
+ <section
142
+ {...rest}
143
+ data-slot="landing-feature-rows"
144
+ className={cn("border-t border-[var(--border)] bg-[var(--bg-surface)]", className)}
145
+ >
146
+ <div className="mx-auto max-w-6xl px-4 py-8 sm:py-12">{children}</div>
147
+ </section>
148
+ );
149
+ }
150
+
151
+ export interface FeatureRowProps extends Omit<ComponentPropsWithoutRef<"div">, "title" | "children"> {
152
+ /** A lucide icon (or any SVG) before the eyebrow. Decorative. */
153
+ icon?: ReactNode;
154
+ /** The small uppercase line above the heading: "Capture", "Plan". */
155
+ eyebrow?: ReactNode;
156
+ /** The row's `h2`. */
157
+ title: ReactNode;
158
+ description?: ReactNode;
159
+ /** Up to three short points; the first three are shown. */
160
+ bullets?: readonly ReactNode[];
161
+ /** The mock beside the copy. Decorative. */
162
+ visual?: ReactNode;
163
+ /** Default `h2`. */
164
+ headingAs?: HeadingLevel;
165
+ }
166
+
167
+ /**
168
+ * One job of the app (§4.1.3), keksdose's "capture → budget → understand": an icon
169
+ * eyebrow, the `h2`, a description, up to three bullets, and a visual on the other side.
170
+ * Inside {@link FeatureRows} every second row puts its copy at the end (`group-even`),
171
+ * which is keksdose's alternation without an index to pass.
172
+ */
173
+ export function FeatureRow({
174
+ icon,
175
+ eyebrow,
176
+ title,
177
+ description,
178
+ bullets,
179
+ visual,
180
+ headingAs: Heading = "h2",
181
+ className,
182
+ ...rest
183
+ }: FeatureRowProps) {
184
+ const shown = bullets?.slice(0, 3) ?? [];
185
+ return (
186
+ <div
187
+ {...rest}
188
+ data-slot="landing-feature-row"
189
+ className={cn("group/feature grid items-center gap-8 py-10", visual != null && "lg:grid-cols-2 lg:gap-16", className)}
190
+ >
191
+ <div className="lg:group-even/feature:order-last">
192
+ {(icon != null || eyebrow != null) && (
193
+ <p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--brand)]">
194
+ {icon != null && (
195
+ <span aria-hidden className="flex shrink-0 [&_svg]:size-4">
196
+ {icon}
197
+ </span>
198
+ )}
199
+ {eyebrow}
200
+ </p>
201
+ )}
202
+ <Heading className="mt-2 text-2xl font-semibold tracking-tight text-balance text-[var(--text-primary)] sm:text-3xl">
203
+ {title}
204
+ </Heading>
205
+ {description != null && <p className="mt-3 max-w-xl text-[var(--text-secondary)]">{description}</p>}
206
+ {shown.length > 0 && (
207
+ <ul className="mt-4 space-y-2">
208
+ {shown.map((bullet, i) => (
209
+ <li key={i} className="flex gap-2.5 text-sm text-[var(--text-secondary)]">
210
+ <span aria-hidden className="mt-1.5 size-1.5 shrink-0 rounded-full bg-[var(--brand)]" />
211
+ <span className="min-w-0">{bullet}</span>
212
+ </li>
213
+ ))}
214
+ </ul>
215
+ )}
216
+ </div>
217
+ {visual != null && <Visual className="max-w-md">{visual}</Visual>}
218
+ </div>
219
+ );
220
+ }
221
+
222
+ /* ── Trust strip ─────────────────────────────────────────────────────────── */
223
+
224
+ export interface TrustItem {
225
+ /** A lucide icon. Decorative. */
226
+ icon?: ReactNode;
227
+ /** The item's `h3`: "Hosted in Switzerland". */
228
+ title: ReactNode;
229
+ /** One sentence. */
230
+ description: ReactNode;
231
+ }
232
+
233
+ export interface TrustStripProps extends Omit<ComponentPropsWithoutRef<"section">, "children"> {
234
+ /** Three or four (§4.1.4): hosting, security, data ownership, sharing. */
235
+ items: readonly TrustItem[];
236
+ /** The items' heading level. Default `h3`. */
237
+ headingAs?: HeadingLevel;
238
+ }
239
+
240
+ /**
241
+ * Quiet, start-aligned facts after the feature rows (§4.1.4) — an icon, an `h3` and one
242
+ * sentence each, on one row from `lg` up (three or four columns), two on a tablet, one
243
+ * on a phone. No marketing volume: keksdose's "trust strip".
244
+ */
245
+ export function TrustStrip({ items, headingAs: Heading = "h3", className, ...rest }: TrustStripProps) {
246
+ return (
247
+ <section {...rest} data-slot="landing-trust" className={cn("mx-auto max-w-6xl px-4 py-12 sm:py-16", className)}>
248
+ <ul className={cn("grid gap-8 sm:grid-cols-2", items.length === 3 ? "lg:grid-cols-3" : "lg:grid-cols-4")}>
249
+ {items.map((item, i) => (
250
+ <li key={i}>
251
+ {item.icon != null && (
252
+ <span aria-hidden className="flex text-[var(--brand)] [&_svg]:size-5">
253
+ {item.icon}
254
+ </span>
255
+ )}
256
+ <Heading className="mt-2 text-sm font-semibold text-[var(--text-primary)]">{item.title}</Heading>
257
+ <p className="mt-1 text-sm text-[var(--text-secondary)]">{item.description}</p>
258
+ </li>
259
+ ))}
260
+ </ul>
261
+ </section>
262
+ );
263
+ }
264
+
265
+ /* ── CTA band ────────────────────────────────────────────────────────────── */
266
+
267
+ export interface CtaBandProps extends Omit<ComponentPropsWithoutRef<"section">, "title" | "children">, ActionProps {
268
+ /** The band's `h2`: the last ask. */
269
+ title: ReactNode;
270
+ subtitle?: ReactNode;
271
+ /** Replaces the action pair ({@link LandingActions}). */
272
+ actions?: ReactNode;
273
+ /** Default `h2`. */
274
+ headingAs?: HeadingLevel;
275
+ labels?: Partial<LandingLabels>;
276
+ }
277
+
278
+ /**
279
+ * The closing band before the footer (§4.1.5): a centred `h2`, a subtitle, and the same
280
+ * action pair as the hero.
281
+ */
282
+ export function CtaBand({
283
+ title,
284
+ subtitle,
285
+ actions,
286
+ headingAs: Heading = "h2",
287
+ session,
288
+ access,
289
+ demoHref,
290
+ openAppHref,
291
+ labels,
292
+ className,
293
+ ...rest
294
+ }: CtaBandProps) {
295
+ return (
296
+ <section
297
+ {...rest}
298
+ data-slot="landing-cta"
299
+ className={cn("border-t border-[var(--border)] bg-[var(--bg-surface)]", className)}
300
+ >
301
+ <div className="mx-auto max-w-5xl px-4 py-16 text-center">
302
+ <Heading className="text-2xl font-semibold tracking-tight text-balance text-[var(--text-primary)] sm:text-3xl">
303
+ {title}
304
+ </Heading>
305
+ {subtitle != null && <p className="mx-auto mt-3 max-w-xl text-[var(--text-secondary)]">{subtitle}</p>}
306
+ <div className="mt-8">
307
+ {actions ?? (
308
+ <LandingActions
309
+ session={session}
310
+ access={access}
311
+ demoHref={demoHref}
312
+ openAppHref={openAppHref}
313
+ align="center"
314
+ labels={labels}
315
+ />
316
+ )}
317
+ </div>
318
+ </div>
319
+ </section>
320
+ );
321
+ }
322
+
323
+ /* ── Footer ──────────────────────────────────────────────────────────────── */
324
+
325
+ export interface PublicFooterProps extends Omit<LegalFooterProps, "children"> {
326
+ /** The app's line under the legal links: "Self-hosted household budgeting." */
327
+ tagline?: ReactNode;
328
+ }
329
+
330
+ /**
331
+ * The public pages' footer (§4.1.6): the kit's {@link LegalFooter} — the three legal
332
+ * links — with the app's tagline under them. keksdose's `SiteFooter`, which was already
333
+ * this.
334
+ */
335
+ export function PublicFooter({ tagline, ...rest }: PublicFooterProps) {
336
+ return <LegalFooter {...rest}>{tagline}</LegalFooter>;
337
+ }
@@ -0,0 +1,251 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Search-engine helpers for the public landing page (docs/landing-demo-harmonization.md
5
+ * §4.3): the hook that declares a page's canonical URL, title, description and JSON-LD,
6
+ * and the two pure helpers each app's SEO test is built from.
7
+ */
8
+
9
+ /**
10
+ * Declares the current page's canonical URL — and optionally its title and meta
11
+ * description — while the calling component is mounted (feedback #129).
12
+ *
13
+ * Client-side because there is nowhere else to put it: the SPA is served by
14
+ * Caddy's `try_files … /index.html`, so every route shares one static head.
15
+ * Googlebot renders JS and reads the canonical from the RENDERED DOM, which is
16
+ * what makes this work; non-rendering scrapers (WhatsApp, Slack) only ever see
17
+ * index.html's static tags, which is why those stay in place as the fallback.
18
+ *
19
+ * The href is resolved against `window.location.origin` rather than hardcoded, so
20
+ * a preview deployment or the VPS host stays self-referential instead of pointing
21
+ * every copy of the app at production.
22
+ *
23
+ * The cleanup matters for the same reason as in {@link useNoIndex}: an SPA
24
+ * navigation from the landing page into the app must not leave the landing's
25
+ * canonical (or title) behind on an app page.
26
+ *
27
+ * 0.31 (§4.3): keksdose's `usePageSeo` (shared/hooks/use-page-seo.ts), verbatim. The
28
+ * landing calls it with `canonicalPath: "/"` on both `/` and `/welcome`, so the two URLs
29
+ * of one page are one result; there are no per-language URLs and no hreflang.
30
+ */
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 {
47
+ useEffect(() => {
48
+ const link = document.createElement("link");
49
+ link.rel = "canonical";
50
+ link.href = new URL(canonicalPath, window.location.origin).toString();
51
+ document.head.appendChild(link);
52
+
53
+ const previousTitle = document.title;
54
+ if (title) document.title = title;
55
+
56
+ // The description tag ships in index.html, so upsert rather than append —
57
+ // two `meta[name=description]` tags would be a coin flip for the scraper.
58
+ const meta = description
59
+ ? document.head.querySelector<HTMLMetaElement>('meta[name="description"]')
60
+ : null;
61
+ const previousDescription = meta?.content;
62
+ if (meta && description) meta.content = description;
63
+
64
+ let ld: HTMLScriptElement | null = null;
65
+ if (jsonLd) {
66
+ ld = document.createElement("script");
67
+ ld.type = "application/ld+json";
68
+ ld.textContent = JSON.stringify(jsonLd);
69
+ document.head.appendChild(ld);
70
+ }
71
+
72
+ return () => {
73
+ link.remove();
74
+ ld?.remove();
75
+ if (title) document.title = previousTitle;
76
+ if (meta && previousDescription !== undefined) meta.content = previousDescription;
77
+ };
78
+ // `jsonLd` is built inline by the caller, so it is a new object every render and
79
+ // cannot be a dependency — it is derived from `title`/`description`, which are.
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, [canonicalPath, title, description]);
82
+ }
83
+
84
+ /* ── The SEO test's helpers ──────────────────────────────────────────────── */
85
+
86
+ /** What a search result shows of the landing, in one language. */
87
+ export interface SeoCopy {
88
+ /** The page title (`landing.seo.title`): "Keksdose — envelope budgeting". */
89
+ title: string;
90
+ /** The meta description. */
91
+ description: string;
92
+ /** The product name the title carries, before or after its tagline. */
93
+ brand: string;
94
+ }
95
+
96
+ /** The width budgets, as characters. */
97
+ export interface SeoCopyLimits {
98
+ /** Default 60: a result's title is cut around 580px. */
99
+ maxTitle?: number;
100
+ /** Default 155: a snippet is cut around 920px. */
101
+ maxDescription?: number;
102
+ }
103
+
104
+ export type SeoCopyProblemCode =
105
+ | "title-empty"
106
+ | "description-empty"
107
+ | "title-too-long"
108
+ | "description-too-long"
109
+ | "title-without-brand"
110
+ | "description-repeats-title";
111
+
112
+ /** One thing wrong with a language's copy. `message` is for the test's failure output. */
113
+ export interface SeoCopyProblem {
114
+ code: SeoCopyProblemCode;
115
+ message: string;
116
+ }
117
+
118
+ /** Characters as a reader counts them: code points, so an emoji or a rare CJK character
119
+ * is one, not two UTF-16 units. */
120
+ const length = (text: string) => Array.from(text).length;
121
+
122
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
123
+
124
+ /** The separators a title puts between the brand and its tagline. */
125
+ const SEPARATOR = "\\s*[—–\\-|:·•]\\s*";
126
+
127
+ /** The title without the brand and its separator, or `null` when the brand is in
128
+ * neither place. */
129
+ function taglineOf(title: string, brand: string): string | null {
130
+ const name = escapeRegExp(brand.trim());
131
+ const leading = new RegExp(`^${name}${SEPARATOR}`, "i");
132
+ if (leading.test(title)) return title.replace(leading, "").trim();
133
+ const trailing = new RegExp(`${SEPARATOR}${name}$`, "i");
134
+ if (trailing.test(title)) return title.replace(trailing, "").trim();
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * What is wrong with one language's search-result copy (§4.3) — an empty list when
140
+ * nothing is. keksdose's landing-seo.test.ts (live #305), made reusable:
141
+ *
142
+ * - the title fits `maxTitle` characters (60) and the description `maxDescription`
143
+ * (155) — conservative stand-ins for Google's pixel budgets; German is the language
144
+ * most likely to blow through them, and what gets cut is always the end;
145
+ * - the title carries the brand, before or after its tagline ("Keksdose — …");
146
+ * - the description does not open with the title's tagline again — a snippet that
147
+ * repeats the line above it spends its first forty characters saying nothing new.
148
+ *
149
+ * ```ts
150
+ * for (const [locale, bundle] of Object.entries(LOCALES)) {
151
+ * it(`fits ${locale} into a search result`, () => {
152
+ * expect(seoCopyProblems({ ...bundle.landing.seo, brand: "Keksdose" })).toEqual([]);
153
+ * });
154
+ * }
155
+ * ```
156
+ */
157
+ export function seoCopyProblems(copy: SeoCopy, limits: SeoCopyLimits = {}): SeoCopyProblem[] {
158
+ const { maxTitle = 60, maxDescription = 155 } = limits;
159
+ const title = copy.title.replace(/\s+/g, " ").trim();
160
+ const description = copy.description.replace(/\s+/g, " ").trim();
161
+ const problems: SeoCopyProblem[] = [];
162
+ if (!title) problems.push({ code: "title-empty", message: "the title is empty" });
163
+ if (!description) problems.push({ code: "description-empty", message: "the description is empty" });
164
+ if (length(title) > maxTitle) {
165
+ problems.push({
166
+ code: "title-too-long",
167
+ message: `the title is ${length(title)} characters, over ${maxTitle}: "${title}"`,
168
+ });
169
+ }
170
+ if (length(description) > maxDescription) {
171
+ problems.push({
172
+ code: "description-too-long",
173
+ message: `the description is ${length(description)} characters, over ${maxDescription}: "${description}"`,
174
+ });
175
+ }
176
+ if (!title) return problems;
177
+ const tagline = taglineOf(title, copy.brand);
178
+ if (tagline === null) {
179
+ problems.push({
180
+ code: "title-without-brand",
181
+ message: `the title does not start or end with "${copy.brand}" and a separator: "${title}"`,
182
+ });
183
+ }
184
+ const repeated = (tagline ?? title).toLowerCase();
185
+ if (repeated && description.toLowerCase().startsWith(repeated)) {
186
+ problems.push({
187
+ code: "description-repeats-title",
188
+ message: `the description opens with the title's tagline "${tagline ?? title}"`,
189
+ });
190
+ }
191
+ return problems;
192
+ }
193
+
194
+ /** The five named references and the numeric ones — enough for a meta tag's text. */
195
+ function decodeEntities(text: string): string {
196
+ const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
197
+ return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (whole, ref: string) => {
198
+ if (ref[0] === "#") {
199
+ const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
200
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
201
+ }
202
+ return named[ref.toLowerCase()] ?? whole;
203
+ });
204
+ }
205
+
206
+ /** A tag's attributes, names lower-cased; values double-, single- or un-quoted. */
207
+ function attributesOf(tag: string): Map<string, string> {
208
+ const found = new Map<string, string>();
209
+ for (const m of tag.matchAll(/([^\s=/<>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
210
+ found.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
211
+ }
212
+ return found;
213
+ }
214
+
215
+ /**
216
+ * The `content` of the first `<meta>` in `html` that `selector` picks, whitespace
217
+ * collapsed and entities decoded — or `undefined` when there is none. For each app's
218
+ * parity test (§4.3): `index.html`'s static description and `og:` tags must equal the
219
+ * default language's strings, because a scraper that runs no script (WhatsApp, Slack)
220
+ * sees only those.
221
+ *
222
+ * `selector` names attributes, as in CSS — `meta[name="description"]`,
223
+ * `[property="og:title"]` or just `property="og:description"` — and every one named must
224
+ * match. A `RegExp` is matched against the tag's source instead (keksdose's form).
225
+ * String parsing, not a DOM: the test runs under `@vitest-environment node`.
226
+ *
227
+ * ```ts
228
+ * expect(metaContent(html, 'meta[name="description"]')).toBe(de.landing.seo.description);
229
+ * ```
230
+ */
231
+ export function metaContent(html: string, selector: string | RegExp): string | undefined {
232
+ const wanted =
233
+ typeof selector === "string"
234
+ ? Array.from(selector.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g), (m) => [
235
+ m[1]!.toLowerCase(),
236
+ m[2] ?? m[3] ?? "",
237
+ ])
238
+ : null;
239
+ if (wanted !== null && wanted.length === 0) {
240
+ throw new Error(`metaContent: the selector names no attribute: ${selector as string}`);
241
+ }
242
+ const pattern = selector instanceof RegExp ? new RegExp(selector.source, selector.flags.replace("g", "")) : null;
243
+ for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
244
+ const attrs = attributesOf(tag);
245
+ const matches = pattern ? pattern.test(tag) : wanted!.every(([name, value]) => attrs.get(name!) === value);
246
+ if (!matches) continue;
247
+ const content = attrs.get("content");
248
+ return content === undefined ? undefined : decodeEntities(content).replace(/\s+/g, " ").trim();
249
+ }
250
+ return undefined;
251
+ }