@cosmicdrift/kumiko-renderer 0.377.0 → 0.378.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 (42) hide show
  1. package/dist/app/kumiko-screen.js +3 -1
  2. package/dist/components/form-derivation-patch.d.ts +8 -0
  3. package/dist/components/form-derivation-patch.js +24 -0
  4. package/dist/components/form-screen-layout.d.ts +13 -0
  5. package/dist/components/form-screen-layout.js +13 -0
  6. package/dist/components/render-edit-types.d.ts +4 -1
  7. package/dist/components/render-edit.js +14 -1
  8. package/dist/components/render-field.js +8 -1
  9. package/dist/file-ids-of.d.ts +1 -0
  10. package/dist/file-ids-of.js +7 -0
  11. package/dist/flow-diagram/icons.d.ts +3 -0
  12. package/dist/flow-diagram/icons.js +14 -0
  13. package/dist/flow-diagram/index.d.ts +2 -0
  14. package/dist/flow-diagram/index.js +1 -0
  15. package/dist/flow-diagram/render.d.ts +3 -0
  16. package/dist/flow-diagram/render.js +234 -0
  17. package/dist/flow-diagram/types.d.ts +18 -0
  18. package/dist/flow-diagram/types.js +1 -0
  19. package/dist/headless/view-model/edit.d.ts +4 -1
  20. package/dist/headless/view-model/edit.js +14 -3
  21. package/dist/headless/view-model/types.d.ts +1 -1
  22. package/dist/i18n-defaults.js +6 -0
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.js +1 -0
  25. package/dist/primitives.d.ts +10 -0
  26. package/dist/web/index.d.ts +2 -2
  27. package/dist/web/index.js +1 -1
  28. package/dist/web/primitives/file-thumbnail.d.ts +6 -0
  29. package/dist/web/primitives/file-thumbnail.js +19 -19
  30. package/dist/web/primitives/file-value-list.d.ts +3 -0
  31. package/dist/web/primitives/file-value-list.js +22 -0
  32. package/dist/web/primitives/index.js +3 -1
  33. package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
  34. package/dist/web/widgets/bounded-load-cache.js +25 -0
  35. package/dist/web/widgets/drawer.js +37 -2
  36. package/dist/web/widgets/index.d.ts +1 -0
  37. package/dist/web/widgets/index.js +1 -0
  38. package/dist/web/widgets/pdf-preview.d.ts +10 -0
  39. package/dist/web/widgets/pdf-preview.js +140 -0
  40. package/package.json +14 -5
  41. package/src/changes.json +39 -0
  42. package/src/web/styles.css +13 -9
@@ -1346,6 +1346,12 @@ export type JsonViewProps = {
1346
1346
  readonly indent?: number;
1347
1347
  readonly testId?: string;
1348
1348
  };
1349
+ /** Read-only list of stored files (FileRef ids): name, download link and, for
1350
+ * PDFs, an inline preview. Used for `renderer: { format: "file" }` on a detail field. */
1351
+ export type FileValueListProps = {
1352
+ readonly fileIds: readonly string[];
1353
+ readonly testId?: string;
1354
+ };
1349
1355
  /** Band above the tabs of a record detail: optional subtitle line plus the
1350
1356
  * metrics as a definition list. `children` are `Metric` tiles. */
