@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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/package.json +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// AiTextField / AiTextArea — Drop-in replacement for TextField/TextareaField
|
|
2
|
+
// with AI-augmented actions (ai-text-primitive plan doc, Phase 3).
|
|
3
|
+
//
|
|
4
|
+
// Built on raw <input>/<textarea> (not the generic Input primitive) because
|
|
5
|
+
// ghost-text needs a keydown handler (Tab/Esc) and an overlay that the
|
|
6
|
+
// discriminated-union Input contract doesn't expose. Same pattern as
|
|
7
|
+
// MoneyInput — a hand-built control wrapped in Field.
|
|
8
|
+
//
|
|
9
|
+
// Graceful degradation: `useAiTextAction`/`useCompletion` surface
|
|
10
|
+
// "unavailable" when the server feature isn't mounted (`feature_disabled`)
|
|
11
|
+
// — the toolbar hides and the field behaves like a plain text field, no
|
|
12
|
+
// error shown to the end-user.
|
|
13
|
+
//
|
|
14
|
+
// Ghost-text simplification: the overlay only ever appends after the
|
|
15
|
+
// current value (no mid-text ghost) — matches the server prompt design
|
|
16
|
+
// (ai-text's `complete` mode only ever continues forward from the given
|
|
17
|
+
// text). Two-layer overlay trick: an aria-hidden div behind the real
|
|
18
|
+
// input renders `value` (invisible, same font/box so it reserves space)
|
|
19
|
+
// followed by the suggestion in muted color; the real input sits on top
|
|
20
|
+
// with a transparent background so the suggestion shows through past the
|
|
21
|
+
// typed text. Both elements MUST share identical typography/box classes
|
|
22
|
+
// or the suggestion won't align with the caret.
|
|
23
|
+
|
|
24
|
+
import type { AiTextRewriteStyle } from "@cosmicdrift/kumiko-renderer";
|
|
25
|
+
import {
|
|
26
|
+
useAiTextAction,
|
|
27
|
+
useCompletion,
|
|
28
|
+
usePrimitives,
|
|
29
|
+
useTranslation,
|
|
30
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
31
|
+
import { Check, Languages, Wand2 } from "lucide-react";
|
|
32
|
+
import { type KeyboardEvent, type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
|
33
|
+
import { cn } from "../lib/cn";
|
|
34
|
+
import { ModeSwitch } from "./mode-switch";
|
|
35
|
+
|
|
36
|
+
type AiTextAction = "correct" | "translate" | "rewrite";
|
|
37
|
+
|
|
38
|
+
interface AiTextFieldBaseProps {
|
|
39
|
+
readonly label: string;
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly value: string;
|
|
43
|
+
readonly onChange: (v: string) => void;
|
|
44
|
+
readonly required?: boolean;
|
|
45
|
+
readonly disabled?: boolean;
|
|
46
|
+
readonly placeholder?: string;
|
|
47
|
+
readonly testId?: string;
|
|
48
|
+
/** Ghost-text completion on/off. Default true. */
|
|
49
|
+
readonly completion?: boolean;
|
|
50
|
+
/** Debounce for ghost-text requests, ms. Default 500 — kept low in
|
|
51
|
+
* tests, tuned for real typing in production. */
|
|
52
|
+
readonly completionDebounceMs?: number;
|
|
53
|
+
/** Toolbar actions to expose. Default all three. */
|
|
54
|
+
readonly actions?: readonly AiTextAction[];
|
|
55
|
+
/** Target languages the translate action offers. First is preselected.
|
|
56
|
+
* Default `["en", "de", "fr", "es"]`. */
|
|
57
|
+
readonly translateLanguages?: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AiTextFieldProps extends AiTextFieldBaseProps {}
|
|
61
|
+
export interface AiTextAreaProps extends AiTextFieldBaseProps {
|
|
62
|
+
readonly rows?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_LANGUAGES: readonly string[] = ["en", "de", "fr", "es"];
|
|
66
|
+
const DEFAULT_ACTIONS: readonly AiTextAction[] = ["correct", "translate", "rewrite"];
|
|
67
|
+
const REWRITE_STYLES: readonly AiTextRewriteStyle[] = ["formal", "casual", "concise", "expand"];
|
|
68
|
+
|
|
69
|
+
const sharedTextClass =
|
|
70
|
+
"w-full rounded-md border border-input px-3 py-2 text-sm shadow-sm leading-6 " +
|
|
71
|
+
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 " +
|
|
72
|
+
"focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
|
|
73
|
+
|
|
74
|
+
// Overlay and real control must share identical wrap/overflow behavior or the
|
|
75
|
+
// ghost suggestion drifts from the caret: <input> never wraps and scrolls
|
|
76
|
+
// horizontally, <textarea> soft-wraps and scrolls vertically.
|
|
77
|
+
const singleLineWrapClass = "whitespace-pre overflow-hidden";
|
|
78
|
+
const multilineWrapClass = "whitespace-pre-wrap break-words overflow-hidden";
|
|
79
|
+
|
|
80
|
+
function AiTextCore({
|
|
81
|
+
multiline,
|
|
82
|
+
label,
|
|
83
|
+
id,
|
|
84
|
+
name,
|
|
85
|
+
value,
|
|
86
|
+
onChange,
|
|
87
|
+
required,
|
|
88
|
+
disabled,
|
|
89
|
+
placeholder,
|
|
90
|
+
testId,
|
|
91
|
+
rows,
|
|
92
|
+
completion = true,
|
|
93
|
+
completionDebounceMs = 500,
|
|
94
|
+
actions = DEFAULT_ACTIONS,
|
|
95
|
+
translateLanguages = DEFAULT_LANGUAGES,
|
|
96
|
+
}: AiTextFieldBaseProps & { readonly multiline: boolean; readonly rows?: number }): ReactNode {
|
|
97
|
+
const { Field, Button, Dialog, Text } = usePrimitives();
|
|
98
|
+
const t = useTranslation();
|
|
99
|
+
|
|
100
|
+
const {
|
|
101
|
+
suggestion,
|
|
102
|
+
state: completionState,
|
|
103
|
+
requestCompletion,
|
|
104
|
+
clear: clearCompletion,
|
|
105
|
+
} = useCompletion(completionDebounceMs);
|
|
106
|
+
const {
|
|
107
|
+
run: runAction,
|
|
108
|
+
state: actionState,
|
|
109
|
+
result: actionResult,
|
|
110
|
+
reset: resetAction,
|
|
111
|
+
} = useAiTextAction();
|
|
112
|
+
|
|
113
|
+
const [dialogMode, setDialogMode] = useState<AiTextAction | null>(null);
|
|
114
|
+
const [targetLanguage, setTargetLanguage] = useState(translateLanguages[0] ?? "en");
|
|
115
|
+
const [rewriteStyle, setRewriteStyle] = useState<AiTextRewriteStyle>("concise");
|
|
116
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
117
|
+
const controlRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
|
118
|
+
|
|
119
|
+
const unavailable = completionState === "unavailable" || actionState === "unavailable";
|
|
120
|
+
const capExceeded = completionState === "cap-exceeded" || actionState === "cap-exceeded";
|
|
121
|
+
const showToolbar = !unavailable && actions.length > 0;
|
|
122
|
+
const showGhost = completion && !unavailable && suggestion !== null && suggestion.length > 0;
|
|
123
|
+
|
|
124
|
+
// ponytail: a manually `resize-y`-shrunk textarea can end up a few px
|
|
125
|
+
// shorter than the overlay div (which has no resize handle), so the
|
|
126
|
+
// synced scrollTop clamps slightly early — sub-line drift, invisible at
|
|
127
|
+
// normal line-height. Upgrade if it ever becomes visually noticeable.
|
|
128
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: value/suggestion are trigger-only, effect reads refs not props
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
if (overlayRef.current === null || controlRef.current === null) return;
|
|
131
|
+
overlayRef.current.scrollLeft = controlRef.current.scrollLeft;
|
|
132
|
+
overlayRef.current.scrollTop = controlRef.current.scrollTop;
|
|
133
|
+
}, [value, suggestion]);
|
|
134
|
+
|
|
135
|
+
function handleChange(next: string): void {
|
|
136
|
+
onChange(next);
|
|
137
|
+
if (completion && !unavailable) {
|
|
138
|
+
requestCompletion(next);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): void {
|
|
143
|
+
if (suggestion === null) return;
|
|
144
|
+
if (e.key === "Tab" && e.currentTarget.selectionStart === value.length) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
onChange(value + suggestion);
|
|
147
|
+
clearCompletion();
|
|
148
|
+
} else if (e.key === "Escape") {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
clearCompletion();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function openDialog(mode: AiTextAction): void {
|
|
155
|
+
resetAction();
|
|
156
|
+
setDialogMode(mode);
|
|
157
|
+
if (mode === "correct") {
|
|
158
|
+
void runAction({ mode: "correct", text: value });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function closeDialog(): void {
|
|
163
|
+
setDialogMode(null);
|
|
164
|
+
resetAction();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runConfiguredAction(): void {
|
|
168
|
+
if (dialogMode === "translate") {
|
|
169
|
+
void runAction({ mode: "translate", text: value, targetLanguage });
|
|
170
|
+
} else if (dialogMode === "rewrite") {
|
|
171
|
+
void runAction({ mode: "rewrite", text: value, style: rewriteStyle });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function applyResult(): void {
|
|
176
|
+
if (actionResult?.type === "text") onChange(actionResult.text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const needsConfig =
|
|
180
|
+
(dialogMode === "translate" || dialogMode === "rewrite") &&
|
|
181
|
+
actionState !== "loading" &&
|
|
182
|
+
actionState !== "success";
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<>
|
|
186
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
187
|
+
<div className="relative w-full">
|
|
188
|
+
{showGhost && (
|
|
189
|
+
<div
|
|
190
|
+
ref={overlayRef}
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
className={cn(
|
|
193
|
+
sharedTextClass,
|
|
194
|
+
multiline ? multilineWrapClass : singleLineWrapClass,
|
|
195
|
+
"pointer-events-none absolute inset-0 select-none border-transparent shadow-none",
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
<span className="invisible">{value}</span>
|
|
199
|
+
<span className="text-muted-foreground">{suggestion}</span>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
{multiline ? (
|
|
203
|
+
<textarea
|
|
204
|
+
ref={(el) => {
|
|
205
|
+
controlRef.current = el;
|
|
206
|
+
}}
|
|
207
|
+
id={id}
|
|
208
|
+
name={name}
|
|
209
|
+
value={value}
|
|
210
|
+
required={required}
|
|
211
|
+
disabled={disabled === true || unavailable}
|
|
212
|
+
placeholder={placeholder}
|
|
213
|
+
rows={rows ?? 4}
|
|
214
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
215
|
+
onKeyDown={handleKeyDown}
|
|
216
|
+
onBlur={clearCompletion}
|
|
217
|
+
onScroll={(e) => {
|
|
218
|
+
if (overlayRef.current) overlayRef.current.scrollTop = e.currentTarget.scrollTop;
|
|
219
|
+
}}
|
|
220
|
+
className={cn(
|
|
221
|
+
sharedTextClass,
|
|
222
|
+
multilineWrapClass,
|
|
223
|
+
"relative resize-y bg-transparent",
|
|
224
|
+
)}
|
|
225
|
+
data-testid={testId !== undefined ? `${testId}-input` : undefined}
|
|
226
|
+
/>
|
|
227
|
+
) : (
|
|
228
|
+
<input
|
|
229
|
+
ref={(el) => {
|
|
230
|
+
controlRef.current = el;
|
|
231
|
+
}}
|
|
232
|
+
id={id}
|
|
233
|
+
name={name}
|
|
234
|
+
type="text"
|
|
235
|
+
value={value}
|
|
236
|
+
required={required}
|
|
237
|
+
disabled={disabled === true || unavailable}
|
|
238
|
+
placeholder={placeholder}
|
|
239
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
240
|
+
onKeyDown={handleKeyDown}
|
|
241
|
+
onBlur={clearCompletion}
|
|
242
|
+
onScroll={(e) => {
|
|
243
|
+
if (overlayRef.current) overlayRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
|
244
|
+
}}
|
|
245
|
+
className={cn(sharedTextClass, singleLineWrapClass, "relative bg-transparent")}
|
|
246
|
+
data-testid={testId !== undefined ? `${testId}-input` : undefined}
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{showGhost && <Text variant="muted">{t("kumiko.aiText.acceptHint")}</Text>}
|
|
252
|
+
{capExceeded && <Text variant="muted">{t("kumiko.aiText.capExceeded")}</Text>}
|
|
253
|
+
|
|
254
|
+
{showToolbar && (
|
|
255
|
+
<div className="mt-1 flex gap-1">
|
|
256
|
+
{actions.includes("correct") && (
|
|
257
|
+
<Button
|
|
258
|
+
variant="secondary"
|
|
259
|
+
size="sm"
|
|
260
|
+
disabled={value.length === 0}
|
|
261
|
+
ariaLabel={t("kumiko.aiText.correct")}
|
|
262
|
+
onClick={() => openDialog("correct")}
|
|
263
|
+
>
|
|
264
|
+
<Check className="size-3.5" aria-hidden="true" />
|
|
265
|
+
</Button>
|
|
266
|
+
)}
|
|
267
|
+
{actions.includes("translate") && (
|
|
268
|
+
<Button
|
|
269
|
+
variant="secondary"
|
|
270
|
+
size="sm"
|
|
271
|
+
disabled={value.length === 0}
|
|
272
|
+
ariaLabel={t("kumiko.aiText.translate")}
|
|
273
|
+
onClick={() => openDialog("translate")}
|
|
274
|
+
>
|
|
275
|
+
<Languages className="size-3.5" aria-hidden="true" />
|
|
276
|
+
</Button>
|
|
277
|
+
)}
|
|
278
|
+
{actions.includes("rewrite") && (
|
|
279
|
+
<Button
|
|
280
|
+
variant="secondary"
|
|
281
|
+
size="sm"
|
|
282
|
+
disabled={value.length === 0}
|
|
283
|
+
ariaLabel={t("kumiko.aiText.rewrite")}
|
|
284
|
+
onClick={() => openDialog("rewrite")}
|
|
285
|
+
>
|
|
286
|
+
<Wand2 className="size-3.5" aria-hidden="true" />
|
|
287
|
+
</Button>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</Field>
|
|
292
|
+
|
|
293
|
+
{/* ponytail: Dialog's built-in Confirm button is always live, even
|
|
294
|
+
while needsConfig is still showing the language/style picker —
|
|
295
|
+
applyResult() no-ops until actionResult exists, so an early click
|
|
296
|
+
just closes the dialog with nothing applied. Gating it needs a
|
|
297
|
+
confirmDisabled prop on the shared Dialog primitive (other
|
|
298
|
+
callers too); not worth widening that contract for one caller.
|
|
299
|
+
Upgrade if this trips users up in practice. */}
|
|
300
|
+
<Dialog
|
|
301
|
+
open={dialogMode !== null}
|
|
302
|
+
onOpenChange={(open) => {
|
|
303
|
+
if (!open) closeDialog();
|
|
304
|
+
}}
|
|
305
|
+
title={dialogMode !== null ? t(`kumiko.aiText.${dialogMode}`) : ""}
|
|
306
|
+
onConfirm={applyResult}
|
|
307
|
+
testId={testId !== undefined ? `${testId}-dialog` : undefined}
|
|
308
|
+
>
|
|
309
|
+
{dialogMode === "translate" && needsConfig && (
|
|
310
|
+
<div className="mb-3 flex flex-col gap-2">
|
|
311
|
+
<ModeSwitch
|
|
312
|
+
value={targetLanguage}
|
|
313
|
+
options={translateLanguages.map((l) => ({ value: l, label: l.toUpperCase() }))}
|
|
314
|
+
onChange={setTargetLanguage}
|
|
315
|
+
/>
|
|
316
|
+
<Button variant="primary" size="sm" onClick={runConfiguredAction}>
|
|
317
|
+
{t(`kumiko.aiText.${dialogMode}`)}
|
|
318
|
+
</Button>
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
{dialogMode === "rewrite" && needsConfig && (
|
|
322
|
+
<div className="mb-3 flex flex-col gap-2">
|
|
323
|
+
<ModeSwitch
|
|
324
|
+
value={rewriteStyle}
|
|
325
|
+
options={REWRITE_STYLES.map((s) => ({
|
|
326
|
+
value: s,
|
|
327
|
+
label: t(`kumiko.aiText.style.${s}`),
|
|
328
|
+
}))}
|
|
329
|
+
onChange={setRewriteStyle}
|
|
330
|
+
/>
|
|
331
|
+
<Button variant="primary" size="sm" onClick={runConfiguredAction}>
|
|
332
|
+
{t(`kumiko.aiText.${dialogMode}`)}
|
|
333
|
+
</Button>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
{actionState === "loading" && (
|
|
337
|
+
<Text variant="muted">{t("kumiko.aiText.diff.generating")}</Text>
|
|
338
|
+
)}
|
|
339
|
+
{actionState === "success" && actionResult?.type === "text" && (
|
|
340
|
+
<div className="flex flex-col gap-3">
|
|
341
|
+
<div>
|
|
342
|
+
<Text variant="small">{t("kumiko.aiText.diff.before")}</Text>
|
|
343
|
+
<p className="whitespace-pre-wrap rounded-md border border-input bg-muted/40 p-2 text-sm">
|
|
344
|
+
{value}
|
|
345
|
+
</p>
|
|
346
|
+
</div>
|
|
347
|
+
<div>
|
|
348
|
+
<Text variant="small">{t("kumiko.aiText.diff.after")}</Text>
|
|
349
|
+
<p className="whitespace-pre-wrap rounded-md border border-input p-2 text-sm">
|
|
350
|
+
{actionResult.text}
|
|
351
|
+
</p>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</Dialog>
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Single-line AI text field — ghost-text completion + correct/translate/
|
|
361
|
+
* rewrite toolbar. Degrades to a plain text field when `ai-text` isn't
|
|
362
|
+
* mounted server-side. */
|
|
363
|
+
export function AiTextField(props: AiTextFieldProps): ReactNode {
|
|
364
|
+
return <AiTextCore {...props} multiline={false} />;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Multi-line variant of {@link AiTextField}. */
|
|
368
|
+
export function AiTextArea(props: AiTextAreaProps): ReactNode {
|
|
369
|
+
return <AiTextCore {...props} multiline={true} />;
|
|
370
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { type ReactNode, useId } from "react";
|
|
2
|
+
import { STATUS_TONE_TEXT, type StatusTone } from "./status-badge";
|
|
3
|
+
|
|
4
|
+
// Inline-SVG-Charts — kein Chart-Dep. Farben ausschließlich über die
|
|
5
|
+
// --color-status-* / --color-foreground Theme-Tokens; Achsen-Labels
|
|
6
|
+
// kommen translated vom Caller (keine Locale-Annahmen im Widget).
|
|
7
|
+
|
|
8
|
+
const TONE_VAR: Record<StatusTone, string> = {
|
|
9
|
+
ok: "var(--color-status-ok)",
|
|
10
|
+
warn: "var(--color-status-warn)",
|
|
11
|
+
bad: "var(--color-status-bad)",
|
|
12
|
+
critical: "var(--color-status-critical)",
|
|
13
|
+
muted: "var(--color-muted-foreground)",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type StatusBarEntry = {
|
|
17
|
+
/** Stable key (z.B. ISO-Datum). */
|
|
18
|
+
readonly key: string;
|
|
19
|
+
/** Balkenhöhe 0..1 (z.B. operational=1, degraded=0.75, outage=0.25). */
|
|
20
|
+
readonly level: number;
|
|
21
|
+
readonly tone: StatusTone;
|
|
22
|
+
/** Tooltip-Text (<title>) — translated vom Caller. */
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Status-Balkenleiste (z.B. 90-Tage-Uptime): variable-height Bars mit
|
|
27
|
+
* Gradient-Fade + Tick-Line am Bar-Top; der letzte Eintrag bekommt einen
|
|
28
|
+
* „jetzt"-Accent-Stripe. */
|
|
29
|
+
export function StatusBarChart({
|
|
30
|
+
entries,
|
|
31
|
+
ariaLabel,
|
|
32
|
+
startLabel,
|
|
33
|
+
endLabel,
|
|
34
|
+
highlightLast = true,
|
|
35
|
+
testId,
|
|
36
|
+
}: {
|
|
37
|
+
readonly entries: readonly StatusBarEntry[];
|
|
38
|
+
readonly ariaLabel: string;
|
|
39
|
+
/** Achsen-Beschriftung links/rechts unter dem Chart — translated. */
|
|
40
|
+
readonly startLabel?: string;
|
|
41
|
+
readonly endLabel?: string;
|
|
42
|
+
readonly highlightLast?: boolean;
|
|
43
|
+
readonly testId?: string;
|
|
44
|
+
}): ReactNode {
|
|
45
|
+
const gradPrefix = useId();
|
|
46
|
+
if (entries.length === 0) return <div className="h-9" aria-hidden />;
|
|
47
|
+
|
|
48
|
+
const chartHeight = 36;
|
|
49
|
+
const tickHeight = 1;
|
|
50
|
+
const barGap = 1;
|
|
51
|
+
const lastIdx = entries.length - 1;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div data-testid={testId}>
|
|
55
|
+
<svg
|
|
56
|
+
viewBox={`0 0 ${entries.length * (1 + barGap)} ${chartHeight}`}
|
|
57
|
+
preserveAspectRatio="none"
|
|
58
|
+
className="block h-9 w-full"
|
|
59
|
+
role="img"
|
|
60
|
+
aria-label={ariaLabel}
|
|
61
|
+
>
|
|
62
|
+
<title>{ariaLabel}</title>
|
|
63
|
+
{entries.map((entry, idx) => {
|
|
64
|
+
const x = idx * (1 + barGap);
|
|
65
|
+
const level = Math.max(0, Math.min(1, entry.level));
|
|
66
|
+
const barHeight = (chartHeight - tickHeight) * level;
|
|
67
|
+
const barY = chartHeight - barHeight;
|
|
68
|
+
const isLast = highlightLast && idx === lastIdx;
|
|
69
|
+
const color = TONE_VAR[entry.tone];
|
|
70
|
+
const gradId = `${gradPrefix}-${idx}`;
|
|
71
|
+
return (
|
|
72
|
+
<g key={entry.key}>
|
|
73
|
+
<defs>
|
|
74
|
+
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
|
75
|
+
<stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
|
|
76
|
+
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
|
77
|
+
</linearGradient>
|
|
78
|
+
</defs>
|
|
79
|
+
{isLast && (
|
|
80
|
+
<rect
|
|
81
|
+
x={x - 0.5}
|
|
82
|
+
y={0}
|
|
83
|
+
width={2}
|
|
84
|
+
height={chartHeight}
|
|
85
|
+
fill="var(--color-foreground)"
|
|
86
|
+
fillOpacity={0.06}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
<rect x={x} y={barY} width={1} height={barHeight} fill={`url(#${gradId})`}>
|
|
90
|
+
{entry.label !== undefined && <title>{entry.label}</title>}
|
|
91
|
+
</rect>
|
|
92
|
+
<rect
|
|
93
|
+
x={x}
|
|
94
|
+
y={barY - tickHeight}
|
|
95
|
+
width={1}
|
|
96
|
+
height={tickHeight}
|
|
97
|
+
fill="var(--color-foreground)"
|
|
98
|
+
fillOpacity={isLast ? 1.0 : 0.7}
|
|
99
|
+
/>
|
|
100
|
+
</g>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</svg>
|
|
104
|
+
{(startLabel !== undefined || endLabel !== undefined) && (
|
|
105
|
+
<div className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
|
|
106
|
+
<span>{startLabel}</span>
|
|
107
|
+
<span>{endLabel}</span>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Quadratic-durch-Mittelpunkte-Trick: glättet die Zick-Zack-Linie ohne
|
|
115
|
+
* Overshoot (~5 Zeilen statt Catmull-Rom/Bezier-Fit). */
|
|
116
|
+
export function smoothPath(pts: ReadonlyArray<{ readonly x: number; readonly y: number }>): string {
|
|
117
|
+
const first = pts[0];
|
|
118
|
+
if (!first) return "";
|
|
119
|
+
let d = `M ${first.x.toFixed(1)} ${first.y.toFixed(1)}`;
|
|
120
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
121
|
+
const cur = pts[i];
|
|
122
|
+
const next = pts[i + 1];
|
|
123
|
+
if (!cur || !next) break;
|
|
124
|
+
const midX = (cur.x + next.x) / 2;
|
|
125
|
+
const midY = (cur.y + next.y) / 2;
|
|
126
|
+
d += ` Q ${cur.x.toFixed(1)} ${cur.y.toFixed(1)}, ${midX.toFixed(1)} ${midY.toFixed(1)}`;
|
|
127
|
+
}
|
|
128
|
+
const last = pts[pts.length - 1];
|
|
129
|
+
if (!last) return d;
|
|
130
|
+
return `${d} L ${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type TimeseriesPoint = {
|
|
134
|
+
readonly atMs: number;
|
|
135
|
+
/** null = Ausfall/kein Wert → fällt auf die Grundlinie (sichtbarer Einbruch). */
|
|
136
|
+
readonly value: number | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Zeitreihen-Linien-Chart (geglättete Linie + Flächen-Verlauf). x-Achse =
|
|
140
|
+
* ZEIT im Fenster windowStartMs..windowEndMs, nicht Index — 5 Min Daten in
|
|
141
|
+
* einem 30-Tage-Fenster ergeben ehrlich einen schmalen Streifen rechts. */
|
|
142
|
+
export function TimeseriesChart({
|
|
143
|
+
points,
|
|
144
|
+
windowStartMs,
|
|
145
|
+
windowEndMs,
|
|
146
|
+
tone = "ok",
|
|
147
|
+
ariaLabel,
|
|
148
|
+
axisLabels,
|
|
149
|
+
emptyContent,
|
|
150
|
+
testId,
|
|
151
|
+
}: {
|
|
152
|
+
readonly points: readonly TimeseriesPoint[];
|
|
153
|
+
readonly windowStartMs: number;
|
|
154
|
+
readonly windowEndMs: number;
|
|
155
|
+
readonly tone?: StatusTone;
|
|
156
|
+
readonly ariaLabel: string;
|
|
157
|
+
/** Achsen-Zeile unter dem Chart (translated/formatiert vom Caller). */
|
|
158
|
+
readonly axisLabels?: { readonly start: string; readonly mid?: string; readonly end: string };
|
|
159
|
+
/** Rendert statt des Charts wenn <2 Messwerte vorliegen. */
|
|
160
|
+
readonly emptyContent?: ReactNode;
|
|
161
|
+
readonly testId?: string;
|
|
162
|
+
}): ReactNode {
|
|
163
|
+
const gradientId = useId();
|
|
164
|
+
const chartWidth = 300;
|
|
165
|
+
const chartHeight = 64;
|
|
166
|
+
|
|
167
|
+
const values = points.map((p) => p.value).filter((v): v is number => v !== null);
|
|
168
|
+
if (values.length < 2) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex h-16 items-center justify-center text-[13px] text-muted-foreground">
|
|
171
|
+
{emptyContent}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const maxValue = Math.max(...values, 1);
|
|
177
|
+
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
178
|
+
const xOf = (atMs: number) =>
|
|
179
|
+
Math.max(0, Math.min(1, (atMs - windowStartMs) / span)) * chartWidth;
|
|
180
|
+
const chartPoints = points.map((p) => ({
|
|
181
|
+
x: xOf(p.atMs),
|
|
182
|
+
y: p.value === null ? chartHeight : chartHeight - (p.value / maxValue) * chartHeight,
|
|
183
|
+
}));
|
|
184
|
+
const linePath = smoothPath(chartPoints);
|
|
185
|
+
const firstPoint = chartPoints[0];
|
|
186
|
+
const lastPoint = chartPoints[chartPoints.length - 1];
|
|
187
|
+
const areaPath =
|
|
188
|
+
firstPoint && lastPoint
|
|
189
|
+
? `${linePath} L ${lastPoint.x.toFixed(1)} ${chartHeight} L ${firstPoint.x.toFixed(1)} ${chartHeight} Z`
|
|
190
|
+
: "";
|
|
191
|
+
const color = TONE_VAR[tone];
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div data-testid={testId} className={STATUS_TONE_TEXT[tone]}>
|
|
195
|
+
<svg
|
|
196
|
+
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
|
197
|
+
preserveAspectRatio="none"
|
|
198
|
+
className="block h-16 w-full"
|
|
199
|
+
role="img"
|
|
200
|
+
aria-label={ariaLabel}
|
|
201
|
+
>
|
|
202
|
+
<title>{ariaLabel}</title>
|
|
203
|
+
<defs>
|
|
204
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
205
|
+
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
|
206
|
+
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
|
207
|
+
</linearGradient>
|
|
208
|
+
</defs>
|
|
209
|
+
<path d={areaPath} fill={`url(#${gradientId})`} stroke="none" />
|
|
210
|
+
<path
|
|
211
|
+
d={linePath}
|
|
212
|
+
fill="none"
|
|
213
|
+
stroke={color}
|
|
214
|
+
strokeWidth={1.5}
|
|
215
|
+
strokeLinejoin="round"
|
|
216
|
+
vectorEffect="non-scaling-stroke"
|
|
217
|
+
/>
|
|
218
|
+
</svg>
|
|
219
|
+
{axisLabels !== undefined && (
|
|
220
|
+
<div className="mt-1 flex justify-between text-[11px] text-muted-foreground">
|
|
221
|
+
<span>{axisLabels.start}</span>
|
|
222
|
+
{axisLabels.mid !== undefined && <span>{axisLabels.mid}</span>}
|
|
223
|
+
<span>{axisLabels.end}</span>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** Aufklappbare Sektion (native <details>, aber React-kontrolliert: sonst
|
|
5
|
+
* springt das open-Attribut bei jedem Parent-Re-Render auf den Initialwert
|
|
6
|
+
* zurück). Eine Optik für alle „Erweitert"/„Mehr"-Abschnitte. */
|
|
7
|
+
export function CollapsibleSection({
|
|
8
|
+
title,
|
|
9
|
+
defaultOpen = false,
|
|
10
|
+
children,
|
|
11
|
+
testId,
|
|
12
|
+
}: {
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly defaultOpen?: boolean;
|
|
15
|
+
readonly children: ReactNode;
|
|
16
|
+
readonly testId?: string;
|
|
17
|
+
}): ReactNode {
|
|
18
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
19
|
+
// useState(defaultOpen) liest nur den Mount-Wert — ein async hydrierter
|
|
20
|
+
// Draft flippt `defaultOpen` zu spät für den Lazy-Initializer. Open-only
|
|
21
|
+
// Effect: kämpft nie gegen ein manuelles Zuklappen des Users.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (defaultOpen) setOpen(true);
|
|
24
|
+
}, [defaultOpen]);
|
|
25
|
+
return (
|
|
26
|
+
<details
|
|
27
|
+
data-testid={testId}
|
|
28
|
+
className="group rounded-lg border bg-card"
|
|
29
|
+
open={open}
|
|
30
|
+
onToggle={(e) => setOpen(e.currentTarget.open)}
|
|
31
|
+
>
|
|
32
|
+
<summary className="flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
|
33
|
+
<span>{title}</span>
|
|
34
|
+
<ChevronRight
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className="size-4 text-muted-foreground transition-transform group-open:rotate-90"
|
|
37
|
+
/>
|
|
38
|
+
</summary>
|
|
39
|
+
<div className="border-t px-4 py-3">{children}</div>
|
|
40
|
+
</details>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Read-only Schlüssel-Wert-Liste für Detail-Masken (Label links gedimmt,
|
|
5
|
+
* Wert rechts). Wert ist ReactNode → Badges/Chips möglich. `emphasize` hebt
|
|
6
|
+
* eine Zeile hervor (z.B. das Endergebnis in einem Rechner). */
|
|
7
|
+
export function DetailList({
|
|
8
|
+
rows,
|
|
9
|
+
testId,
|
|
10
|
+
}: {
|
|
11
|
+
readonly rows: readonly {
|
|
12
|
+
/** Optional stable key -- falls back to the label when omitted (fine as
|
|
13
|
+
* long as labels are unique; set id when they can repeat). */
|
|
14
|
+
readonly id?: string;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly value: ReactNode;
|
|
17
|
+
readonly emphasize?: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
readonly testId?: string;
|
|
20
|
+
}): ReactNode {
|
|
21
|
+
return (
|
|
22
|
+
<dl data-testid={testId} className="flex flex-col divide-y">
|
|
23
|
+
{rows.map((row) => (
|
|
24
|
+
<div
|
|
25
|
+
key={row.id ?? row.label}
|
|
26
|
+
className="grid grid-cols-1 gap-0.5 py-2.5 sm:grid-cols-[200px_1fr] sm:gap-4"
|
|
27
|
+
>
|
|
28
|
+
<dt
|
|
29
|
+
className={cn(
|
|
30
|
+
"text-sm text-muted-foreground",
|
|
31
|
+
row.emphasize === true && "font-semibold text-foreground",
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{row.label}
|
|
35
|
+
</dt>
|
|
36
|
+
<dd className={cn("text-sm font-medium", row.emphasize === true && "font-semibold")}>
|
|
37
|
+
{row.value}
|
|
38
|
+
</dd>
|
|
39
|
+
</div>
|
|
40
|
+
))}
|
|
41
|
+
</dl>
|
|
42
|
+
);
|
|
43
|
+
}
|