@eifi1/ui-kit 0.18.0 → 0.19.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 (119) hide show
  1. package/README.md +60 -6
  2. package/dist/chart.d.ts +4 -1
  3. package/dist/components/amount-input.d.ts +4 -1
  4. package/dist/components/button-group.d.ts +4 -1
  5. package/dist/components/calculator.d.ts +4 -1
  6. package/dist/components/copy-button.d.ts +4 -1
  7. package/dist/components/facing-pair.d.ts +4 -1
  8. package/dist/components/file-button.d.ts +4 -1
  9. package/dist/components/file-dropzone.d.ts +4 -1
  10. package/dist/components/floating-panel.d.ts +4 -1
  11. package/dist/components/floating-panel.js +15 -1
  12. package/dist/components/floating-panel.js.map +1 -1
  13. package/dist/components/form-actions.d.ts +4 -1
  14. package/dist/components/legal.d.ts +84 -0
  15. package/dist/components/legal.js +55 -0
  16. package/dist/components/legal.js.map +1 -0
  17. package/dist/components/money-field.d.ts +4 -1
  18. package/dist/components/number-field.d.ts +4 -1
  19. package/dist/components/number-input.d.ts +4 -1
  20. package/dist/components/numpad-sheet.d.ts +4 -1
  21. package/dist/components/series-chart.d.ts +4 -1
  22. package/dist/components/server-wake.d.ts +4 -2
  23. package/dist/components/server-wake.js +23 -2
  24. package/dist/components/server-wake.js.map +1 -1
  25. package/dist/components/settings-fields.d.ts +4 -1
  26. package/dist/components/share-card.d.ts +4 -1
  27. package/dist/components/text-link.d.ts +4 -1
  28. package/dist/components/time-input.d.ts +4 -1
  29. package/dist/components/translation-review-editor.d.ts +56 -0
  30. package/dist/components/translation-review-editor.js +159 -0
  31. package/dist/components/translation-review-editor.js.map +1 -0
  32. package/dist/components/translation-review-labels.d.ts +92 -0
  33. package/dist/components/translation-review-labels.js +67 -0
  34. package/dist/components/translation-review-labels.js.map +1 -0
  35. package/dist/components/translation-review-parts.d.ts +97 -0
  36. package/dist/components/translation-review-parts.js +163 -0
  37. package/dist/components/translation-review-parts.js.map +1 -0
  38. package/dist/components/translation-review.d.ts +94 -0
  39. package/dist/components/translation-review.js +344 -0
  40. package/dist/components/translation-review.js.map +1 -0
  41. package/dist/components/ui.d.ts +4 -1
  42. package/dist/hooks/use-file-drop.d.ts +4 -1
  43. package/dist/i18n/defaults.d.ts +4 -1
  44. package/dist/i18n/defaults.js +5 -1
  45. package/dist/i18n/defaults.js.map +1 -1
  46. package/dist/i18n/german.d.ts +4 -1
  47. package/dist/i18n/german.js +51 -0
  48. package/dist/i18n/german.js.map +1 -1
  49. package/dist/i18n/kit-labels.d.ts +4 -1
  50. package/dist/i18n/kit-labels.js.map +1 -1
  51. package/dist/i18n/languages.d.ts +243 -0
  52. package/dist/i18n/languages.js +70 -0
  53. package/dist/i18n/languages.js.map +1 -0
  54. package/dist/i18n/locales/de-CH.d.ts +4 -1
  55. package/dist/i18n/locales/en.d.ts +4 -1
  56. package/dist/i18n/locales/en.js +8 -0
  57. package/dist/i18n/locales/en.js.map +1 -1
  58. package/dist/i18n/locales/es.d.ts +4 -1
  59. package/dist/i18n/locales/es.js +83 -33
  60. package/dist/i18n/locales/es.js.map +1 -1
  61. package/dist/i18n/locales/fr.d.ts +4 -1
  62. package/dist/i18n/locales/fr.js +50 -0
  63. package/dist/i18n/locales/fr.js.map +1 -1
  64. package/dist/i18n/locales/hu.d.ts +4 -1
  65. package/dist/i18n/locales/hu.js +51 -0
  66. package/dist/i18n/locales/hu.js.map +1 -1
  67. package/dist/i18n/locales/it.d.ts +4 -1
  68. package/dist/i18n/locales/it.js +90 -40
  69. package/dist/i18n/locales/it.js.map +1 -1
  70. package/dist/i18n/locales/zh.d.ts +4 -1
  71. package/dist/i18n/locales/zh.js +51 -0
  72. package/dist/i18n/locales/zh.js.map +1 -1
  73. package/dist/i18n/review.d.ts +102 -0
  74. package/dist/i18n/review.js +158 -0
  75. package/dist/i18n/review.js.map +1 -0
  76. package/dist/index.d.ts +9 -1
  77. package/dist/index.js +64 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/{kit-labels-CPbsjlXr.d.ts → kit-labels-D1J2_jyD.d.ts} +7 -0
  80. package/dist/lib/floating-stack.d.ts +31 -0
  81. package/dist/lib/floating-stack.js +42 -0
  82. package/dist/lib/floating-stack.js.map +1 -0
  83. package/dist/lib/server-wake.d.ts +24 -8
  84. package/dist/lib/server-wake.js +6 -5
  85. package/dist/lib/server-wake.js.map +1 -1
  86. package/dist/lib/translation-review.d.ts +232 -0
  87. package/dist/lib/translation-review.js +194 -0
  88. package/dist/lib/translation-review.js.map +1 -0
  89. package/dist/rhf/fields.d.ts +4 -1
  90. package/dist/rhf/form.d.ts +4 -1
  91. package/dist/rhf.d.ts +4 -1
  92. package/dist/shell/app-shell.d.ts +4 -1
  93. package/dist/shell/top-bar-brand.d.ts +4 -1
  94. package/dist/shell.d.ts +4 -1
  95. package/dist/wizard/stepper-nav.d.ts +4 -1
  96. package/dist/wizard.d.ts +4 -1
  97. package/package.json +1 -1
  98. package/src/components/floating-panel.tsx +19 -1
  99. package/src/components/legal.tsx +159 -0
  100. package/src/components/server-wake.tsx +37 -5
  101. package/src/components/translation-review-editor.tsx +261 -0
  102. package/src/components/translation-review-labels.ts +152 -0
  103. package/src/components/translation-review-parts.tsx +269 -0
  104. package/src/components/translation-review.tsx +502 -0
  105. package/src/i18n/defaults.ts +4 -0
  106. package/src/i18n/german.ts +52 -0
  107. package/src/i18n/kit-labels.tsx +7 -0
  108. package/src/i18n/languages.ts +285 -0
  109. package/src/i18n/locales/en.ts +8 -0
  110. package/src/i18n/locales/es.ts +84 -33
  111. package/src/i18n/locales/fr.ts +51 -0
  112. package/src/i18n/locales/hu.ts +52 -0
  113. package/src/i18n/locales/it.ts +91 -40
  114. package/src/i18n/locales/zh.ts +52 -0
  115. package/src/i18n/review.ts +193 -0
  116. package/src/index.ts +63 -0
  117. package/src/lib/floating-stack.ts +66 -0
  118. package/src/lib/server-wake.ts +34 -13
  119. package/src/lib/translation-review.ts +465 -0
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ReactElement } from 'react';
3
- import { f as ButtonVariant } from '../kit-labels-CPbsjlXr.js';
3
+ import { f as ButtonVariant } from '../kit-labels-D1J2_jyD.js';
4
4
  import { UseWizardReturn, WizardLabels } from './types.js';