1351
1357
  export type MetricBandProps = {
@@ -1528,6 +1534,10 @@ export type CorePrimitives = {
1528
1534
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1529
1535
  * a new primitive shouldn't force every test double to grow a stub. */
1530
1536
  readonly JsonView?: ComponentType<JsonViewProps>;
1537
+ /** Optional (unlike the other Core-Primitives) so existing partial
1538
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
1539
+ * a new primitive shouldn't force every test double to grow a stub. */
1540
+ readonly FileValueList?: ComponentType<FileValueListProps>;
1531
1541
  /** Optional (unlike the other Core-Primitives) so existing partial
1532
1542
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1533
1543
  * a new primitive shouldn't force every test double to grow a stub. */
@@ -74,5 +74,5 @@ export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resiz
74
74
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.tsx";
75
75
  export type { KumikoBuild } from "./version/update-checker.tsx";
76
76
  export { readLoadedBuild } from "./version/update-checker.tsx";
77
- export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, ModeSwitchVariant, NumberFieldProps, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardLinkSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesHeight, TimeseriesPoint, TimeseriesReferenceLine, TimeseriesXAxis, TimeseriesYAxis, UploadZoneProps, } from "./widgets/index.ts";
78
- export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
77
+ export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, ModeSwitchVariant, NumberFieldProps, PdfPreviewSource, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardLinkSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesHeight, TimeseriesPoint, TimeseriesReferenceLine, TimeseriesXAxis, TimeseriesYAxis, UploadZoneProps, } from "./widgets/index.ts";
78
+ export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
package/dist/web/index.js CHANGED
@@ -50,4 +50,4 @@ export { applyTokensToCssVars, defaultTokens, lightTokens, useBrowserTokensApi,
50
50
  export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable.js";
51
51
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.js";
52
52
  export { readLoadedBuild } from "./version/update-checker.js";
53
- export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
53
+ export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
@@ -8,6 +8,11 @@ export declare function FileThumbnail({ fileId, variant, className, testId, }: {
8
8
  readonly className?: string;
9
9
  readonly testId?: string;
10
10
  }): ReactNode;
11
+ type FileMeta = {
12
+ readonly fileName: string;
13
+ readonly mimeType: string | undefined;
14
+ };
15
+ export declare function useFileMeta(fileId: string): FileMeta | null;
11
16
  export declare function FileNameLink({ fileId, fallbackLabel, className, }: {
12
17
  readonly fileId: string;
13
18
  readonly fallbackLabel: string;
@@ -23,3 +28,4 @@ export declare function FileDisplayCell({ value, display, fallbackLabel, compact
23
28
  readonly fallbackLabel: string;
24
29
  readonly compact?: boolean;
25
30
  }): ReactNode;
31
+ export {};
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
3
3
  import { ImageIcon } from "lucide-react";
4
4
  import { useEffect, useState } from "react";
5
+ import { fileIdsOf } from "../../file-ids-of.js";
5
6
  import { cn } from "../lib/cn.js";
6
7
  // Stored values are untrusted: only UUIDs reach a request, and path segments are
7
8
  // encoded so an id like "../x" or "a?b" cannot redirect the request.
@@ -33,42 +34,41 @@ function ThumbnailChain({ fileId, variant, className, testId, }) {
33
34
  export function FileThumbnail({ fileId, variant, className = "h-24 w-32", testId, }) {
34
35
  return (_jsx(ThumbnailChain, { fileId: fileId, variant: variant, className: className, testId: testId }, `${fileId}:${variant ?? ""}`));
35
36
  }
36
- function isFileMeta(value) {
37
- return (typeof value === "object" &&
38
- value !== null &&
39
- "fileName" in value &&
40
- typeof value.fileName === "string");
37
+ function parseFileMeta(value) {
38
+ if (typeof value !== "object" || value === null || !("fileName" in value))
39
+ return null;
40
+ if (typeof value.fileName !== "string")
41
+ return null;
42
+ const mimeType = "mimeType" in value && typeof value.mimeType === "string" ? value.mimeType : undefined;
43
+ return { fileName: value.fileName, mimeType };
41
44
  }
42
- export function FileNameLink({ fileId, fallbackLabel, className, }) {
43
- const [fileName, setFileName] = useState(null);
45
+ export function useFileMeta(fileId) {
46
+ const [meta, setMeta] = useState(null);
44
47
  const validId = isUuid(fileId);
45
48
  useEffect(() => {
46
49
  let cancelled = false;
47
- setFileName(null);
50
+ setMeta(null);
48
51
  if (!validId)
49
52
  return;
50
53
  fetch(`${fileOriginalUrl(fileId)}/meta`)
51
54
  .then((res) => (res.ok ? res.json() : null))
52
- .then((meta) => {
53
- if (!cancelled && isFileMeta(meta))
54
- setFileName(meta.fileName);
55
+ .then((json) => {
56
+ if (!cancelled)
57
+ setMeta(parseFileMeta(json));
55
58
  })
56
59
  .catch(() => { });
57
60
  return () => {
58
61
  cancelled = true;
59
62
  };
60
63
  }, [fileId, validId]);
61
- if (!validId)
64
+ return meta;
65
+ }
66
+ export function FileNameLink({ fileId, fallbackLabel, className, }) {
67
+ const fileName = useFileMeta(fileId)?.fileName ?? null;
68
+ if (!isUuid(fileId))
62
69
  return _jsx("span", { className: cn("text-sm", className), children: fallbackLabel });
63
70
  return (_jsx("a", { href: fileOriginalUrl(fileId), target: "_blank", rel: "noreferrer", className: cn("text-sm underline underline-offset-2", className), children: fileName ?? fallbackLabel }));
64
71
  }
65
- function fileIdsOf(value) {
66
- if (typeof value === "string")
67
- return value === "" ? [] : [value];
68
- if (!Array.isArray(value))
69
- return [];
70
- return value.filter((entry) => typeof entry === "string" && entry !== "");
71
- }
72
72
  // Height-bound, width follows the aspect ratio within a clamp, so a portrait and
73
73
  // a landscape photo both read as a preview without stretching the column.
74
74
  const TABLE_THUMBNAIL_CLASS = "h-16 min-w-16 max-w-24 object-cover";
@@ -0,0 +1,3 @@
1
+ import { type ReactNode } from "react";
2
+ import { type FileValueListProps } from "../../index.ts";
3
+ export declare function FileValueList({ fileIds, testId }: FileValueListProps): ReactNode;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
3
+ import { useState } from "react";
4
+ import { useTranslation } from "../../index.js";
5
+ import { Button } from "../ui/button.js";
6
+ import { PdfPreview } from "../widgets/pdf-preview.js";
7
+ import { fileOriginalUrl, useFileMeta } from "./file-thumbnail.js";
8
+ const PDF_MIME_TYPE = "application/pdf";
9
+ function FileValueRow({ fileId, testId, }) {
10
+ const t = useTranslation();
11
+ const meta = useFileMeta(fileId);
12
+ const [previewOpen, setPreviewOpen] = useState(false);
13
+ if (!isUuid(fileId))
14
+ return _jsx("span", { className: "text-sm", children: fileId });
15
+ const downloadUrl = fileOriginalUrl(fileId);
16
+ return (_jsxs("li", { className: "flex flex-col gap-2", "data-testid": testId, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("span", { className: "text-sm", children: meta?.fileName ?? fileId.slice(0, 8) }), _jsx("a", { href: downloadUrl, download: meta?.fileName ?? "", className: "text-primary text-sm underline underline-offset-2", ...(testId !== undefined && { "data-testid": `${testId}-download` }), children: t("kumiko.field.file-list.download") }), meta?.mimeType === PDF_MIME_TYPE && (_jsx(Button, { type: "button", variant: "link", size: "sm", "aria-expanded": previewOpen, onClick: () => setPreviewOpen((open) => !open), ...(testId !== undefined && { "data-testid": `${testId}-preview-toggle` }), children: t(previewOpen ? "kumiko.field.file-list.hidePreview" : "kumiko.field.file-list.preview") }))] }), previewOpen && (_jsx(PdfPreview, { source: { url: downloadUrl }, testId: `file-preview-${fileId}` }))] }));
17
+ }
18
+ export function FileValueList({ fileIds, testId }) {
19
+ if (fileIds.length === 0)
20
+ return null;
21
+ return (_jsx("ul", { className: "m-0 flex list-none flex-col gap-3 p-0", "data-testid": testId, children: fileIds.map((fileId) => (_jsx(FileValueRow, { fileId: fileId, ...(testId !== undefined && { testId: `${testId}-${fileId}` }) }, fileId))) }));
22
+ }
@@ -32,6 +32,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
32
32
  import { EmbeddedListInput } from "./embedded-list-input.js";
33
33
  import { CardLeadingImage, FileDisplayCell } from "./file-thumbnail.js";
34
34
  import { FileUploadInput } from "./file-upload.js";
35
+ import { FileValueList } from "./file-value-list.js";
35
36
  import { DefaultJsonView } from "./json-view.js";
36
37
  import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
37
38
  import { DefaultLightbox } from "./lightbox.js";
@@ -1920,7 +1921,7 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
1920
1921
  // The one region allowed to shrink and scroll internally — mirrors
1921
1922
  // FormSections' fillHeight === true && "min-h-0" treatment of the
1922
1923
  // section it wraps.
1923
- fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
1924
+ fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && s.footer !== null && s.footer !== false && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
1924
1925
  return (_jsx(InsideScreenFormColumnContext.Provider, { value: false, children: screenBody ? _jsx(FormScreenShell, { children: card }) : card }));
1925
1926
  }
1926
1927
  // otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
@@ -2011,6 +2012,7 @@ export const defaultPrimitives = {
2011
2012
  MetricBand: DefaultMetricBand,
2012
2013
  PageHeader: DefaultPageHeader,
2013
2014
  JsonView: DefaultJsonView,
2015
+ FileValueList,
2014
2016
  FillContainer: DefaultFillContainer,
2015
2017
  ActionOverflowMenu,
2016
2018
  SecretReveal: DefaultSecretReveal,
@@ -0,0 +1 @@
1
+ export declare function createBoundedLoadCache<T>(max: number, dispose: (value: T) => void): (key: string, load: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,25 @@
1
+ // Evicts the oldest entry once `max` is exceeded and disposes only that one,
2
+ // so several documents on one page keep their own loading tasks alive.
3
+ export function createBoundedLoadCache(max, dispose) {
4
+ const entries = new Map();
5
+ return (key, load) => {
6
+ const cached = entries.get(key);
7
+ if (cached !== undefined)
8
+ return cached;
9
+ const loading = load();
10
+ entries.set(key, loading);
11
+ // A failed load must not stick: the next mount retries instead of replaying the rejection.
12
+ loading.catch(() => {
13
+ if (entries.get(key) === loading)
14
+ entries.delete(key);
15
+ });
16
+ if (entries.size > max) {
17
+ const [oldestKey, oldest] = entries.entries().next().value ?? [];
18
+ if (oldestKey !== undefined && oldest !== undefined) {
19
+ entries.delete(oldestKey);
20
+ void oldest.then(dispose);
21
+ }
22
+ }
23
+ return loading;
24
+ };
25
+ }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Maximize2Icon, Minimize2Icon } from "lucide-react";
3
- import { useState } from "react";
3
+ import { useEffect, useState } from "react";
4
4
  import { useTranslation } from "../../index.js";
5
5
  import { clamp } from "../lib/clamp.js";
6
6
  import { cn } from "../lib/cn.js";
@@ -78,6 +78,39 @@ function sidePanelClass(side, narrow, variant, belowHeader) {
78
78
  return `inset-y-8 right-8 h-auto ${WIDTH_CLASS} rounded-[2rem] border shadow-2xl overflow-hidden`;
79
79
  }
80
80
  }
81
+ const DRAWER_INSET_PROPERTY = {
82
+ left: "--kumiko-drawer-inset-left",
83
+ right: "--kumiko-drawer-inset-right",
84
+ };
85
+ // Publishes the width a non-modal side drawer occupies so the shell content (styles.css) can
86
+ // narrow instead of sitting underneath it. Read from the layout box (offsetLeft/offsetWidth), not
87
+ // getBoundingClientRect: the slide-in animation moves the painted rect, and the observer would
88
+ // not fire again once it settles.
89
+ // ponytail: one owner per side, so two non-modal drawers open on the same side overwrite each
90
+ // other's value; a registry that takes the max is needed once a screen stacks them.
91
+ function usePublishDrawerInset(sheet, side, active) {
92
+ useEffect(() => {
93
+ if (!active || sheet === null || (side !== "left" && side !== "right"))
94
+ return;
95
+ const property = DRAWER_INSET_PROPERTY[side];
96
+ const root = document.documentElement;
97
+ const publish = () => {
98
+ const inset = side === "right"
99
+ ? window.innerWidth - sheet.offsetLeft
100
+ : sheet.offsetLeft + sheet.offsetWidth;
101
+ root.style.setProperty(property, `${Math.max(0, Math.round(inset))}px`);
102
+ };
103
+ publish();
104
+ const observer = new ResizeObserver(publish);
105
+ observer.observe(sheet);
106
+ window.addEventListener("resize", publish);
107
+ return () => {
108
+ observer.disconnect();
109
+ window.removeEventListener("resize", publish);
110
+ root.style.removeProperty(property);
111
+ };
112
+ }, [sheet, side, active]);
113
+ }
81
114
  /** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
82
115
  * thin wrapper over the Sheet primitive with header/body/footer slots
83
116
  * so screens skip per-route Radix boilerplate. */
@@ -95,6 +128,8 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
95
128
  : Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
96
129
  const [resizedWidthPx, setResizedWidthPx] = useState(() => clamp(resize?.defaultWidthPx ?? defaultWidthFromViewport(), minWidthPx, effectiveMaxWidthPx()));
97
130
  const [maximized, setMaximized] = useState(false);
131
+ const [sheet, setSheet] = useState(null);
132
+ usePublishDrawerInset(sheet, side, open && !modal && !narrow);
98
133
  const blurPx = backdrop?.blurPx ?? DEFAULT_BLUR_PX;
99
134
  const dimPercent = backdrop?.dimPercent ?? DEFAULT_DIM_PERCENT;
100
135
  const overlayStyle = {
@@ -134,7 +169,7 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
134
169
  // instead of resizing relative to what's on screen.
135
170
  setResizedWidthPx((current) => clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()));
136
171
  };
137
- return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsxs(DrawerSheetContent, { side: side, "data-testid": testId, overlayStyle: overlayStyle,
172
+ return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsxs(DrawerSheetContent, { ref: setSheet, side: side, "data-testid": testId, overlayStyle: overlayStyle,
138
173
  // Radix already drops the overlay and the focus trap for a
139
174
  // non-modal Root; only the dismiss-on-outside-interaction is left
140
175
  // to suppress, so a click into the page behind keeps the panel open.
@@ -10,6 +10,7 @@ export { FloatingPanel, type FloatingPanelGeometry, type FloatingPanelProps, } f
10
10
  export { BooleanField, type BooleanFieldProps, DateField, type DateFieldProps, FileField, type FileFieldProps, MoneyField, NumberField, type NumberFieldProps, PercentField, RangeField, type RangeFieldProps, SelectField, type SelectFieldProps, TextareaField, type TextareaFieldProps, TextField, type TextFieldProps, } from "./form-fields.tsx";
11
11
  export { InfinityList, type InfinityListProps, type InfinityListSelection, } from "./infinity-list.tsx";
12
12
  export { ModeSwitch, type ModeSwitchVariant } from "./mode-switch.tsx";
13
+ export { PdfPreview, type PdfPreviewSource } from "./pdf-preview.tsx";
13
14
  export { type PhotoSlotSpec, PhotoSlots, type PhotoSlotsProps } from "./photo-slots.tsx";
14
15
  export { PlanCard, type PlanCardActionSlot, type PlanCardLinkSlot, type PlanCardPrice, type PlanCardProps, PlanGrid, type PlanGridProps, } from "./plan-card.tsx";
15
16
  export { ProgressBar } from "./progress-bar.tsx";
@@ -13,6 +13,7 @@ export { FloatingPanel, } from "./floating-panel.js";
13
13
  export { BooleanField, DateField, FileField, MoneyField, NumberField, PercentField, RangeField, SelectField, TextareaField, TextField, } from "./form-fields.js";
14
14
  export { InfinityList, } from "./infinity-list.js";
15
15
  export { ModeSwitch } from "./mode-switch.js";
16
+ export { PdfPreview } from "./pdf-preview.js";
16
17
  export { PhotoSlots } from "./photo-slots.js";
17
18
  export { PlanCard, PlanGrid, } from "./plan-card.js";
18
19
  export { ProgressBar } from "./progress-bar.js";
@@ -0,0 +1,10 @@
1
+ import { type ReactNode } from "react";
2
+ export type PdfPreviewSource = {
3
+ readonly base64: string;
4
+ } | {
5
+ readonly url: string;
6
+ };
7
+ export declare function PdfPreview({ source, testId, }: {
8
+ readonly source: PdfPreviewSource;
9
+ readonly testId?: string;
10
+ }): ReactNode;
@@ -0,0 +1,140 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Suspense, use, useCallback, useMemo, useState } from "react";
3
+ import { useTranslation } from "../../i18n.js";
4
+ import { createBoundedLoadCache } from "./bounded-load-cache.js";
5
+ // pdf.js runs in the main thread ("fake worker"): the worker module is bundled
6
+ // with the app and handed over through globalThis.pdfjsWorker, so no worker or
7
+ // CDN URL is ever fetched (self-hosting and CSP stay intact).
8
+ const RENDER_SCALE = 96 / 72;
9
+ const MAX_CACHED_DOCUMENTS = 4;
10
+ const cachedDocument = createBoundedLoadCache(MAX_CACHED_DOCUMENTS, (loaded) => {
11
+ if (loaded.ok)
12
+ void loaded.loadingTask.destroy();
13
+ });
14
+ function decodeBase64(base64) {
15
+ const binary = atob(base64);
16
+ const bytes = new Uint8Array(binary.length);
17
+ for (let index = 0; index < binary.length; index++)
18
+ bytes[index] = binary.charCodeAt(index);
19
+ return bytes;
20
+ }
21
+ async function loadBytes(source) {
22
+ if ("base64" in source)
23
+ return decodeBase64(source.base64);
24
+ const res = await fetch(source.url, { credentials: "same-origin" });
25
+ if (!res.ok)
26
+ throw new Error(`pdf fetch failed (${res.status})`);
27
+ return new Uint8Array(await res.arrayBuffer());
28
+ }
29
+ async function loadDocument(source) {
30
+ try {
31
+ const [pdfjs, worker, data] = await Promise.all([
32
+ import("pdfjs-dist"),
33
+ import("pdfjs-dist/build/pdf.worker.min.mjs"),
34
+ loadBytes(source),
35
+ ]);
36
+ Object.assign(globalThis, { pdfjsWorker: worker });
37
+ const loadingTask = pdfjs.getDocument({ data });
38
+ return { ok: true, document: await loadingTask.promise, loadingTask };
39
+ }
40
+ catch {
41
+ return { ok: false };
42
+ }
43
+ }
44
+ // The promise must be stable across renders for use().
45
+ function documentFor(source) {
46
+ const key = "base64" in source ? `base64:${source.base64}` : `url:${source.url}`;
47
+ return cachedDocument(key, () => loadDocument(source));
48
+ }
49
+ function PageView({ document, pageNumber, }) {
50
+ const t = useTranslation();
51
+ const page = use(pageFor(document, pageNumber));
52
+ // Stable identity: the mount callbacks below depend on it, and a new viewport per render re-mounts canvas and text layer.
53
+ const viewport = useMemo(() => page.getViewport({ scale: RENDER_SCALE }), [page]);
54
+ const [availableWidth, setAvailableWidth] = useState(null);
55
+ const measureFrame = useCallback((frame) => {
56
+ if (frame === null)
57
+ return;
58
+ const observer = new ResizeObserver(() => setAvailableWidth(frame.clientWidth));
59
+ observer.observe(frame);
60
+ setAvailableWidth(frame.clientWidth);
61
+ return () => observer.disconnect();
62
+ }, []);
63
+ // The sheet keeps its natural pixel size and is scaled as a whole, so canvas and text layer stay aligned.
64
+ const fitScale = availableWidth === null || availableWidth <= 0
65
+ ? 1
66
+ : Math.min(1, availableWidth / viewport.width);
67
+ const mountCanvas = useCallback((canvas) => {
68
+ if (canvas === null)
69
+ return;
70
+ const pixelRatio = globalThis.devicePixelRatio || 1;
71
+ canvas.width = Math.floor(viewport.width * pixelRatio);
72
+ canvas.height = Math.floor(viewport.height * pixelRatio);
73
+ const task = page.render({
74
+ canvas,
75
+ viewport,
76
+ transform: pixelRatio === 1 ? undefined : [pixelRatio, 0, 0, pixelRatio, 0, 0],
77
+ });
78
+ task.promise.catch(() => undefined);
79
+ return () => task.cancel();
80
+ }, [page, viewport]);
81
+ const mountTextLayer = useCallback((container) => {
82
+ if (container === null)
83
+ return;
84
+ container.style.setProperty("--total-scale-factor", String(RENDER_SCALE));
85
+ container.style.setProperty("--scale-factor", String(RENDER_SCALE));
86
+ let layer = null;
87
+ let cancelled = false;
88
+ void import("pdfjs-dist").then(({ TextLayer }) => {
89
+ // skip: cleanup ran before pdfjs finished loading
90
+ if (cancelled)
91
+ return undefined;
92
+ // TextLayer.cancel() keeps rendered spans; a re-mount must not append a second layer.
93
+ container.replaceChildren();
94
+ const textLayer = new TextLayer({
95
+ textContentSource: page.streamTextContent(),
96
+ container,
97
+ viewport,
98
+ });
99
+ layer = textLayer;
100
+ return textLayer.render().catch(() => undefined);
101
+ });
102
+ return () => {
103
+ cancelled = true;
104
+ layer?.cancel();
105
+ };
106
+ }, [page, viewport]);
107
+ return (_jsxs("figure", { className: "m-0 flex w-full flex-col items-center gap-2 rounded-lg bg-muted p-4", "data-testid": `pdf-page-${pageNumber}`, children: [_jsx("div", { ref: measureFrame, className: "w-full", children: _jsx("div", { className: "mx-auto", style: { width: viewport.width * fitScale, height: viewport.height * fitScale }, children: _jsxs("div", { className: "relative border border-border bg-white shadow-md", style: {
108
+ width: viewport.width,
109
+ height: viewport.height,
110
+ transform: `scale(${fitScale})`,
111
+ transformOrigin: "top left",
112
+ }, children: [_jsx("canvas", { ref: mountCanvas, style: { width: viewport.width, height: viewport.height }, className: "block" }), _jsx("div", { ref: mountTextLayer, className: "absolute inset-0 overflow-hidden leading-none [&_span]:absolute [&_span]:origin-top-left [&_span]:cursor-text [&_span]:whitespace-pre [&_span]:text-transparent [&_span::selection]:bg-primary/30" })] }) }) }), _jsx("figcaption", { className: "text-xs text-muted-foreground", children: t("kumiko.widget.pdf-preview.pageOf", { page: pageNumber, pages: document.numPages }) })] }));
113
+ }
114
+ const pageCache = new WeakMap();
115
+ function pageFor(document, pageNumber) {
116
+ let pages = pageCache.get(document);
117
+ if (pages === undefined) {
118
+ pages = new Map();
119
+ pageCache.set(document, pages);
120
+ }
121
+ let page = pages.get(pageNumber);
122
+ if (page === undefined) {
123
+ page = document.getPage(pageNumber);
124
+ pages.set(pageNumber, page);
125
+ }
126
+ return page;
127
+ }
128
+ function Pages({ source }) {
129
+ const t = useTranslation();
130
+ const loaded = use(documentFor(source));
131
+ if (!loaded.ok) {
132
+ return _jsx("p", { className: "text-sm text-status-bad", children: t("kumiko.widget.pdf-preview.renderFailed") });
133
+ }
134
+ const pageNumbers = Array.from({ length: loaded.document.numPages }, (_, index) => index + 1);
135
+ return (_jsx(_Fragment, { children: pageNumbers.map((pageNumber) => (_jsx(Suspense, { fallback: null, children: _jsx(PageView, { document: loaded.document, pageNumber: pageNumber }) }, pageNumber))) }));
136
+ }
137
+ export function PdfPreview({ source, testId = "pdf-preview", }) {
138
+ const t = useTranslation();
139
+ return (_jsx("div", { className: "flex flex-col items-stretch gap-4 overflow-auto", "data-testid": testId, children: _jsx(Suspense, { fallback: _jsx("p", { className: "text-sm text-muted-foreground", children: t("kumiko.widget.pdf-preview.loading") }), children: _jsx(Pages, { source: source }) }) }));
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.377.0",
3
+ "version": "0.378.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/headless/index.d.ts",
18
18
  "default": "./dist/headless/index.js"
19
19
  },
20
+ "./flow-diagram": {
21
+ "types": "./dist/flow-diagram/index.d.ts",
22
+ "default": "./dist/flow-diagram/index.js"
23
+ },
20
24
  "./headless/apex": {
21
25
  "types": "./dist/headless/apex/index.d.ts",
22
26
  "default": "./dist/headless/apex/index.js"
@@ -44,8 +48,8 @@
44
48
  }
45
49
  },
46
50
  "dependencies": {
47
- "@cosmicdrift/kumiko-framework": "0.377.0",
48
- "@cosmicdrift/kumiko-types": "0.377.0",
51
+ "@cosmicdrift/kumiko-framework": "0.378.0",
52
+ "@cosmicdrift/kumiko-types": "0.378.0",
49
53
  "@radix-ui/react-dialog": "^1.1.15",
50
54
  "@radix-ui/react-dropdown-menu": "^2.1.16",
51
55
  "@radix-ui/react-label": "^2.1.8",
@@ -64,6 +68,7 @@
64
68
  "clsx": "^2.1.1",
65
69
  "cmdk": "^1.1.1",
66
70
  "lucide-react": "^1.14.0",
71
+ "pdfjs-dist": "6.3.289",
67
72
  "qrcode": "^1.5.4",
68
73
  "radix-ui": "^1.4.3",
69
74
  "react": "^19.2.6",
@@ -87,8 +92,8 @@
87
92
  }
88
93
  },
89
94
  "devDependencies": {
90
- "@cosmicdrift/kumiko-locale-de": "0.377.0",
91
- "@cosmicdrift/kumiko-testing": "0.377.0",
95
+ "@cosmicdrift/kumiko-locale-de": "0.378.0",
96
+ "@cosmicdrift/kumiko-testing": "0.378.0",
92
97
  "@tailwindcss/cli": "^4.3.0",
93
98
  "@testing-library/react": "^16.3.2",
94
99
  "@testing-library/user-event": "^14.6.1",
@@ -118,6 +123,10 @@
118
123
  "types": "./dist/headless/index.d.ts",
119
124
  "default": "./dist/headless/index.js"
120
125
  },
126
+ "./flow-diagram": {
127
+ "types": "./dist/flow-diagram/index.d.ts",
128
+ "default": "./dist/flow-diagram/index.js"
129
+ },
121
130
  "./headless/apex": {
122
131
  "types": "./dist/headless/apex/index.d.ts",
123
132
  "default": "./dist/headless/apex/index.js"
package/src/changes.json CHANGED
@@ -1,4 +1,43 @@
1
1
  [
2
+ {
3
+ "version": "0.378.0",
4
+ "type": "fix",
5
+ "title": "Non-modal drawers narrow the content instead of covering it",
6
+ "detail": "A Drawer with modal={false} on a wide viewport now publishes the width it occupies as --kumiko-drawer-inset-right or --kumiko-drawer-inset-left on the document root, and the shell content area (main next to the sidebar) takes it as inline padding, so error banners, right columns and the pinned form footer end beside the drawer. Modal drawers and narrow viewports keep overlaying. Two non-modal drawers open on the same side overwrite each other's value."
7
+ },
8
+ {
9
+ "version": "0.378.0",
10
+ "type": "improvement",
11
+ "title": "Files fields show download and PDF preview",
12
+ "detail": "A read-only detail field with renderer { format: \"file\" } (projectionDetail, readOnly edit field) lists each file with its name and a download link; PDFs get an inline preview toggle. New optional primitive FileValueList, and PdfPreview (moved here from the document designer) with a per-source bounded document cache so several PDFs can share a page. i18n keys kumiko.field.file-list.download/preview/hidePreview and kumiko.widget.pdf-preview.* ship in de, es, fr and it.",
13
+ "migration": "PdfPreview is now exported from @cosmicdrift/kumiko-renderer/web (props: source {base64}|{url}, testId)."
14
+ },
15
+ {
16
+ "version": "0.378.0",
17
+ "type": "improvement",
18
+ "title": "FormScreenLayout gives custom screens the standard form screen layout",
19
+ "detail": "New universal component FormScreenLayout (children, description, actions, secondaryActions, onSubmit, width, testId) renders the same left-aligned form column, section stack and pinned footer as an entityEdit screen. Fill it with Section primitives (title, icon, subtitle); description becomes the form subtitle.",
20
+ "migration": "Custom form screens can replace FormScreenShell + Card with FormScreenLayout + Section."
21
+ },
22
+ {
23
+ "version": "0.378.0",
24
+ "type": "fix",
25
+ "title": "Card renders no footer bar for an empty footer slot",
26
+ "detail": "slots.footer set to false or null (for example `hasDone && <Button/>`) left an empty grey footer bar. The bar now renders only when the footer slot has content."
27
+ },
28
+ {
29
+ "version": "0.378.0",
30
+ "type": "improvement",
31
+ "title": "flow-diagram subpath",
32
+ "detail": "\"@cosmicdrift/kumiko-renderer/flow-diagram\" exports renderFlow(spec) and the FlowSpec, FlowNode, FlowEdge, FlowIcon types, a pure string function that renders an animated SVG flow diagram."
33
+ },
34
+ {
35
+ "version": "0.378.0",
36
+ "type": "breaking",
37
+ "title": "Renderer stylesheet no longer scans the document designer",
38
+ "detail": "The renderer stylesheet stopped scanning the document designer sources by relative path, which broke in isolated installs.",
39
+ "migration": "Apps that embed the document designer add `@import \"@cosmicdrift/kumiko-document-designer/web/styles.css\";` after the renderer stylesheet import in their styles.css."
40
+ },
2
41
  {
3
42
  "version": "0.377.0",
4
43
  "type": "improvement",
@@ -34,12 +34,13 @@
34
34
  imported through. Here that is `<package>/src/web` in both layouts, so:
35
35
  - `../` is the renderer's own src (shared screens and components) and
36
36
  `../../dist` its compiled twin;
37
- - the sibling packages (bundled-features, document-designer, neither
38
- ships its own compiled CSS) need two variants: `../../../X` in the
37
+ - the sibling package bundled-features (no compiled CSS of its own) needs two variants: `../../../X` in the
39
38
  workspace (unscoped dir name) and `../../../kumiko-X` in a standalone
40
39
  consumer (scoped registry name under node_modules/@cosmicdrift/).
41
40
  A path that doesn't exist in a given layout is simply not matched, no
42
- error. samples is monorepo-only (in-repo sample build), a no-op elsewhere.
41
+ error. The document designer is not scanned from here: it ships its own
42
+ `@cosmicdrift/kumiko-document-designer/web/styles.css`, which an app imports
43
+ after this file. samples is monorepo-only (in-repo sample build), a no-op elsewhere.
43
44
 
44
45
  The published packages ship compiled dist (class names survive as string
45
46
  literals in the .js), so every src line has a dist twin; src stays for
@@ -51,10 +52,6 @@
51
52
  @source "../../../bundled-features/dist/**/*.js";
52
53
  @source "../../../kumiko-bundled-features/src/**/*.{ts,tsx}";
53
54
  @source "../../../kumiko-bundled-features/dist/**/*.js";
54
- @source "../../../document-designer/src/**/*.{ts,tsx}";
55
- @source "../../../document-designer/dist/**/*.js";
56
- @source "../../../kumiko-document-designer/src/**/*.{ts,tsx}";
57
- @source "../../../kumiko-document-designer/dist/**/*.js";
58
55
  @source "../../../../samples/**/src/**/*.{ts,tsx}";
59
56
  /* In the monorepo `tsc --build` emits composite output (incl. compiled tests
60
57
  and stale files of deleted components) into dist; scanning it would make the
@@ -62,8 +59,6 @@
62
59
  @source not "../../dist/**/__tests__/**";
63
60
  @source not "../../../bundled-features/dist/**/__tests__/**";
64
61
  @source not "../../../kumiko-bundled-features/dist/**/__tests__/**";
65
- @source not "../../../document-designer/dist/**/__tests__/**";
66
- @source not "../../../kumiko-document-designer/dist/**/__tests__/**";
67
62
 
68
63
  /* Dark-Mode via class-selector (nicht media). Erlaubt JS-Toggle
69
64
  und überschreibt System-Preference wenn die App will. */
@@ -301,6 +296,15 @@
301
296
  --shell-header-height: 3.5rem;
302
297
  }
303
298
 
299
+ /* A non-modal side Drawer publishes the width it occupies (widgets/drawer.tsx); the content area
300
+ narrows by that much instead of sitting underneath. Only <main>: the drawer starts below the
301
+ header, and the pinned form footer lives inside <main>. No transition: the sheet slides by
302
+ transform, its width never animates. */
303
+ [data-slot="sidebar-inset"] > main {
304
+ padding-inline-end: var(--kumiko-drawer-inset-right, 0px);
305
+ padding-inline-start: var(--kumiko-drawer-inset-left, 0px);
306
+ }
307
+
304
308
  /* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
305
309
  hat. Dark ist der Default (siehe @theme oben); toggled der User
306
310
  auf light, greifen diese Werte. */