@cosmicdrift/kumiko-renderer 0.393.0 → 0.395.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 (77) hide show
  1. package/dist/app/content-preview.js +1 -9
  2. package/dist/app/entity-list-screen.js +4 -2
  3. package/dist/app/kumiko-screen.js +4 -36
  4. package/dist/app/projection-list-body.js +1 -1
  5. package/dist/app/qn.d.ts +1 -6
  6. package/dist/app/qn.js +1 -12
  7. package/dist/app/screen-visibility-gate.d.ts +19 -0
  8. package/dist/app/screen-visibility-gate.js +60 -0
  9. package/dist/app/secrets-edit-body.js +5 -4
  10. package/dist/app/use-embedded-screen.d.ts +2 -0
  11. package/dist/app/use-embedded-screen.js +1 -1
  12. package/dist/app/use-gated-toolbar-actions.d.ts +7 -0
  13. package/dist/app/use-gated-toolbar-actions.js +51 -0
  14. package/dist/app/use-projection-list-actions.d.ts +1 -0
  15. package/dist/app/use-projection-list-actions.js +11 -2
  16. package/dist/app/visible-when.d.ts +5 -1
  17. package/dist/app/visible-when.js +11 -0
  18. package/dist/components/form-screen-layout.d.ts +2 -1
  19. package/dist/components/form-screen-layout.js +2 -2
  20. package/dist/components/render-edit-banners.js +1 -1
  21. package/dist/components/render-edit-layout-flags.d.ts +2 -1
  22. package/dist/components/render-edit-layout-flags.js +3 -2
  23. package/dist/components/render-edit-logic.d.ts +2 -1
  24. package/dist/components/render-edit-logic.js +14 -0
  25. package/dist/data-table-view-types.d.ts +4 -2
  26. package/dist/hooks/list-column-layout.d.ts +3 -1
  27. package/dist/hooks/list-column-layout.js +20 -7
  28. package/dist/hooks/use-list-view-state.d.ts +1 -1
  29. package/dist/hooks/use-list-view-state.js +3 -2
  30. package/dist/i18n-defaults.js +2 -0
  31. package/dist/web/app/client-plugin.d.ts +1 -1
  32. package/dist/web/app/dashboard-body.js +1 -1
  33. package/dist/web/app/dashboard-screen-tiles.d.ts +2 -1
  34. package/dist/web/app/dashboard-screen-tiles.js +9 -7
  35. package/dist/web/app/dashboard-shared.d.ts +2 -0
  36. package/dist/web/app/use-dashboard-screen-state.js +6 -1
  37. package/dist/web/index.d.ts +1 -1
  38. package/dist/web/index.js +1 -1
  39. package/dist/web/layout/shell-breadcrumb.d.ts +4 -1
  40. package/dist/web/layout/shell-breadcrumb.js +18 -3
  41. package/dist/web/layout/shell-header.js +4 -2
  42. package/dist/web/lib/upload-file-ref.d.ts +4 -1
  43. package/dist/web/lib/upload-file-ref.js +15 -3
  44. package/dist/web/primitives/action-menu.js +2 -2
  45. package/dist/web/primitives/calendar-popover.js +1 -1
  46. package/dist/web/primitives/combobox.js +1 -1
  47. package/dist/web/primitives/data-table/table-data-cell.js +27 -1
  48. package/dist/web/primitives/data-table/table-header-row.js +1 -1
  49. package/dist/web/primitives/data-table/table-view.js +2 -2
  50. package/dist/web/primitives/data-table/use-column-sizing.d.ts +4 -2
  51. package/dist/web/primitives/data-table/use-column-sizing.js +34 -11
  52. package/dist/web/primitives/data-table/view-menu.js +1 -1
  53. package/dist/web/primitives/data-table-cell.js +1 -1
  54. package/dist/web/primitives/dialog.js +1 -1
  55. package/dist/web/primitives/dropdown-menu.d.ts +1 -1
  56. package/dist/web/primitives/dropdown-menu.js +2 -2
  57. package/dist/web/primitives/file-upload.js +5 -2
  58. package/dist/web/primitives/files-dropzone.js +8 -2
  59. package/dist/web/primitives/lightbox.js +1 -1
  60. package/dist/web/primitives/modal-shell.js +1 -1
  61. package/dist/web/primitives/modal.js +1 -1
  62. package/dist/web/primitives/narrow-pinned-footer.js +1 -1
  63. package/dist/web/primitives/toast.js +2 -2
  64. package/dist/web/sse/live-events.js +0 -7
  65. package/dist/web/widgets/floating-panel-geometry.d.ts +32 -0
  66. package/dist/web/widgets/floating-panel-geometry.js +71 -0
  67. package/dist/web/widgets/floating-panel.d.ts +2 -6
  68. package/dist/web/widgets/floating-panel.js +11 -55
  69. package/dist/web/widgets/infinity-list.js +27 -14
  70. package/dist/web/widgets/plan-card.d.ts +2 -0
  71. package/dist/web/widgets/plan-card.js +5 -1
  72. package/package.json +6 -19
  73. package/src/changes.json +54 -0
  74. package/dist/web/app/rich-content-editor.d.ts +0 -3
  75. package/dist/web/app/rich-content-editor.js +0 -15
  76. package/dist/web/app/tiptap-editor.d.ts +0 -3
  77. package/dist/web/app/tiptap-editor.js +0 -120
@@ -6,17 +6,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  // file name link. A disabled input shows the value only, without controls.
7
7
  import { ImageIcon, Loader2, Upload } from "lucide-react";