5
5
  import '../components/data-table-labels.js';
6
6
  import '../components/mini-calendar.js';
@@ -52,6 +52,7 @@ import '../hooks/use-authed-src.js';
52
52
  import '../components/image-grid.js';
53
53
  import '../components/lightbox.js';
54
54
  import '../components/write-lock.js';
55
+ import '../components/legal.js';
55
56
  import '../components/account-chips.js';
56
57
  import '../lib/format.js';
57
58
  import '../data-table-3mi2R3so.js';
@@ -60,6 +61,8 @@ import '../components/swipeable-row.js';
60
61
  import '../components/reauth-dialog.js';
61
62
  import '../components/server-wake.js';
62
63
  import '../lib/server-wake.js';
64
+ import '../components/translation-review-labels.js';
65
+ import '../lib/translation-review.js';
63
66
 
64
67
  /**
65
68
  * The chrome around a wizard: the step indicator, the step content, and the
package/dist/wizard.d.ts CHANGED
@@ -6,7 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
6
6
  export { WizardSummary } from './wizard/wizard-summary.js';
7
7
  export { StepperNav } from './wizard/stepper-nav.js';
8
8
  import 'react';
9
- import './kit-labels-CPbsjlXr.js';
9
+ import './kit-labels-D1J2_jyD.js';
10
10
  import './components/data-table-labels.js';
11
11
  import './components/mini-calendar.js';
12
12
  import './components/calendar-heatmap.js';
@@ -57,6 +57,7 @@ import './hooks/use-authed-src.js';
57
57
  import './components/image-grid.js';
58
58
  import './components/lightbox.js';
59
59
  import './components/write-lock.js';
60
+ import './components/legal.js';
60
61
  import './components/account-chips.js';
61
62
  import './lib/format.js';
62
63
  import './data-table-3mi2R3so.js';
@@ -65,3 +66,5 @@ import './components/swipeable-row.js';
65
66
  import './components/reauth-dialog.js';
66
67
  import './components/server-wake.js';
67
68
  import './lib/server-wake.js';
69
+ import './components/translation-review-labels.js';
70
+ import './lib/translation-review.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  AnchorHTMLAttributes,
@@ -15,7 +15,9 @@ import { X } from "lucide-react";
15
15
 
16
16
  import { cn } from "../lib/cn";
17
17
  import { dirOf, type Direction } from "../lib/direction";
18
+ import { registerFloating } from "../lib/floating-stack";
18
19
  import { useKitLabels, useKitLink } from "../i18n/kit-labels";
20
+ import { assignRef } from "./choice-parts";
19
21
  import { pickLinkRenderer } from "./text-link";
20
22
  import { Tooltip, type TooltipSide } from "./tooltip";
21
23
 
@@ -172,6 +174,9 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
172
174
  *
173
175
  * Portalled, because `position: fixed` inside any transformed ancestor (an animated
174
176
  * page transition) is fixed to that ancestor, not to the screen.
177
+ *
178
+ * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`
179
+ * stacks above it rather than over it.
175
180
  */
