@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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
@@ -9,7 +9,7 @@
9
9
  // Dropdown etc. kommen später).
10
10
 
11
11
  import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
12
- import { applyFormatSpec } from "@cosmicdrift/kumiko-headless";
12
+ import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
13
13
  import type {
14
14
  DataTableRowAction,
15
15
  DataTableRowActionMode,
@@ -29,12 +29,14 @@ import {
29
29
  type GridProps,
30
30
  type HeadingProps,
31
31
  type InputProps,
32
+ type LinkProps,
32
33
  type SectionProps,
33
34
  type TextProps,
34
35
  useColumnRenderer,
35
36
  useTranslation,
36
37
  WriteFailedError,
37
38
  } from "@cosmicdrift/kumiko-renderer";
39
+ import { cva } from "class-variance-authority";
38
40
  import {
39
41
  ArrowDown,
40
42
  ArrowUp,
@@ -58,8 +60,7 @@ import {
58
60
  } from "react";
59
61
  import { cn } from "../lib/cn";
60
62
  import { Badge } from "../ui/badge";
61
- import { Button as UiButton } from "../ui/button";
62
- import { Card, CardContent } from "../ui/card";
63
+ import { buttonVariants, Button as UiButton } from "../ui/button";
63
64
  import { Checkbox } from "../ui/checkbox";
64
65
  import { Input as UiInput } from "../ui/input";
65
66
  import { Label as UiLabel } from "../ui/label";
@@ -76,20 +77,44 @@ import {
76
77
  DropdownMenuTrigger,
77
78
  } from "./dropdown-menu";
78
79
  import { FileUploadInput } from "./file-upload";
80
+ import { DefaultLightbox } from "./lightbox";
79
81
  import { LocatedTimestampInput } from "./located-timestamp-input";
80
- import { MoneyInput } from "./money-input";
82
+ import { formatMoney, MoneyInput } from "./money-input";
81
83
  import { TimestampInput } from "./timestamp-input";
82
84
  import { useToast } from "./toast";
83
85
 
86
+ // ---- Card-Chrome (eine Definition für Form/Section/Card) ----
87
+
88
+ // Die eine Card-Surface. Maße (Padding/Radius/Shadow) kommen aus --card-*
89
+ // CSS-Tokens (Defaults in styles.css), damit eine App sie einmal zentral in
90
+ // ihrer styles.css überschreiben kann — wie die Farben. Radius-Variante:
91
+ // xl = Card/Screen-Fläche (Default, token-getrieben), lg = "innen"-Fläche.
92
+ const cardSurface = cva(
93
+ "flex flex-col border bg-card text-card-foreground shadow-[var(--card-shadow)]",
94
+ {
95
+ variants: { radius: { xl: "rounded-[var(--card-radius)]", lg: "rounded-lg" } },
96
+ defaultVariants: { radius: "xl" },
97
+ },
98
+ );
99
+ const cardFooter = "flex items-center justify-end gap-2 px-[var(--card-padding)] py-4";
100
+ const cardFooterBorder = "border-t bg-muted/30";
101
+
84
102
  // ---- Button (vendored shadcn ui/button) ----
85
103
 
86
104
  // Contract-Variant → shadcn-Variant: secondary war schon immer der
87
105
  // bordered-bg-background-Look = shadcns `outline`. primary→default,
88
- // danger→destructive.
106
+ // danger→destructive, link→link (kein BG, underline on hover).
89
107
  const BUTTON_VARIANT = {
90
108
  primary: "default",
91
109
  secondary: "outline",
92
110
  danger: "destructive",
111
+ link: "link",
112
+ } as const;
113
+
114
+ const BUTTON_SIZE = {
115
+ sm: "sm",
116
+ md: "default",
117
+ icon: "icon",
93
118
  } as const;
94
119
 
95
120
  function DefaultButton({
@@ -98,9 +123,18 @@ function DefaultButton({
98
123
  disabled,
99
124
  loading,
100
125
  variant = "primary",
126
+ size = "md",
127
+ ariaLabel,
128
+ width = "auto",
101
129
  children,
102
130
  testId,
103
131
  }: ButtonProps): ReactNode {
132
+ // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
133
+ // gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
134
+ const className =
135
+ [variant === "link" ? "h-auto px-0 py-0" : "", width === "full" ? "w-full" : ""]
136
+ .filter(Boolean)
137
+ .join(" ") || undefined;
104
138
  return (
105
139
  <UiButton
106
140
  type={type}
@@ -109,6 +143,9 @@ function DefaultButton({
109
143
  data-testid={testId}
110
144
  data-loading={loading === true ? "true" : undefined}
111
145
  variant={BUTTON_VARIANT[variant]}
146
+ size={BUTTON_SIZE[size]}
147
+ aria-label={ariaLabel}
148
+ className={className}
112
149
  >
113
150
  {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
114
151
  </UiButton>
@@ -230,6 +267,8 @@ function DefaultInput(props: InputProps): ReactNode {
230
267
  <UiInput
231
268
  type="text"
232
269
  {...common}
270
+ data-testid={props.testId}
271
+ readOnly={props.readOnly}
233
272
  value={props.value}
234
273
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
235
274
  {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
@@ -241,6 +280,7 @@ function DefaultInput(props: InputProps): ReactNode {
241
280
  <UiInput
242
281
  type="email"
243
282
  {...common}
283
+ data-testid={props.testId}
244
284
  value={props.value}
245
285
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
246
286
  {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
@@ -252,6 +292,7 @@ function DefaultInput(props: InputProps): ReactNode {
252
292
  <UiInput
253
293
  type="password"
254
294
  {...common}
295
+ data-testid={props.testId}
255
296
  value={props.value}
256
297
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
257
298
  autoComplete={props.autoComplete ?? "current-password"}
@@ -262,6 +303,7 @@ function DefaultInput(props: InputProps): ReactNode {
262
303
  <UiInput
263
304
  type="number"
264
305
  {...common}
306
+ data-testid={props.testId}
265
307
  value={props.value}
266
308
  onChange={(e: ChangeEvent<HTMLInputElement>) => {
267
309
  const v = e.target.value;
@@ -270,6 +312,19 @@ function DefaultInput(props: InputProps): ReactNode {
270
312
  className="text-right tabular-nums"
271
313
  />
272
314
  );
315
+ case "range":
316
+ return (
317
+ <input
318
+ type="range"
319
+ {...common}
320
+ min={props.min}
321
+ max={props.max}
322
+ step={props.step ?? 1}
323
+ value={props.value}
324
+ onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(Number(e.target.value))}
325
+ className="w-full accent-primary"
326
+ />
327
+ );
273
328
  case "boolean":
274
329
  return (
275
330
  <Checkbox
@@ -501,9 +556,11 @@ function DefaultDataTable({
501
556
  {emptyState ?? <span>No entries.</span>}
502
557
  </div>
503
558
  ) : (
504
- // dashboard-01-Muster: schlichter `rounded-lg border`-Rahmen (kein
505
- // bg-card/shadow), die Header-Zeile trägt den bg-muted-Grauton.
506
- <div className="overflow-hidden rounded-lg border">
559
+ // dashboard-01-Muster: `rounded-lg border`-Rahmen, die Header-Zeile
560
+ // trägt den bg-muted-Grauton. `bg-card` (statt transparent) → die Liste
561
+ // sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
562
+ // Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
563
+ <div className="overflow-hidden rounded-lg border bg-card">
507
564
  <Table data-testid={testId}>
508
565
  {tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions, rowActionMode)}
509
566
  </Table>
@@ -754,7 +811,7 @@ function useRowActionTrigger(row: ListRowViewModel) {
754
811
  toast({
755
812
  title: t("kumiko.rowAction.failed"),
756
813
  description: e instanceof Error ? e.message : String(e),
757
- variant: "destructive",
814
+ variant: "bad",
758
815
  ...(docsUrl !== undefined && { docsUrl }),
759
816
  });
760
817
  } finally {
@@ -1199,6 +1256,16 @@ export function isComponentRendererRef(renderer: unknown): { readonly name: stri
1199
1256
  // applyFormatSpec re-exported from headless (platform-agnostic).
1200
1257
  export { applyFormatSpec };
1201
1258
 
1259
+ function isMoneyValue(value: unknown): value is { amount: number; currency: string } {
1260
+ return (
1261
+ typeof value === "object" &&
1262
+ value !== null &&
1263
+ typeof (value as Record<string, unknown>)["amount"] === "number" &&
1264
+ typeof (value as Record<string, unknown>)["currency"] === "string" &&
1265
+ /^[A-Za-z]{3}$/.test((value as Record<string, unknown>)["currency"] as string)
1266
+ );
1267
+ }
1268
+
1202
1269
  // Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
1203
1270
  // expliziten renderer setzen (FormatSpec oder PlatformComponent); ohne
1204
1271
  // expliziten renderer fällt DataTableCell hier durch.
@@ -1206,6 +1273,7 @@ export { applyFormatSpec };
1206
1273
  // - boolean → ✓ / leer
1207
1274
  // - timestamp/date → locale-formatiert (kein roher ISO-String)
1208
1275
  // - select → human-lesbar (kebab-case → Title Case)
1276
+ // - money → { amount, currency } formatted via Intl (not "[object Object]")
1209
1277
  // - text/number/sonst → toString
1210
1278
  export function defaultCellRender(
1211
1279
  value: unknown,
@@ -1215,6 +1283,16 @@ export function defaultCellRender(
1215
1283
  if (value === null || value === undefined || value === "") return "";
1216
1284
  if (type === "boolean") return value === true ? "✓" : "";
1217
1285
  if (type === "timestamp" || type === "date") return applyFormatSpec({ format: type }, value);
1286
+ if (type === "money") {
1287
+ if (!isMoneyValue(value)) return String(value);
1288
+ // No `locale` param here: DataTableCell has no app-locale context to
1289
+ // thread through (unlike the form path — see MoneyInput/RenderField in
1290
+ // packages/renderer, which pins the LocaleProvider locale). formatMoney
1291
+ // falls back to guessLocale() (navigator.language), so the same amount
1292
+ // can render differently in a table vs. a form on the same screen.
1293
+ // Mid-term: pass the app locale down here too, analogous to render-field.
1294
+ return formatMoney(value.amount, value.currency);
1295
+ }
1218
1296
  if (type === "select") {
1219
1297
  const raw = String(value);
1220
1298
  // Translated Label aus dem ViewModel-Builder (Convention-Key
@@ -1354,7 +1432,9 @@ function DefaultForm({
1354
1432
  // OHNE Trennlinie darunter (Titel fließt in die erste Section). Sections
1355
1433
  // sind divide-y-getrennt (Linien nur ZWISCHEN ihnen). Action-Footer mit
1356
1434
  // bg-muted/30 als Farb-Trenner statt harter Linie. main hat kein Padding;
1357
- // der max-w-3xl-Body rahmt die Card.
1435
+ // der max-w-3xl-Body rahmt die Card, mx-auto zentriert ihn im Main statt
1436
+ // linksbündig (sonst „eingesperrt" mit leerer rechter Hälfte auf breiten
1437
+ // Screens).
1358
1438
  return (
1359
1439
  <form
1360
1440
  onSubmit={(e) => {
@@ -1364,8 +1444,8 @@ function DefaultForm({
1364
1444
  data-testid={testId}
1365
1445
  className="flex flex-col w-full"
1366
1446
  >
1367
- <div className="px-6 pt-6 pb-12 max-w-3xl w-full">
1368
- <div className="bg-card overflow-hidden rounded-xl border shadow-sm">
1447
+ <FormScreenShell>
1448
+ <div className={cn(cardSurface(), "overflow-hidden")}>
1369
1449
  {(title !== undefined || subtitle !== undefined) && (
1370
1450
  <div className="px-6 pb-2 pt-5">
1371
1451
  {title !== undefined && (
@@ -1402,18 +1482,62 @@ function DefaultForm({
1402
1482
  {actions !== undefined && (
1403
1483
  <div
1404
1484
  data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1405
- className="flex items-center justify-end gap-2 border-t bg-muted/30 px-6 py-4"
1485
+ className={cn(cardFooter, cardFooterBorder)}
1406
1486
  >
1407
1487
  {actions}
1408
1488
  </div>
1409
1489
  )}
1410
1490
  </div>
1411
- </div>
1491
+ </FormScreenShell>
1412
1492
  </form>
1413
1493
  );
1414
1494
  }
1415
1495
 
1416
- function DefaultSection({ title, subtitle, children, actions, testId }: SectionProps): ReactNode {
1496
+ // Kanonische Form/Settings-Shell: zentrierte Spalte mit Standard-Screen-
1497
+ // Padding. DefaultForm (configEdit/entityEdit) UND custom Settings-Screens
1498
+ // (url-settings, privacy-center) teilen sie → einheitliche Breite +
1499
+ // Zentrierung statt per-Screen-Wildwuchs. Breite über `maxWidth`-Intent statt
1500
+ // beliebiger max-w-*-Overrides: sm=schmale Auth-Forms, 3xl=Standard-Detail,
1501
+ // 4xl=tabellen-nahe Forms, full=volle Breite. Inhalt nutzt Card-Primitives;
1502
+ // `className` (z.B. "flex flex-col gap-6") für Multi-Card-Stacks.
1503
+ export type FormScreenShellWidth = "sm" | "3xl" | "4xl" | "full";
1504
+
1505
+ const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
1506
+ sm: "max-w-sm mx-auto",
1507
+ "3xl": "max-w-3xl mx-auto",
1508
+ "4xl": "max-w-4xl mx-auto",
1509
+ full: "max-w-full",
1510
+ };
1511
+
1512
+ export function FormScreenShell({
1513
+ children,
1514
+ className,
1515
+ testId,
1516
+ maxWidth = "3xl",
1517
+ }: {
1518
+ readonly children: ReactNode;
1519
+ readonly className?: string;
1520
+ readonly testId?: string;
1521
+ readonly maxWidth?: FormScreenShellWidth;
1522
+ }): ReactNode {
1523
+ return (
1524
+ <div
1525
+ data-testid={testId}
1526
+ className={cn("px-6 pt-6 pb-12 w-full", formScreenShellWidth[maxWidth], className)}
1527
+ >
1528
+ {children}
1529
+ </div>
1530
+ );
1531
+ }
1532
+
1533
+ function DefaultSection({
1534
+ title,
1535
+ subtitle,
1536
+ children,
1537
+ actions,
1538
+ variant = "default",
1539
+ testId,
1540
+ }: SectionProps): ReactNode {
1417
1541
  const insideForm = useContext(InsideFormContext);
1418
1542
 
1419
1543
  // h3 statt CardTitle (= div): erhält die Heading-Semantik für
@@ -1446,7 +1570,13 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
1446
1570
  // actions hier = rechtsbündige Reihe (das Form trägt den eigenen Footer).
1447
1571
  if (insideForm) {
1448
1572
  return (
1449
- <section data-testid={testId} className="flex flex-col gap-4 px-6 py-6">
1573
+ <section
1574
+ data-testid={testId}
1575
+ className={cn(
1576
+ "flex flex-col gap-4 px-6 py-6",
1577
+ variant === "destructive" && "border-l-2 border-destructive/40",
1578
+ )}
1579
+ >
1450
1580
  {header}
1451
1581
  {children}
1452
1582
  {actions !== undefined && (
@@ -1469,20 +1599,27 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
1469
1599
  // `children`) WOULD get silently clipped — verify this against any new
1470
1600
  // standalone-section content that renders its own non-portaled overlay.
1471
1601
  return (
1472
- <Card data-testid={testId} className="gap-0 overflow-hidden rounded-lg py-0">
1473
- <CardContent className="flex flex-col gap-4 px-6 py-6">
1602
+ <div
1603
+ data-testid={testId}
1604
+ className={cn(
1605
+ cardSurface(),
1606
+ "overflow-hidden",
1607
+ variant === "destructive" && "border-destructive/40",
1608
+ )}
1609
+ >
1610
+ <div className="flex flex-col gap-4 px-6 py-6">
1474
1611
  {header}
1475
1612
  {children}
1476
- </CardContent>
1613
+ </div>
1477
1614
  {actions !== undefined && (
1478
1615
  <div
1479
1616
  data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1480
- className="flex items-center justify-end gap-2 border-t bg-muted/30 px-6 py-4"
1617
+ className={cn(cardFooter, cardFooterBorder)}
1481
1618
  >
1482
1619
  {actions}
1483
1620
  </div>
1484
1621
  )}
1485
- </Card>
1622
+ </div>
1486
1623
  );
1487
1624
  }
1488
1625
 
@@ -1532,11 +1669,54 @@ function DefaultText({ variant = "body", children, testId }: TextProps): ReactNo
1532
1669
  {children}
1533
1670
  </span>
1534
1671
  );
1672
+ case "muted":
1673
+ return (
1674
+ <span data-testid={testId} className="text-sm text-muted-foreground">
1675
+ {children}
1676
+ </span>
1677
+ );
1535
1678
  default:
1536
1679
  return <span data-testid={testId}>{children}</span>;
1537
1680
  }
1538
1681
  }
1539
1682
 
1683
+ // ---- Link (anchor mit Button-/Muted-Optik) ----
1684
+
1685
+ // isSafeHref lives in @cosmicdrift/kumiko-headless — shared with
1686
+ // page-render's server-side markdown renderer, both take untrusted
1687
+ // tenant-authored hrefs.
1688
+
1689
+ // `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
1690
+ // der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
1691
+ // authButtonClass), `muted` der dezente Sekundär-Link (ehem.
1692
+ // authMutedLinkClass).
1693
+ function DefaultLink({
1694
+ href,
1695
+ variant = "default",
1696
+ target,
1697
+ className,
1698
+ children,
1699
+ testId,
1700
+ }: LinkProps): ReactNode {
1701
+ const variantClass =
1702
+ variant === "button"
1703
+ ? buttonVariants({ variant: "default" })
1704
+ : variant === "muted"
1705
+ ? "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
1706
+ : "text-primary underline-offset-4 hover:underline";
1707
+ return (
1708
+ <a
1709
+ href={isSafeHref(href) ? href : "#"}
1710
+ target={target}
1711
+ rel={target === "_blank" ? "noreferrer" : undefined}
1712
+ data-testid={testId}
1713
+ className={cn(variantClass, className)}
1714
+ >
1715
+ {children}
1716
+ </a>
1717
+ );
1718
+ }
1719
+
1540
1720
  function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
1541
1721
  // Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
1542
1722
  // Section-Heading = h2 mit uppercase + muted-foreground — derselbe
@@ -1564,14 +1744,14 @@ import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/con
1564
1744
 
1565
1745
  // Generische Card-Chrome (rounded-xl wie die Entity-Card) — slot- + options-
1566
1746
  // basiert, damit der Contract additiv wächst und Consumer nie migriert werden.
1567
- function DefaultCard({ slots, options, className, testId, children }: CardProps): ReactNode {
1747
+ export function DefaultCard({ slots, options, className, testId, children }: CardProps): ReactNode {
1568
1748
  const padded = options?.padded ?? true;
1569
1749
  const radius = options?.radius ?? "xl";
1570
1750
  const footerBordered = options?.footerBordered ?? true;
1571
1751
  const s = slots ?? {};
1572
1752
  const defaultHeader =
1573
1753
  s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined ? (
1574
- <div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
1754
+ <div className="flex flex-wrap items-start justify-between gap-3 px-[var(--card-padding)] pt-6 pb-4">
1575
1755
  <div className="flex flex-col gap-1">
1576
1756
  {s.title !== undefined && (
1577
1757
  <h3 className="text-base font-semibold leading-none tracking-tight">{s.title}</h3>
@@ -1586,30 +1766,26 @@ function DefaultCard({ slots, options, className, testId, children }: CardProps)
1586
1766
  const header = s.header ?? defaultHeader;
1587
1767
  const hasHeader = header !== null && header !== undefined;
1588
1768
  return (
1589
- <div
1590
- data-testid={testId}
1591
- className={cn(
1592
- "flex flex-col overflow-hidden border bg-card text-card-foreground shadow-sm",
1593
- radius === "xl" ? "rounded-xl" : "rounded-lg",
1594
- className,
1595
- )}
1596
- >
1769
+ <div data-testid={testId} className={cn(cardSurface({ radius }), "overflow-hidden", className)}>
1597
1770
  {header}
1598
1771
  {/* != null covers undefined AND explicit null; a `false` child (from
1599
1772
  `cond && <El/>`) still renders no visible content either way. */}
1600
1773
  {children != null && (
1601
- <div className={cn("grow", padded && (hasHeader ? "px-6 pb-6" : "p-6"))}>{children}</div>
1602
- )}
1603
- {s.footer !== undefined && (
1604
1774
  <div
1605
1775
  className={cn(
1606
- "flex items-center justify-end gap-2 px-6 py-4",
1607
- footerBordered && "border-t bg-muted/30",
1776
+ "grow",
1777
+ padded &&
1778
+ (hasHeader
1779
+ ? "px-[var(--card-padding)] pb-[var(--card-padding)]"
1780
+ : "p-[var(--card-padding)]"),
1608
1781
  )}
1609
1782
  >
1610
- {s.footer}
1783
+ {children}
1611
1784
  </div>
1612
1785
  )}
1786
+ {s.footer !== undefined && (
1787
+ <div className={cn(cardFooter, footerBordered && cardFooterBorder)}>{s.footer}</div>
1788
+ )}
1613
1789
  </div>
1614
1790
  );
1615
1791
  }
@@ -1628,6 +1804,8 @@ export const defaultPrimitives: CorePrimitives = {
1628
1804
  Text: DefaultText,
1629
1805
  Heading: DefaultHeading,
1630
1806
  Dialog: DefaultDialog,
1807
+ Lightbox: DefaultLightbox,
1631
1808
  ConfigSourceBadge: DefaultConfigSourceBadge,
1632
1809
  ConfigCascadeView: DefaultConfigCascadeView,
1810
+ Link: DefaultLink,
1633
1811
  };
@@ -0,0 +1,39 @@
1
+ // Layout-Primitives für Custom-Screens: ein Ort für vertikale Abstände und
2
+ // Screen-Padding, damit Consumer nicht `flex flex-col gap-*` / `p-6` per Hand
3
+ // streuen. Bewusst dünn — kein generisches Box-mit-20-props-System.
4
+
5
+ import type { ReactNode } from "react";
6
+ import { cn } from "../lib/cn";
7
+
8
+ const STACK_GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6" } as const;
9
+
10
+ type StackGap = keyof typeof STACK_GAP;
11
+
12
+ type StackProps = {
13
+ readonly gap?: StackGap;
14
+ readonly className?: string;
15
+ readonly children?: ReactNode;
16
+ readonly testId?: string;
17
+ };
18
+
19
+ export function Stack({ gap = "md", className, children, testId }: StackProps): ReactNode {
20
+ return (
21
+ <div data-testid={testId} className={cn("flex flex-col", STACK_GAP[gap], className)}>
22
+ {children}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ type PageSectionProps = {
28
+ readonly className?: string;
29
+ readonly children?: ReactNode;
30
+ readonly testId?: string;
31
+ };
32
+
33
+ export function PageSection({ className, children, testId }: PageSectionProps): ReactNode {
34
+ return (
35
+ <div data-testid={testId} className={cn("p-6", className)}>
36
+ {children}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,39 @@
1
+ // Full-size image overlay — ModalShell without confirm/cancel actions.
2
+
3
+ import type { LightboxProps } from "@cosmicdrift/kumiko-renderer";
4
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
6
+ import type { ReactNode } from "react";
7
+ import { cn } from "../lib/cn";
8
+ import { ModalShell } from "./modal-shell";
9
+
10
+ export function DefaultLightbox({
11
+ open,
12
+ onOpenChange,
13
+ src,
14
+ alt,
15
+ testId,
16
+ }: LightboxProps): ReactNode {
17
+ const t = useTranslation();
18
+
19
+ return (
20
+ <ModalShell
21
+ open={open}
22
+ onOpenChange={onOpenChange}
23
+ testId={testId}
24
+ closeLabel={t("kumiko.dialog.close")}
25
+ noAriaDescription
26
+ contentClassName={cn(
27
+ "border-0 bg-transparent p-0 shadow-none",
28
+ "max-w-[95vw] max-h-[90vh] w-auto",
29
+ )}
30
+ >
31
+ <DialogPrimitive.Title className="sr-only">{alt}</DialogPrimitive.Title>
32
+ <img
33
+ src={src}
34
+ alt={alt}
35
+ className="block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg"
36
+ />
37
+ </ModalShell>
38
+ );
39
+ }
@@ -0,0 +1,62 @@
1
+ // Shared Radix dialog chrome for confirm Dialog + image Lightbox.
2
+ // Not exported from the package — only an internal building block.
3
+
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { X } from "lucide-react";
6
+ import type { ReactNode } from "react";
7
+ import { cn } from "../lib/cn";
8
+
9
+ export type ModalShellProps = {
10
+ readonly open: boolean;
11
+ readonly onOpenChange: (open: boolean) => void;
12
+ readonly children: ReactNode;
13
+ readonly testId?: string;
14
+ readonly contentClassName?: string;
15
+ /** Radix wants aria-describedby={undefined} when there is no Description. */
16
+ readonly noAriaDescription?: boolean;
17
+ readonly closeLabel?: string;
18
+ readonly showCloseButton?: boolean;
19
+ };
20
+
21
+ export function ModalShell({
22
+ open,
23
+ onOpenChange,
24
+ children,
25
+ testId,
26
+ contentClassName,
27
+ noAriaDescription,
28
+ closeLabel,
29
+ showCloseButton = true,
30
+ }: ModalShellProps): ReactNode {
31
+ return (
32
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
33
+ <DialogPrimitive.Portal>
34
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
35
+ <DialogPrimitive.Content
36
+ data-testid={testId}
37
+ {...(noAriaDescription && { "aria-describedby": undefined })}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
40
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
41
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
42
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
43
+ contentClassName,
44
+ )}
45
+ >
46
+ {children}
47
+ {showCloseButton && closeLabel !== undefined && (
48
+ <DialogPrimitive.Close asChild>
49
+ <button
50
+ type="button"
51
+ aria-label={closeLabel}
52
+ className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
53
+ >
54
+ <X className="size-4" />
55
+ </button>
56
+ </DialogPrimitive.Close>
57
+ )}
58
+ </DialogPrimitive.Content>
59
+ </DialogPrimitive.Portal>
60
+ </DialogPrimitive.Root>
61
+ );
62
+ }
@@ -64,16 +64,7 @@ export function MoneyInput({
64
64
  const [draft, setDraft] = useState<string>("");
65
65
 
66
66
  const major = value === "" ? null : value / factor;
67
-
68
- const formatted =
69
- major === null
70
- ? ""
71
- : new Intl.NumberFormat(resolvedLocale, {
72
- style: "currency",
73
- currency,
74
- minimumFractionDigits: decimals,
75
- maximumFractionDigits: decimals,
76
- }).format(major);
67
+ const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
77
68
 
78
69
  // Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
79
70
  // Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
@@ -170,6 +161,25 @@ function guessLocale(): string {
170
161
  return "en-US";
171
162
  }
172
163
 
164
+ // Shared with defaultCellRender (index.tsx) so both formatting paths stay
165
+ // identical instead of drifting. currency is validated against the
166
+ // 3-letter-alpha shape Intl.NumberFormat requires — a non-conforming
167
+ // value (e.g. from a JSONB custom field that never ran through
168
+ // rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
169
+ // no ErrorBoundary in this render tree that would take down the whole
170
+ // page instead of just this cell.
171
+ export function formatMoney(amountMinor: number, currency: string, locale?: string): string {
172
+ if (!/^[A-Za-z]{3}$/.test(currency)) return String(amountMinor);
173
+ const decimals = currencyDecimals(currency);
174
+ const resolvedLocale = locale ?? guessLocale();
175
+ return new Intl.NumberFormat(resolvedLocale, {
176
+ style: "currency",
177
+ currency,
178
+ minimumFractionDigits: decimals,
179
+ maximumFractionDigits: decimals,
180
+ }).format(amountMinor / 10 ** decimals);
181
+ }
182
+
173
183
  // Locale-Decimal-Parse: erkennt automatisch ob Komma oder Punkt der
174
184
  // Decimal-Separator ist. Intl.NumberFormat liefert die Trenner für
175
185
  // das Locale, daraus bauen wir den Reverse-Parser. Strict beim