8
8
  import { useRef, useState } from "react";
9
- import { useTranslation } from "../../index.js";
9
+ import { useSessionEndedSignal, useTranslation } from "../../index.js";
10
10
  import { toAcceptAttr } from "../lib/accept-attr.js";
11
11
  import { resizeImageBeforeUpload } from "../lib/resize-image.js";
12
12
  import { uploadErrorKey } from "../lib/upload-error-key.js";
13
- import { uploadFileRef } from "../lib/upload-file-ref.js";
13
+ import { isSessionEndedUploadError, uploadFileRef } from "../lib/upload-file-ref.js";
14
14
  import { Button as UiButton } from "../ui/button.js";
15
15
  import { FileNameLink, FileThumbnail } from "./file-thumbnail.js";
16
16
  // React's input typings have no `webkitdirectory`; spreading keeps it typed-cast-free.
17
17
  const DIRECTORY_INPUT_ATTRS = { webkitdirectory: "", multiple: true };
18
18
  export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
19
19
  const t = useTranslation();
20
+ const sessionEnded = useSessionEndedSignal();
20
21
  const inputRef = useRef(null);
21
22
  const [uploading, setUploading] = useState(false);
22
23
  const [error, setError] = useState(null);
@@ -42,6 +43,8 @@ export function FileUploadInput({ kind, id, value, onChange, accept, disabled, e
42
43
  }));
43
44
  }
44
45
  catch (cause) {
46
+ if (isSessionEndedUploadError(cause))
47
+ sessionEnded?.notify();
45
48
  setError(t(uploadErrorKey(cause)));
46
49
  }
47
50
  finally {
@@ -3,12 +3,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  // file, the field value is the array of FileRef ids of the files that uploaded. Failed files stay
4
4
  // out of the value; removing a row removes its id.
5
5
  import { useEffect, useRef } from "react";
6
- import { useTranslation } from "../../index.js";
6
+ import { useSessionEndedSignal, useTranslation } from "../../index.js";
7
7
  import { FileCountLimitError, uploadErrorKey } from "../lib/upload-error-key.js";
8
- import { uploadFileRef } from "../lib/upload-file-ref.js";
8
+ import { isSessionEndedUploadError, uploadFileRef, } from "../lib/upload-file-ref.js";
9
9
  import { UploadZone } from "../widgets/upload-zone.js";
10
10
  export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }) {
11
11
  const t = useTranslation();
12
+ const sessionEnded = useSessionEndedSignal();
12
13
  // Uploads of one batch finish independently; building each next value from the render's
13
14
  // `value` would drop the ids of the others. Re-synced whenever the owner changes the value.
14
15
  const latestIds = useRef(value);
@@ -33,6 +34,11 @@ export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCo
33
34
  onChange(latestIds.current);
34
35
  return fileId;
35
36
  }
37
+ catch (cause) {
38
+ if (isSessionEndedUploadError(cause))
39
+ sessionEnded?.notify();
40
+ throw cause;
41
+ }
36
42
  finally {
37
43
  uploadsRunning.current -= 1;
38
44
  }
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Full-size image overlay — ModalShell without confirm/cancel actions.
3
- import * as DialogPrimitive from "@radix-ui/react-dialog";
4
3
  import { ChevronLeft, ChevronRight } from "lucide-react";
4
+ import { Dialog as DialogPrimitive } from "radix-ui";
5
5
  import { useTranslation } from "../../index.js";
6
6
  import { cn } from "../lib/cn.js";
7
7
  import { ModalShell } from "./modal-shell.js";
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Shared Radix dialog chrome for confirm Dialog + image Lightbox.
3
3
  // Not exported from the package — only an internal building block.
4
- import * as DialogPrimitive from "@radix-ui/react-dialog";
5
4
  import { X } from "lucide-react";
5
+ import { Dialog as DialogPrimitive } from "radix-ui";
6
6
  import { cn } from "../lib/cn.js";