176
181
  export function FloatingActionButton({
177
182
  label,
@@ -189,18 +194,30 @@ export function FloatingActionButton({
189
194
  style,
190
195
  type = "button",
191
196
  title,
197
+ ref,
192
198
  ...rest
193
199
  }: FloatingActionButtonProps) {
194
200
  const [marker, dir] = usePortalDir();
195
201
  const toggle = pressed !== undefined;
196
202
  const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
197
203
  const wrapped = tip !== undefined && tip !== null;
204
+ // The fixed element — the button, or the wrapper when there is a tooltip.
205
+ const [fixed, setFixed] = useState<HTMLElement | null>(null);
206
+ useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);
207
+ const buttonRef = useCallback(
208
+ (node: HTMLButtonElement | null) => {
209
+ assignRef(ref, node);
210
+ if (!wrapped) setFixed(node);
211
+ },
212
+ [ref, wrapped],
213
+ );
198
214
  // An empty title is "no tooltip", not an attribute with nothing in it. The kit
199
215
  // tooltip replaces it outright.
200
216
  const nativeTip = !wrapped && nativeTitle && title !== "" ? (title ?? label) : undefined;
201
217
  const button = (
202
218
  <button
203
219
  {...rest}
220
+ ref={buttonRef}
204
221
  type={type}
205
222
  dir={dir}
206
223
  aria-label={label}
@@ -248,6 +265,7 @@ export function FloatingActionButton({
248
265
  <>
249
266
  {wrapped ? (
250
267
  <div
268
+ ref={setFixed}
251
269
  dir={dir}
252
270
  hidden={rest.hidden}
253
271
  style={cornerStyle(corner, offset)}
@@ -0,0 +1,159 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { useKitLabels } from "../i18n/kit-labels";
4
+ import { cn } from "../lib/cn";
5
+ import { AlertBanner } from "./alert-banner";
6
+ import { TextLink } from "./text-link";
7
+
8
+ /**
9
+ * The legal pages' shell — Imprint, Privacy Policy, Terms — for every app (Marcel,
10
+ * 2026-10-02: every app has them; docs/i18n-harmonization.md H10).
11
+ *
12
+ * keksdose and kastlan each carried the same shell, written twice: a back link, the
13
+ * title, a "closed beta, not yet reviewed by a lawyer" notice, sections of heading +
14
+ * running text, and kastlan's row of links to the three pages with the current one
15
+ * marked. Kurvenschmiede had none. The words stay in each app — they are the app's
16
+ * legal statements, reviewed under the `legal` area of the translation review — and
17
+ * only the shape moves here.
18
+ *
19
+ * A CONTENT COLUMN, not a page: keksdose shows its legal pages inside its public
20
+ * landing header and footer, kastlan inside `AuthLayout` (`width="wide" card={false}`,
21
+ * no title of its own). Both put this inside their chrome; a page-owning layout would
22
+ * have fought one of them.
23
+ */
24
+
25
+ // ── Labels ────────────────────────────────────────────────────────────────────
26
+
27
+ export interface LegalLabels {
28
+ /** The accessible name of {@link LegalLinks}' `<nav>`. */
29
+ navLabel: string;
30
+ }
31
+
32
+ export const DEFAULT_LEGAL_LABELS: LegalLabels = {
33
+ navLabel: "Legal",
34
+ };
35
+
36
+ // ── Links ─────────────────────────────────────────────────────────────────────
37
+
38
+ export interface LegalLink {
39
+ href: string;
40
+ /** "Imprint", "Privacy Policy", "Terms" — the app's words. */
41
+ label: ReactNode;
42
+ }
43
+
44
+ export interface LegalLinksProps {
45
+ links: readonly LegalLink[];
46
+ /** The page being shown; its link is marked `aria-current="page"`. Usually the
47
+ * router's pathname. */
48
+ currentHref?: string;
49
+ /** `false` renders the links without the `<nav>` — inside `AuthLayout`'s `footer`,
50
+ * which wraps its footer in a `<nav>` named by `footerLabel` already. Default true. */
51
+ nav?: boolean;
52
+ labels?: Partial<LegalLabels>;
53
+ className?: string;
54
+ }
55
+
56
+ /** The legal pages as a row of quiet links, the one you are on marked (kastlan's
57
+ * `LegalLinks`). Wraps on a phone. */
58
+ export function LegalLinks({ links, currentHref, nav = true, labels: labelsProp, className }: LegalLinksProps) {
59
+ const labels = useKitLabels("legal", DEFAULT_LEGAL_LABELS, labelsProp);
60
+ const items = links.map((link) => (
61
+ <TextLink key={link.href} href={link.href} tone="muted" current={link.href === currentHref}>
62
+ {link.label}
63
+ </TextLink>
64
+ ));
65
+ if (!nav) return <>{items}</>;
66
+ return (
67
+ <nav
68
+ aria-label={labels.navLabel}
69
+ className={cn("flex flex-wrap gap-x-4 gap-y-1 text-xs", className)}
70
+ >
71
+ {items}
72
+ </nav>
73
+ );
74
+ }
75
+
76
+ // ── Section ───────────────────────────────────────────────────────────────────
77
+
78
+ export interface LegalSectionProps {
79
+ heading: ReactNode;
80
+ /** Running text. Line breaks in it are kept (`white-space: pre-line`), so a
81
+ * translation can hold an address or a list as plain lines. */
82
+ body?: string;
83
+ /** Anything richer than plain text — links, a list — after `body`. */
84
+ children?: ReactNode;
85
+ /** The section's `id`, for a link to it from elsewhere ("see §4"). */
86
+ id?: string;
87
+ /** The heading's element. Default `h2`, under the layout's `h1`. */
88
+ headingAs?: "h2" | "h3";
89
+ }
90
+
91
+ export function LegalSection({ heading, body, children, id, headingAs: Heading = "h2" }: LegalSectionProps) {
92
+ return (
93
+ <section id={id} className="scroll-mt-20">
94
+ <Heading className="text-lg font-semibold text-[var(--text-primary)]">{heading}</Heading>
95
+ {body !== undefined && (
96
+ <p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--text-secondary)]">{body}</p>
97
+ )}
98
+ {children !== undefined && (
99
+ <div className="mt-2 text-sm leading-relaxed text-[var(--text-secondary)]">{children}</div>
100
+ )}
101
+ </section>
102
+ );
103
+ }
104
+
105
+ // ── Layout ────────────────────────────────────────────────────────────────────
106
+
107
+ export interface LegalLayoutProps {
108
+ /** "Imprint", "Privacy Policy", "Terms". */
109
+ title: ReactNode;
110
+ /** Above the title — the app's "← Back" link (`<TextLink icon={ArrowLeft}>`), whose
111
+ * target differs per app (keksdose's start page, kastlan's sign-in). */
112
+ back?: ReactNode;
113
+ /**
114
+ * A notice under the title, as a warning banner with `role="note"` — page furniture,
115
+ * not an alert. Both apps say their legal texts await a lawyer's review; keksdose's
116
+ * privacy page says something more precise than the others (its technical sections
117
+ * describe running code), so the notice is per page, not per layout.
118
+ */
119
+ notice?: ReactNode;
120
+ /** The sections — {@link LegalSection}s. */
121
+ children: ReactNode;
122
+ /** The three pages, linked at the end of the column with the current one marked.
123
+ * Leave out when the page's chrome already shows them (kastlan's AuthLayout footer). */
124
+ links?: readonly LegalLink[];
125
+ currentHref?: string;
126
+ /** Default `h1`: the page's heading. */
127
+ headingAs?: "h1" | "h2";
128
+ /** Extra classes on the column — `px-0 py-0` inside a chrome that pads already. */
129
+ className?: string;
130
+ labels?: Partial<LegalLabels>;
131
+ }
132
+
133
+ export function LegalLayout({
134
+ title,
135
+ back,
136
+ notice,
137
+ children,
138
+ links,
139
+ currentHref,
140
+ headingAs: Heading = "h1",
141
+ className,
142
+ labels,
143
+ }: LegalLayoutProps) {
144
+ return (
145
+ <article className={cn("mx-auto w-full max-w-3xl px-4 py-12", className)}>
146
+ {back !== undefined && <div className="mb-4 text-sm">{back}</div>}
147
+ <Heading className="text-3xl font-bold tracking-tight text-[var(--text-primary)]">{title}</Heading>
148
+ {notice !== undefined && notice !== null && (
149
+ <AlertBanner tone="warning" role="note" className="mt-4">
150
+ {notice}
151
+ </AlertBanner>
152
+ )}
153
+ <div className="mt-8 space-y-8">{children}</div>
154
+ {links !== undefined && links.length > 0 && (
155
+ <LegalLinks links={links} currentHref={currentHref} labels={labels} className="mt-12 border-t border-[var(--border)] pt-4" />
156
+ )}
157
+ </article>
158
+ );
159
+ }
@@ -1,8 +1,9 @@
1
- import { useSyncExternalStore } from "react";
2
- import type { CSSProperties } from "react";
1
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import type { CSSProperties, RefObject } from "react";
3
3
 
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
5
  import { cn } from "../lib/cn";
6
+ import { clearanceAbove, subscribeFloating } from "../lib/floating-stack";
6
7
  import { serverWake } from "../lib/server-wake";
7
8
  import type { ServerWakeStage, ServerWakeWatcher } from "../lib/server-wake";
8
9
  import { AlertBanner } from "./alert-banner";
@@ -17,7 +18,8 @@ import { Spinner } from "./ui";
17
18
  * Corner-anchored and non-blocking on purpose. The pages underneath already show their
18
19
  * own skeletons; what was missing is the explanation, not another overlay. Start
19
20
  * corner, above the phone's bottom nav, so it collides with neither the centred toasts
20
- * nor the nav bar.
21
+ * nor the nav bar — and above any FloatingActionButton it would otherwise cover
22
+ * (kastlan's offline pill shares the corner; on a phone the notice spans both).
21
23
  *
22
24
  * Mount it next to the router, not inside the app layout, so it also covers the login
23
25
  * screen and the landing page — a cold start is at its most confusing exactly there,
@@ -67,12 +69,38 @@ export interface ServerWakeNoticeProps {
67
69
  */
68
70
  navOffset?: string | number;
69
71
  /** Extra classes on the fixed anchor — a different width or `start-*`. The bottom
70
- * offset is an inline style, so move it vertically with {@link navOffset}. */
72
+ * offset is an inline style, so move it vertically with {@link navOffset}. Either
73
+ * way it rises above a visible FloatingActionButton under it. */
71
74
  className?: string;
72
75
  }
73
76
 
74
77
  const px = (v: string | number) => (typeof v === "number" ? `${v}px` : v);
75
78
 
79
+ /** Gap between the notice and a floating control it stacks above. */
80
+ const STACK_GAP = "0.5rem";
81
+
82
+ /**
83
+ * How far off the bottom the notice must sit to clear the FloatingActionButtons under
84
+ * it (`lib/floating-stack.ts`), re-measured when one appears, goes, resizes or the
85
+ * viewport does — or `null` while it is idle or nothing is under it. kastlan 0.18.
86
+ */
87
+ function useFloatingClearance(anchor: RefObject<HTMLDivElement | null>, stage: ServerWakeStage): number | null {
88
+ const [clearance, setClearance] = useState<number | null>(null);
89
+ useLayoutEffect(() => {
90
+ if (stage === "idle") return;
91
+ const measure = () => setClearance(clearanceAbove(anchor.current));
92
+ measure();
93
+ const unsubscribe = subscribeFloating(measure);
94
+ window.addEventListener("resize", measure);
95
+ return () => {
96
+ unsubscribe();
97
+ window.removeEventListener("resize", measure);
98
+ };
99
+ }, [anchor, stage]);
100
+ // Idle, the last measurement is stale and nothing shows anyway.
101
+ return stage === "idle" ? null : clearance;
102
+ }
103
+
76
104
  /**
77
105
  * Renders nothing visible while idle. The LIVE REGION, though, is always mounted: a
78
106
  * `role="status"` element inserted together with its text is announced unreliably
@@ -84,12 +112,16 @@ export function ServerWakeNotice({ watcher, appName, labels: labelsProp, navOffs
84
112
  const labels = useKitLabels("serverWake", DEFAULT_SERVER_WAKE_LABELS, labelsProp);
85
113
  const stage = useServerWakeStage(watcher);
86
114
  const waking = stage === "waking";
115
+ const anchor = useRef<HTMLDivElement>(null);
116
+ const clearance = useFloatingClearance(anchor, stage);
117
+ const base = `calc(max(${navOffset === undefined ? "var(--app-nav-h, 0px)" : px(navOffset)}, env(safe-area-inset-bottom, 0px)) + 1rem)`;
87
118
  const style: CSSProperties = {
88
- bottom: `calc(max(${navOffset === undefined ? "var(--app-nav-h, 0px)" : px(navOffset)}, env(safe-area-inset-bottom, 0px)) + 1rem)`,
119
+ bottom: clearance === null ? base : `max(${base}, calc(${clearance}px + ${STACK_GAP}))`,
89
120
  };
90
121
 
91
122
  return (
92
123
  <div
124
+ ref={anchor}
93
125
  role="status"
94
126
  aria-live="polite"
95
127
  data-stage={stage}
@@ -0,0 +1,261 @@
1
+ import { useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { cn } from "../lib/cn";
6
+ import { useKitFormat } from "../lib/format";
7
+ import { placeholderTokens, reviewWrite } from "../lib/translation-review";
8
+ import type {
9
+ TranslationReviewKey,
10
+ TranslationReviewWrite,
11
+ TranslationRow,
12
+ TranslationVerdict,
13
+ } from "../lib/translation-review";
14
+ import { AlertBanner } from "./alert-banner";
15
+ import { Caption, SectionLabel } from "./text";
16
+ import { DEFAULT_TRANSLATION_REVIEW_LABELS } from "./translation-review-labels";
17
+ import type { TranslationReviewLabels } from "./translation-review-labels";
18
+ import { Button, Textarea } from "./ui";
19
+
20
+ /** A callback may return a promise: resolve and the editor closes, reject and it stays
21
+ * open with the error. */
22
+ type MaybePromise = void | Promise<unknown>;
23
+
24
+ export interface TranslationReviewEditorProps {
25
+ row: TranslationRow;
26
+ /** The reference language's own name ("English", "Deutsch") — the app's choice of
27
+ * reference, so the app's word for it. */
28
+ referenceLabel: ReactNode;
29
+ /** The reviewed language's own name ("Français"). */
30
+ localeLabel: ReactNode;
31
+ /** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last
32
+ * verdict, with no fields and no actions. */
33
+ onSave?: (write: TranslationReviewWrite) => MaybePromise;
34
+ /** Clear the verdict — back to unreviewed. Left out: no reset button. */
35
+ onClear?: (key: TranslationReviewKey) => MaybePromise;
36
+ /** The Cancel button — and called after a save or a reset resolves, so the panel
37
+ * that opened the editor closes it. */
38
+ onClose?: () => void;
39
+ /** Look, do not touch: a locale the viewer may read but not review. */
40
+ readOnly?: boolean;
41
+ /** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the
42
+ * provider's locale. */
43
+ formatDate?: (iso: string) => string;
44
+ /** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */
45
+ formatError?: (error: unknown) => ReactNode;
46
+ className?: string;
47
+ labels?: Partial<TranslationReviewLabels>;
48
+ }
49
+
50
+ /** The wording a verdict was given on, under the line that says it changed. */
51
+ function Then({ label, text }: { label: string; text: string }) {
52
+ return (
53
+ <div className="mt-1.5 min-w-0">
54
+ <span className="text-xs font-medium">{label}</span>
55
+ <p className="whitespace-pre-line break-words text-[var(--text-secondary)]">{text}</p>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * One string, opened: the reference beside the translation, what changed since the last
62
+ * verdict, what a machine can see is wrong (the placeholders), and the two things a
63
+ * reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.
64
+ *
65
+ * "Better wording" opens on the CURRENT text rather than empty, because the usual
66
+ * correction is a word or an ending, and retyping a sentence to change one word is how
67
+ * new mistakes get in. It is sent only with "Needs a change" — approving says the text is
68
+ * fine as it stands. A "Needs a change" with neither a note nor a different wording tells
69
+ * whoever fixes it nothing, so it takes one of the two.
70
+ *
71
+ * A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is
72
+ * nothing to approve. Its field starts empty and is the translation to add; the verdict
73
+ * is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to
74
+ * add.
75
+ *
76
+ * Every action waits for its callback: the buttons hold while the promise is out, a
77
+ * resolution closes the editor through `onClose`, and a rejection keeps it open with what
78
+ * was typed and the error under the buttons.
79
+ */
80
+ export function TranslationReviewEditor({
81
+ row,
82
+ referenceLabel,
83
+ localeLabel,
84
+ onSave,
85
+ onClear,
86
+ onClose,
87
+ readOnly = false,
88
+ formatDate: formatDateProp,
89
+ formatError,
90
+ className,
91
+ labels: labelsProp,
92
+ }: TranslationReviewEditorProps) {
93
+ const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
94
+ const { formatDate } = useKitFormat();
95
+ const review = row.review;
96
+ const missing = row.text === "";
97
+ const editable = !readOnly && onSave !== undefined;
98
+ const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);
99
+ const [note, setNote] = useState(review?.note ?? "");
100
+ const [busy, setBusy] = useState<"approve" | "flag" | "clear" | null>(null);
101
+ const [failure, setFailure] = useState<ReactNode>(null);
102
+
103
+ const canFlag = note.trim() !== "" || suggestion.trim() !== row.text.trim();
104
+ const changed = row.status === "changed" ? review : null;
105
+ const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "medium"));
106
+
107
+ const run = async (kind: "approve" | "flag" | "clear", action: () => MaybePromise) => {
108
+ setBusy(kind);
109
+ setFailure(null);
110
+ try {
111
+ await action();
112
+ onClose?.();
113
+ } catch (caught) {
114
+ setFailure(formatError ? formatError(caught) : labels.failed);
115
+ } finally {
116
+ setBusy(null);
117
+ }
118
+ };
119
+
120
+ const save = (verdict: TranslationVerdict) => {
121
+ if (!onSave) return;
122
+ const trimmedNote = note.trim() || null;
123
+ // Approving says the text is right as it stands: there is no wording to suggest.
124
+ const sent = verdict === "APPROVED" ? null : suggestion.trim() || null;
125
+ void run(verdict === "APPROVED" ? "approve" : "flag", () =>
126
+ onSave(reviewWrite(row, verdict, trimmedNote, sent)),
127
+ );
128
+ };
129
+
130
+ return (
131
+ <div className={cn("min-w-0 space-y-3 text-sm", className)}>
132
+ <code className="block break-all text-xs text-[var(--text-muted)]">{row.key}</code>
133
+
134
+ <div className="grid gap-3 md:grid-cols-2">
135
+ <div className="min-w-0">
136
+ <SectionLabel as="span" size="xs">
137
+ {referenceLabel}
138
+ </SectionLabel>
139
+ <p className="mt-1 whitespace-pre-line break-words text-[var(--text-secondary)]">{row.reference}</p>
140
+ </div>
141
+ <div className="min-w-0">
142
+ <SectionLabel as="span" size="xs">
143
+ {localeLabel}
144
+ </SectionLabel>
145
+ {missing ? (
146
+ <p className="mt-1 italic text-[var(--text-muted)]">{labels.missingText}</p>
147
+ ) : (
148
+ <p lang={row.locale} className="mt-1 whitespace-pre-line break-words">
149
+ {row.text}
150
+ </p>
151
+ )}
152
+ </div>
153
+ </div>
154
+
155
+ {changed && (
156
+ <AlertBanner tone="warning" variant="inline" size="sm" block live={false}>
157
+ <div className="min-w-0">
158
+ <p>{labels.changedSince}</p>
159
+ {changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || "—"} />}
160
+ {changed.referenceText != null && changed.referenceText !== row.reference && (
161
+ <Then label={labels.reviewedReference} text={changed.referenceText} />
162
+ )}
163
+ </div>
164
+ </AlertBanner>
165
+ )}
166
+
167
+ {row.placeholderMismatch && (
168
+ <AlertBanner tone="danger" variant="inline" size="sm" block live={false}>
169
+ <span className="min-w-0 break-words">
170
+ {labels.placeholderMismatch(
171
+ placeholderTokens(row.reference).join(" ") || "—",
172
+ placeholderTokens(row.text).join(" ") || "—",
173
+ )}
174
+ </span>
175
+ </AlertBanner>
176
+ )}
177
+
178
+ {review && (
179
+ <Caption>
180
+ {(review.verdict === "APPROVED" ? labels.lastApproved : labels.lastFlagged)(
181
+ review.reviewerName ?? labels.erasedReviewer,
182
+ date(review.reviewedAt),
183
+ )}
184
+ </Caption>
185
+ )}
186
+
187
+ {/* Read-only: what the reviewer said, as text — the fields are for saying it. */}
188
+ {!editable && review?.verdict === "NEEDS_CHANGE" && (review.suggestion || review.note) && (
189
+ <div className="space-y-2">
190
+ {review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}
191
+ {review.note && <Then label={labels.note} text={review.note} />}
192
+ </div>
193
+ )}
194
+
195
+ {editable && (
196
+ <>
197
+ <Textarea
198
+ label={missing ? labels.translation : labels.suggestion}
199
+ lang={row.locale}
200
+ rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}
201
+ value={suggestion}
202
+ disabled={busy !== null}
203
+ onChange={(e) => setSuggestion(e.target.value)}
204
+ />
205
+ <Textarea
206
+ label={labels.note}
207
+ rows={2}
208
+ value={note}
209
+ placeholder={labels.notePlaceholder}
210
+ disabled={busy !== null}
211
+ onChange={(e) => setNote(e.target.value)}
212
+ />
213
+ <div className="flex flex-wrap gap-2">
214
+ {!missing && (
215
+ <Button
216
+ variant="primary"
217
+ commit
218
+ pending={busy === "approve"}
219
+ disabled={busy !== null}
220
+ onClick={() => save("APPROVED")}
221
+ >
222
+ {labels.approve}
223
+ </Button>
224
+ )}
225
+ <Button
226
+ variant={missing ? "primary" : "danger"}
227
+ commit
228
+ pending={busy === "flag"}
229
+ disabled={busy !== null || !canFlag}
230
+ onClick={() => save("NEEDS_CHANGE")}
231
+ >
232
+ {missing ? labels.suggest : labels.flag}
233
+ </Button>
234
+ {review && onClear && (
235
+ <Button
236
+ variant="ghost"
237
+ commit
238
+ pending={busy === "clear"}
239
+ disabled={busy !== null}
240
+ onClick={() => void run("clear", () => onClear({ locale: row.locale, key: row.key }))}
241
+ >
242
+ {labels.reset}
243
+ </Button>
244
+ )}
245
+ {onClose && (
246
+ <Button variant="ghost" disabled={busy !== null} onClick={onClose}>
247
+ {labels.cancel}
248
+ </Button>
249
+ )}
250
+ </div>
251
+ </>
252
+ )}
253
+
254
+ {failure && (
255
+ <AlertBanner tone="danger" size="sm" role="alert">
256
+ {failure}
257
+ </AlertBanner>
258
+ )}
259
+ </div>
260
+ );
261
+ }