7
7
  export function ModalShell({ open, onOpenChange, children, testId, contentClassName, noAriaDescription, closeLabel, showCloseButton = true, onOpenAutoFocus, onContentKeyDown, }) {
8
8
  return (_jsx(DialogPrimitive.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogPrimitive.Portal, { children: [_jsx(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" }), _jsxs(DialogPrimitive.Content, { "data-testid": testId, ...(noAriaDescription && { "aria-describedby": undefined }), ...(onOpenAutoFocus !== undefined && { onOpenAutoFocus }), ...(onContentKeyDown !== undefined && { onKeyDown: onContentKeyDown }), className: cn("fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", contentClassName), children: [children, showCloseButton && closeLabel !== undefined && (_jsx(DialogPrimitive.Close, { asChild: true, children: _jsx("button", { type: "button", "aria-label": closeLabel, 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", children: _jsx(X, { className: "size-4" }) }) }))] })] }) }));
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Bare content shell for hosting self-contained widgets (own submit/cancel
3
3
  // buttons) in a modal overlay — same Radix chrome as DefaultDialog, no
4
4
  // footer buttons of its own.
5
- import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { Dialog as DialogPrimitive } from "radix-ui";
6
6
  import { useTranslation } from "../../index.js";
7
7
  import { cn } from "../lib/cn.js";
8
8
  import { BareFormProvider } from "./contexts.js";
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import * as PopoverPrimitive from "@radix-ui/react-popover";
3
2
  import { MoreHorizontal } from "lucide-react";
3
+ import { Popover as PopoverPrimitive } from "radix-ui";
4
4
  import { cloneElement, isValidElement, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
  import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, } from "../../index.js";
@@ -1,13 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // Toast primitive built on @radix-ui/react-toast: a global provider and
2
+ // Toast primitive built on radix-ui Toast: a global provider and
3
3
  // a useToast() hook for triggering toasts throughout the app. Consumers
4
4
  // get only the `toast()` trigger, without Context boilerplate.
5
5
  //
6
6
  // Variant is StatusTone, using the same color family as StatusBadge;
7
7
  // the default is "muted" (neutral). Toasts auto-dismiss after 5s and can
8
8
  // be closed manually with the X button. Radix provides the ARIA live region.
9
- import * as Primitive from "@radix-ui/react-toast";
10
9
  import { X } from "lucide-react";
10
+ import { Toast as Primitive } from "radix-ui";
11
11
  import { createContext, useCallback, useContext, useId, useMemo, useRef, useState, } from "react";
12
12
  import { useTranslation } from "../../index.js";
13
13
  import { cn } from "../lib/cn.js";
@@ -74,7 +74,6 @@ export function createEventSourceLiveEvents(options = {}) {
74
74
  const wiredEntities = new Set();
75
75
  let cancelReconnect;
76
76
  let reconnectAttempt = 0;
77
- let needsResync = false;
78
77
  // `subscribers` is a flat Set across all entities — the browser-side
79
78
  // addEventListener(entityName, ...) gate below only filters which frames
80
79
  // arrive at all, not which subscriber a given frame is for. Without this
@@ -134,10 +133,6 @@ export function createEventSourceLiveEvents(options = {}) {
134
133
  };
135
134
  connection.addEventListener("open", () => {
136
135
  reconnectAttempt = 0;
137
- // skip: first connect, nothing was missed
138
- if (!needsResync)
139
- return;
140
- needsResync = false;
141
136
  emitResync();
142
137
  });
143
138
  connection.addEventListener(SESSION_ENDED_EVENT, (e) => {
@@ -146,7 +141,6 @@ export function createEventSourceLiveEvents(options = {}) {
146
141
  signalSessionEnded();
147
142
  });
148
143
  connection.addEventListener("error", () => {
149
- needsResync = true;
150
144
  // skip: CONNECTING means a dropped stream the browser retries itself;
151
145
  // only a refused handshake (non-200) closes the EventSource for good.
152
146
  if (connection.readyState !== EventSource.CLOSED)
@@ -191,7 +185,6 @@ export function createEventSourceLiveEvents(options = {}) {
191
185
  cancelReconnect = undefined;
192
186
  }
193
187
  reconnectAttempt = 0;
194
- needsResync = false;
195
188
  // skip: already closed, nothing to close
196
189
  if (source === undefined)
197
190
  return;
@@ -0,0 +1,32 @@
1
+ import type { DragDelta } from "./use-pointer-drag.ts";
2
+ export type FloatingPanelGeometry = {
3
+ readonly x: number;
4
+ readonly y: number;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ };
8
+ export type Edge = "top" | "right" | "bottom" | "left";
9
+ export declare const FLOATING_PANEL_MARGIN_PX = 24;
10
+ export type Limits = {
11
+ readonly minWidth: number;
12
+ readonly minHeight: number;
13
+ readonly maxWidth: number;
14
+ readonly maxHeight: number;
15
+ readonly viewportWidth: number;
16
+ readonly viewportHeight: number;
17
+ };
18
+ type LimitOptions = {
19
+ readonly minWidthPx?: number | undefined;
20
+ readonly minHeightPx?: number | undefined;
21
+ readonly maxWidthPx?: number | undefined;
22
+ readonly maxHeightPx?: number | undefined;
23
+ };
24
+ export declare function readLimits(options: LimitOptions): Limits;
25
+ export declare function fitGeometry(geometry: FloatingPanelGeometry, limits: Limits): FloatingPanelGeometry;
26
+ export type ViewportSize = {
27
+ readonly width: number;
28
+ readonly height: number;
29
+ };
30
+ export declare function refitToViewport(geometry: FloatingPanelGeometry, previousViewport: ViewportSize, limits: Limits): FloatingPanelGeometry;
31
+ export declare function resizeGeometry(start: FloatingPanelGeometry, edges: readonly Edge[], delta: DragDelta, limits: Limits): FloatingPanelGeometry;
32
+ export {};
@@ -0,0 +1,71 @@
1
+ import { clamp } from "../lib/clamp.js";
2
+ export const FLOATING_PANEL_MARGIN_PX = 24;
3
+ // Minimum distance to every viewport edge, so the panel never sticks to the window border.
4
+ const VIEWPORT_GAP_PX = 16;
5
+ const DEFAULT_MIN_WIDTH_PX = 280;
6
+ const DEFAULT_MIN_HEIGHT_PX = 200;
7
+ export function readLimits(options) {
8
+ const viewportWidth = window.innerWidth;
9
+ const viewportHeight = window.innerHeight;
10
+ const maxWidth = Math.min(options.maxWidthPx ?? viewportWidth - 2 * FLOATING_PANEL_MARGIN_PX, viewportWidth - 2 * VIEWPORT_GAP_PX);
11
+ const maxHeight = Math.min(options.maxHeightPx ?? viewportHeight - 2 * FLOATING_PANEL_MARGIN_PX, viewportHeight - 2 * VIEWPORT_GAP_PX);
12
+ return {
13
+ // A viewport smaller than the configured minimum wins: the panel must stay fully visible.
14
+ minWidth: Math.min(options.minWidthPx ?? DEFAULT_MIN_WIDTH_PX, maxWidth),
15
+ minHeight: Math.min(options.minHeightPx ?? DEFAULT_MIN_HEIGHT_PX, maxHeight),
16
+ maxWidth,
17
+ maxHeight,
18
+ viewportWidth,
19
+ viewportHeight,
20
+ };
21
+ }
22
+ // Size first, then position, so a shrunk panel is placed against its final size.
23
+ export function fitGeometry(geometry, limits) {
24
+ const width = clamp(geometry.width, limits.minWidth, limits.maxWidth);
25
+ const height = clamp(geometry.height, limits.minHeight, limits.maxHeight);
26
+ return {
27
+ width,
28
+ height,
29
+ x: clampIntoViewport(geometry.x, width, limits.viewportWidth),
30
+ y: clampIntoViewport(geometry.y, height, limits.viewportHeight),
31
+ };
32
+ }
33
+ function clampIntoViewport(position, size, viewportSize) {
34
+ return clamp(position, VIEWPORT_GAP_PX, Math.max(VIEWPORT_GAP_PX, viewportSize - size - VIEWPORT_GAP_PX));
35
+ }
36
+ // A panel closer to the right (bottom) edge keeps its distance to that edge when the window
37
+ // changes size; otherwise it keeps its distance to the left (top) edge.
38
+ function anchoredPosition(position, size, previousViewportSize, fittedSize, viewportSize) {
39
+ const endGap = previousViewportSize - (position + size);
40
+ return endGap < position ? viewportSize - fittedSize - endGap : position;
41
+ }
42
+ export function refitToViewport(geometry, previousViewport, limits) {
43
+ const sized = fitGeometry(geometry, limits);
44
+ return fitGeometry({
45
+ ...sized,
46
+ x: anchoredPosition(geometry.x, geometry.width, previousViewport.width, sized.width, limits.viewportWidth),
47
+ y: anchoredPosition(geometry.y, geometry.height, previousViewport.height, sized.height, limits.viewportHeight),
48
+ }, limits);
49
+ }
50
+ // The edge being dragged follows the pointer; the opposite edge stays put.
51
+ function resizeAxis(start, delta, movesStartEdge, min, max, viewportSize) {
52
+ if (movesStartEdge) {
53
+ const end = start.position + start.size;
54
+ const size = clamp(start.size - delta, min, Math.min(max, end - VIEWPORT_GAP_PX));
55
+ return { position: end - size, size };
56
+ }
57
+ const size = clamp(start.size + delta, min, Math.min(max, viewportSize - VIEWPORT_GAP_PX - start.position));
58
+ return { position: start.position, size };
59
+ }
60
+ export function resizeGeometry(start, edges, delta, limits) {
61
+ let { x, y, width, height } = start;
62
+ for (const edge of edges) {
63
+ if (edge === "left" || edge === "right") {
64
+ ({ position: x, size: width } = resizeAxis({ position: start.x, size: start.width }, delta.dx, edge === "left", limits.minWidth, limits.maxWidth, limits.viewportWidth));
65
+ }
66
+ else {
67
+ ({ position: y, size: height } = resizeAxis({ position: start.y, size: start.height }, delta.dy, edge === "top", limits.minHeight, limits.maxHeight, limits.viewportHeight));
68
+ }
69
+ }
70
+ return { x, y, width, height };
71
+ }
@@ -1,10 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
- export type FloatingPanelGeometry = {
3
- readonly x: number;
4
- readonly y: number;
5
- readonly width: number;
6
- readonly height: number;
7
- };
2
+ import { type FloatingPanelGeometry } from "./floating-panel-geometry.ts";
3
+ export type { FloatingPanelGeometry } from "./floating-panel-geometry.ts";
8
4
  export type FloatingPanelProps = {
9
5
  /** Header content; the header is the move handle. */
10
6
  readonly title: ReactNode;
@@ -3,15 +3,12 @@ import { GripVerticalIcon } from "lucide-react";
3
3
  import { useCallback, useEffect, useId, useRef, useState, } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { useTranslation } from "../../index.js";
6
- import { clamp } from "../lib/clamp.js";
7
6
  import { cn } from "../lib/cn.js";
8
7
  import { useIsNarrowViewport } from "../primitives/use-narrow-viewport.js";
8
+ import { FLOATING_PANEL_MARGIN_PX, fitGeometry, readLimits, refitToViewport, resizeGeometry, } from "./floating-panel-geometry.js";
9
9
  import { arrowKeyDelta, usePointerDrag } from "./use-pointer-drag.js";
10
- const MARGIN_PX = 24;
11
10
  const DEFAULT_WIDTH_PX = 400;
12
11
  const DEFAULT_HEIGHT_PX = 560;
13
- const DEFAULT_MIN_WIDTH_PX = 280;
14
- const DEFAULT_MIN_HEIGHT_PX = 200;
15
12
  const EDGES = ["top", "right", "bottom", "left"];
16
13
  const EDGE_HANDLE_BASE = "absolute z-10 touch-none hover:bg-border focus-visible:bg-ring";
17
14
  const CORNER_HANDLE_BASE = "absolute z-20 size-3 touch-none";
@@ -44,54 +41,6 @@ const EDGE_LABEL_KEYS = {
44
41
  };
45
42
  const DRAG_HANDLE_SELECTOR = "[data-drag-handle]";
46
43
  const INTERACTIVE_HEADER_TARGET = "button, a, input, select, textarea, [role=button]";
47
- function readLimits(options) {
48
- const viewportWidth = window.innerWidth;
49
- const viewportHeight = window.innerHeight;
50
- const maxWidth = Math.min(options.maxWidthPx ?? viewportWidth - 2 * MARGIN_PX, viewportWidth);
51
- const maxHeight = Math.min(options.maxHeightPx ?? viewportHeight - 2 * MARGIN_PX, viewportHeight);
52
- return {
53
- // A viewport smaller than the configured minimum wins: the panel must stay fully visible.
54
- minWidth: Math.min(options.minWidthPx ?? DEFAULT_MIN_WIDTH_PX, maxWidth),
55
- minHeight: Math.min(options.minHeightPx ?? DEFAULT_MIN_HEIGHT_PX, maxHeight),
56
- maxWidth,
57
- maxHeight,
58
- viewportWidth,
59
- viewportHeight,
60
- };
61
- }
62
- // Size first, then position, so a shrunk panel is placed against its final size.
63
- function fitGeometry(geometry, limits) {
64
- const width = clamp(geometry.width, limits.minWidth, limits.maxWidth);
65
- const height = clamp(geometry.height, limits.minHeight, limits.maxHeight);
66
- return {
67
- width,
68
- height,
69
- x: clamp(geometry.x, 0, Math.max(0, limits.viewportWidth - width)),
70
- y: clamp(geometry.y, 0, Math.max(0, limits.viewportHeight - height)),
71
- };
72
- }
73
- // The edge being dragged follows the pointer; the opposite edge stays put.
74
- function resizeAxis(start, delta, movesStartEdge, min, max, viewportSize) {
75
- if (movesStartEdge) {
76
- const end = start.position + start.size;
77
- const size = clamp(start.size - delta, min, Math.min(max, end));
78
- return { position: end - size, size };
79
- }
80
- const size = clamp(start.size + delta, min, Math.min(max, viewportSize - start.position));
81
- return { position: start.position, size };
82
- }
83
- function resizeGeometry(start, edges, delta, limits) {
84
- let { x, y, width, height } = start;
85
- for (const edge of edges) {
86
- if (edge === "left" || edge === "right") {
87
- ({ position: x, size: width } = resizeAxis({ position: start.x, size: start.width }, delta.dx, edge === "left", limits.minWidth, limits.maxWidth, limits.viewportWidth));
88
- }
89
- else {
90
- ({ position: y, size: height } = resizeAxis({ position: start.y, size: start.height }, delta.dy, edge === "top", limits.minHeight, limits.maxHeight, limits.viewportHeight));
91
- }
92
- }
93
- return { x, y, width, height };
94
- }
95
44
  function isFiniteNumber(value) {
96
45
  return typeof value === "number" && Number.isFinite(value);
97
46
  }
@@ -141,8 +90,8 @@ function initialGeometry(storageKey, defaults, limits) {
141
90
  return fitGeometry({
142
91
  width,
143
92
  height,
144
- x: defaults?.x ?? limits.viewportWidth - width - MARGIN_PX,
145
- y: defaults?.y ?? limits.viewportHeight - height - MARGIN_PX,
93
+ x: defaults?.x ?? limits.viewportWidth - width - FLOATING_PANEL_MARGIN_PX,
94
+ y: defaults?.y ?? limits.viewportHeight - height - FLOATING_PANEL_MARGIN_PX,
146
95
  }, limits);
147
96
  }
148
97
  /** Non-modal panel floating above the page: draggable by its header,
@@ -158,6 +107,8 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
158
107
  const [geometry, setGeometry] = useState(() => initialGeometry(storageKey, defaultGeometry, getLimits()));
159
108
  const geometryRef = useRef(geometry);
160
109
  geometryRef.current = geometry;
110
+ // The viewport the current geometry was last fitted to; null until the first fit.
111
+ const fittedViewportRef = useRef(null);
161
112
  const applyGeometry = useCallback((next) => {
162
113
  geometryRef.current = next;
163
114
  setGeometry(next);
@@ -167,7 +118,12 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
167
118
  if (narrow)
168
119
  return;
169
120
  const onWindowResize = () => {
170
- applyGeometry(fitGeometry(geometryRef.current, getLimitsRef.current()));
121
+ const limits = getLimitsRef.current();
122
+ const previousViewport = fittedViewportRef.current;
123
+ applyGeometry(previousViewport === null
124
+ ? fitGeometry(geometryRef.current, limits)
125
+ : refitToViewport(geometryRef.current, previousViewport, limits));
126
+ fittedViewportRef.current = { width: limits.viewportWidth, height: limits.viewportHeight };
171
127
  };
172
128
  // Resize events fired while narrow were ignored, so re-fit once on the way back to wide.
173
129
  onWindowResize();
@@ -32,6 +32,9 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
32
32
  // this, a slow earlier response can land after a faster later one and
33
33
  // overwrite it, leaving stale rows on screen.
34
34
  const requestSeq = useRef(0);
35
+ // Lets refreshFirstPage tell "initial load still running" from outside a state updater.
36
+ const stateKindRef = useRef(state.kind);
37
+ stateKindRef.current = state.kind;
35
38
  // IDs of the most recently loaded first page. refreshFirstPage() needs
36
39
  // this to tell "row dropped from page 1 elsewhere" (must be pruned) apart
37
40
  // from "row belongs to an already-accumulated later page" (must survive)
@@ -81,6 +84,12 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
81
84
  requestSeq.current += 1;
82
85
  };
83
86
  }, [load]);
87
+ // Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
88
+ // into one query per event. A refresh already running schedules
89
+ // exactly one trailing pass after it settles.
90
+ const liveTimerRef = useRef(null);
91
+ const liveInFlightRef = useRef(false);
92
+ const liveTrailingRef = useRef(false);
84
93
  // Live-mode: on an SSE event for the query's entity, refetch only the
85
94
  // first page and merge it in — rows the fresh page still contains move
86
95
  // to the front (newest-first feeds), rows it dropped (edited/deleted
@@ -89,19 +98,32 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
89
98
  // scroll position.
90
99
  // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
91
100
  const refreshFirstPage = useCallback(async () => {
92
- // Read, don't bump: a concurrent load() (e.g. the mount fetch still
93
- // in flight) must still land. Bumping here would make load()'s own
94
- // sequence check discard it, and the refresh below then bails on
95
- // `prev.kind !== "ready"` — the list gets stuck in loading forever.
101
+ // Read, don't bump at the start: a concurrent load() (e.g. the mount
102
+ // fetch still in flight) must not be discarded by a refresh that then
103
+ // fails or is superseded. Only a refresh that actually delivers fresh
104
+ // rows bumps (below), because those are newer than the running load's.
96
105
  const seqAtStart = requestSeq.current;
97
106
  const res = await dispatcher.query(query, { ...payload, limit: pageSize });
98
- if (seqAtStart !== requestSeq.current)
107
+ if (seqAtStart !== requestSeq.current) {
108
+ // A load-more bumps the sequence without covering page 1, so the signal that
109
+ // triggered this refresh would otherwise be lost.
110
+ liveTrailingRef.current = true;
99
111
  return;
112
+ }
100
113
  // skip: background live refresh failed, keep showing the current rows
101
114
  if (!res.isSuccess)
102
115
  return;
103
116
  const freshRows = rowsRef.current(res.data);
104
117
  const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
118
+ if (stateKindRef.current === "loading") {
119
+ // The initial load was issued before the change that triggered this
120
+ // refresh (the live stream reports its first open as a resync), so
121
+ // its page 1 may be stale: adopt the fresh page and drop the older load.
122
+ requestSeq.current += 1;
123
+ firstPageIdsRef.current = freshIds;
124
+ setState({ kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) });
125
+ return;
126
+ }
105
127
  // A row absent from freshIds is stale for one of two reasons: it was
106
128
  // dropped from page 1 (deleted, or filtered out elsewhere) and must be
107
129
  // pruned, or it belongs to an already-accumulated later page and must
@@ -110,9 +132,6 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
110
132
  const previousFirstPageIds = firstPageIdsRef.current;
111
133
  firstPageIdsRef.current = freshIds;
112
134
  setState((prev) => {
113
- // skip: the initial load() is still in flight and owns the eventual
114
- // state — a concurrent refresh landing here has nothing accumulated
115
- // to merge into yet.
116
135
  if (prev.kind === "loading")
117
136
  return prev;
118
137
  if (prev.kind !== "ready") {
@@ -128,12 +147,6 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
128
147
  return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
129
148
  });
130
149
  }, [dispatcher, query, pageSize, payloadKey]);
131
- // Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
132
- // into one query per event. A refresh already running schedules
133
- // exactly one trailing pass after it settles.
134
- const liveTimerRef = useRef(null);
135
- const liveInFlightRef = useRef(false);
136
- const liveTrailingRef = useRef(false);
137
150
  useEffect(() => {
138
151
  // skip: live mode off, no SSE subscription needed
139
152
  if (!live)
@@ -4,6 +4,8 @@ export type PlanCardPrice = {
4
4
  readonly period?: string;
5
5
  /** Shown before the amount, e.g. "from" when prices differ per provider. */
6
6
  readonly prefix?: string;
7
+ /** Small line under the price row, e.g. "plus VAT". */
8
+ readonly note?: string;
7
9
  };
8
10
  export type PlanCardActionSlot = {
9
11
  readonly label: string;
@@ -6,8 +6,12 @@ export function PlanCard({ title, description, price, features, current = false,
6
6
  const t = useTranslation();
7
7
  const compact = variant === "compact";
8
8
  const footer = cta !== undefined || ctaLink !== undefined || secondaryAction !== undefined ? (_jsxs("div", { className: "flex w-full flex-col gap-2", children: [ctaLink !== undefined && (_jsx(Link, { href: ctaLink.href, variant: "button", target: ctaLink.target, className: "w-full justify-center", testId: testId !== undefined ? `${testId}-cta` : "plan-card-cta", children: ctaLink.label })), ctaLink === undefined && cta !== undefined && (_jsx(Button, { width: "full", disabled: cta.disabled, onClick: cta.onClick, testId: testId !== undefined ? `${testId}-cta` : "plan-card-cta", children: cta.label })), secondaryAction !== undefined && (_jsx(Button, { variant: "secondary", width: "full", disabled: secondaryAction.disabled, onClick: secondaryAction.onClick, children: secondaryAction.label }))] })) : undefined;
9
- return (_jsxs(Card, { testId: testId ?? "plan-card", className: cn(current && "border-primary"), slots: { footer }, children: [_jsxs("div", { className: cn("flex items-start justify-between gap-2", compact && "items-center"), children: [_jsx("h2", { className: cn("font-semibold", compact ? "text-base" : "text-lg"), children: title }), current && (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-current` : "plan-card-current", className: "inline-block rounded-xl bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary", children: t("kumiko.planCard.current") }))] }), description !== undefined && !compact && (_jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: description })), price !== undefined && (_jsx("div", { className: cn(compact ? "mt-1" : "mt-1 mb-3"), children: price === null ? (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-price-unavailable` : "plan-card-price-unavailable", className: "text-sm text-muted-foreground", children: t("kumiko.planCard.priceUnavailable") })) : (_jsxs(_Fragment, { children: [price.prefix !== undefined && (_jsx("span", { className: "mr-1 text-sm text-muted-foreground", children: price.prefix })), _jsx("span", { className: "text-2xl font-bold", children: price.amount }), price.period !== undefined && (_jsx("span", { className: "ml-1 text-sm text-muted-foreground", children: price.period }))] })) })), !compact && features !== undefined && features.length > 0 && (_jsx("ul", { className: "space-y-1 text-sm", children: features.map((feature, index) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { "aria-hidden": "true", children: "\u2713" }), _jsx("span", { children: feature })] }, index))) }))] }));
9
+ return (_jsxs(Card, { testId: testId ?? "plan-card", className: cn(current && "border-primary"), slots: { footer }, children: [_jsxs("div", { className: cn("flex items-start justify-between gap-2", compact && "items-center"), children: [_jsx("h2", { className: cn("font-semibold", compact ? "text-base" : "text-lg"), children: title }), current && (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-current` : "plan-card-current", className: "inline-block rounded-xl bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary", children: t("kumiko.planCard.current") }))] }), description !== undefined && !compact && (_jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: description })), price !== undefined && _jsx(PlanCardPriceRow, { price: price, compact: compact, testId: testId }), !compact && features !== undefined && features.length > 0 && (_jsx("ul", { className: "space-y-1 text-sm", children: features.map((feature, index) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { "aria-hidden": "true", children: "\u2713" }), _jsx("span", { children: feature })] }, index))) }))] }));
10
10
  }
11
11
  export function PlanGrid({ children, testId }) {
12
12
  return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", children: children }));
13
13
  }
14
+ function PlanCardPriceRow({ price, compact, testId, }) {
15
+ const t = useTranslation();
16
+ return (_jsxs("div", { className: cn(compact ? "mt-1" : "mt-1 mb-3"), children: [price === null ? (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-price-unavailable` : "plan-card-price-unavailable", className: "text-sm text-muted-foreground", children: t("kumiko.planCard.priceUnavailable") })) : (_jsxs(_Fragment, { children: [price.prefix !== undefined && (_jsx("span", { className: "mr-1 text-sm text-muted-foreground", children: price.prefix })), _jsx("span", { className: "text-2xl font-bold", children: price.amount }), price.period !== undefined && (_jsx("span", { className: "ml-1 text-sm text-muted-foreground", children: price.period }))] })), price?.note !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-price-note` : "plan-card-price-note", className: "text-xs text-muted-foreground", children: price.note }))] }));
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.393.0",
3
+ "version": "0.395.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": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,21 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.393.0",
52
- "@cosmicdrift/kumiko-types": "0.393.0",
53
- "@radix-ui/react-dialog": "^1.1.15",
54
- "@radix-ui/react-dropdown-menu": "^2.1.16",
55
- "@radix-ui/react-label": "^2.1.8",
56
- "@radix-ui/react-popover": "^1.1.15",
57
- "@radix-ui/react-select": "^2.2.6",
58
- "@radix-ui/react-separator": "^1.1.7",
59
- "@radix-ui/react-slot": "^1.2.4",
60
- "@radix-ui/react-toast": "^1.2.15",
61
- "@radix-ui/react-tooltip": "^1.2.8",
62
- "@tiptap/core": "^3.31.3",
63
- "@tiptap/pm": "^3.31.3",
64
- "@tiptap/react": "^3.31.3",
65
- "@tiptap/starter-kit": "^3.31.3",
51
+ "@cosmicdrift/kumiko-framework": "0.395.0",
52
+ "@cosmicdrift/kumiko-types": "0.395.0",
66
53
  "@types/qrcode": "^1.5.5",
67
54
  "class-variance-authority": "^0.7.1",
68
55
  "clsx": "^2.1.1",
@@ -70,7 +57,7 @@
70
57
  "lucide-react": "^1.14.0",
71
58
  "pdfjs-dist": "6.3.289",
72
59
  "qrcode": "^1.5.4",
73
- "radix-ui": "^1.4.3",
60
+ "radix-ui": "^1.8.1",
74
61
  "react": "^19.2.6",
75
62
  "react-day-picker": "^10.0.0",
76
63
  "react-dom": "^19.2.6",
@@ -92,8 +79,8 @@
92
79
  }
93
80
  },
94
81
  "devDependencies": {
95
- "@cosmicdrift/kumiko-locale-de": "0.393.0",
96
- "@cosmicdrift/kumiko-testing": "0.393.0",
82
+ "@cosmicdrift/kumiko-locale-de": "0.395.0",
83
+ "@cosmicdrift/kumiko-testing": "0.395.0",
97
84
  "@tailwindcss/cli": "^4.3.0",
98
85
  "@testing-library/react": "^16.3.2",
99
86
  "@testing-library/user-event": "^14.6.1",
package/src/changes.json CHANGED
@@ -1,4 +1,58 @@
1
1
  [
2
+ {
3
+ "version": "0.395.0",
4
+ "type": "improvement",
5
+ "title": "Server field errors without a field on the form show in the form banner",
6
+ "detail": "An entityEdit save that fails with a field error whose path is not a rendered field (for example a field an extension section writes) now shows the message in the form error banner instead of losing it."
7
+ },
8
+ {
9
+ "version": "0.395.0",
10
+ "type": "fix",
11
+ "title": "A live refresh that a load-more supersedes is retried instead of lost",
12
+ "detail": "When a live signal triggered a first-page refresh while the list loaded its next page, the refresh result was discarded and nothing re-ran it, so the change on page 1 stayed invisible until the next event. The discarded refresh now runs once more."
13
+ },
14
+ {
15
+ "version": "0.394.0",
16
+ "type": "improvement",
17
+ "title": "edit screens opened without a record use screen:<id>.createTitle",
18
+ "detail": "When a detail screen is opened without an entity id and the feature translates `screen:<id>.createTitle`, the breadcrumb and page title use it instead of the edit title. managed-pages names its create state \"New page\"."
19
+ },
20
+ {
21
+ "version": "0.394.0",
22
+ "type": "improvement",
23
+ "title": "resizable tables size columns by content until the user resizes one",
24
+ "detail": "With `table.resizing` on (also through `bundledLists`), columns without a schema `width` no longer start at a flat 160px. The table keeps the auto layout and sizes them by content until the user drags or arrow-keys a column; that first resize stores the displayed widths of the other columns too, so nothing jumps. The first column of a resizable table breaks only when the table runs out of width, and a clickable first cell wraps instead of truncating, and dates and timestamps (also through a `format` renderer) stay on one line. `onWidthChange` gets an optional third argument with those widths. Stored widths from earlier sessions still apply; a browser that saved any view setting before this release still holds the old 160px widths until the view is reset. Apps with `bundledLists.table.resizing` (use-all-bundled, solon) see wider name columns and narrower leftover space on first visit."
25
+ },
26
+ {
27
+ "version": "0.394.0",
28
+ "type": "fix",
29
+ "title": "Floating panel keeps its distance to the window edge",
30
+ "detail": "Moving, resizing and window resizes clamp the panel 16px inside the viewport instead of at the border. On a window resize the panel keeps its gap to the nearer horizontal and vertical edge, so a bottom-right panel no longer sticks to the right border when the window narrows. Stored geometry stays valid and is only clamped again."
31
+ },
32
+ {
33
+ "version": "0.394.0",
34
+ "type": "fix",
35
+ "title": "One copy of Radix dismissable layers",
36
+ "detail": "Dialog, Popover, DropdownMenu and Toast now come from the radix-ui package instead of the single @radix-ui/react-* packages, which dropped nine direct dependencies. A root override pins @radix-ui/react-dialog to 1.3.1 because cmdk would otherwise pull an older dialog with its own react-dismissable-layer. Apps that import @radix-ui/react-* themselves, or add cmdk, should align their own lockfile to one react-dialog version."
37
+ },
38
+ {
39
+ "version": "0.394.0",
40
+ "type": "improvement",
41
+ "title": "A projectionDetail can be a dashboard screen panel",
42
+ "detail": "A `screen` panel or its `fallback` may point at a projectionDetail. The id comes from the host dashboard's `record` binding and is passed to the embedded screen as its entityId. Boot fails when the host dashboard has no `record`. Header, fields and Detail query render inline in the tile."
43
+ },
44
+ {
45
+ "version": "0.394.0",
46
+ "type": "improvement",
47
+ "title": "Toolbar actions accept visibleWhen",
48
+ "detail": "Every toolbar action of a projectionList or entityList takes an optional `visibleWhen` ({ query, field, eq }). The button shows only while the live query matches; it stays hidden while the query loads and when it fails. Buttons gating on the same query share one request. Boot checks the query, its output field and that it needs no input. relatedList toolbar actions reject `visibleWhen`."
49
+ },
50
+ {
51
+ "version": "0.394.0",
52
+ "type": "improvement",
53
+ "title": "Screen fallback can depend on the failure reason",
54
+ "detail": "A screen-level `fallback` is either a screen id (unchanged) or a map `{ hidden, forbidden, notFound, error }`. `forbidden` is a 403 and `notFound` a 404 on the gate query; `hidden` is an unmet condition; `error` is any other failure. A reason without an entry shows a default notice (new keys kumiko.screen.forbidden and kumiko.screen.notFound) and `error` shows an error banner with retry. Boot rejects an empty map and checks each entry like a string fallback."
55
+ },
2
56
  {
3
57
  "version": "0.392.1",
4
58
  "type": "fix",
@@ -1,3 +0,0 @@
1
- import { type ReactNode } from "react";
2
- import { type ContentEditorProps } from "../../index.ts";
3
- export declare function RichContentEditor(props: ContentEditorProps): ReactNode;
@@ -1,15 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- // @runtime client
3
- //
4
- // "rich" contentFormat editor: WYSIWYG on tiptap. Public contract is the
5
- // same four props every ContentEditorComponent gets — no tiptap type
6
- // crosses this boundary. tiptap itself is dynamic-imported (TiptapEditor
7
- // lives in ./tiptap-editor) so an app that never mounts a "rich" collection
8
- // never pays for it; the fallback while the chunk loads is the same plain
9
- // textarea uses for a missing editor, never a blank panel.
10
- import { lazy, Suspense } from "react";
11
- import { TextareaContentEditor } from "../../index.js";
12
- const TiptapEditor = lazy(() => import("./tiptap-editor.js"));
13
- export function RichContentEditor(props) {
14
- return (_jsx(Suspense, { fallback: _jsx(TextareaContentEditor, { ...props }), children: _jsx(TiptapEditor, { ...props }) }));
15
- }
@@ -1,3 +0,0 @@
1
- import { type ReactNode } from "react";
2
- import { type ContentEditorProps } from "../../index.ts";
3
- export default function TiptapEditor({ id, value, onChange, variables, readOnly, }: ContentEditorProps): ReactNode;