@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
@@ -5,6 +5,7 @@ import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
6
  import { isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
7
7
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { withDerivedValues } from "../components/form-derivation-patch.js";
8
9
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
9
10
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
10
11
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
@@ -2549,6 +2550,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2549
2550
  handoffValues,
2550
2551
  loadedValues,
2551
2552
  ]);
2553
+ const derivedInitial = useMemo(() => withDerivedValues(initial, screen.derive !== undefined ? screen : undefined), [initial, screen]);
2552
2554
  const t = useTranslation();
2553
2555
  const effectiveTranslate = translate ?? t;
2554
2556
  const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
@@ -2650,7 +2652,7 @@ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides,
2650
2652
  if (loadState !== undefined && loadState.status !== "ready") {
2651
2653
  return (_jsx(ActionFormLoadStatus, { state: loadState, screenId: screen.id, translate: effectiveTranslate }));
2652
2654
  }
2653
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial,
2655
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: derivedInitial, ...(screen.derive !== undefined && { formDerivation: screen }),
2654
2656
  // Extension-Sections im actionForm sehen die initialen Form-Values
2655
2657
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2656
2658
  // — anders als im entityEdit gibt es hier keinen record, aus dem
@@ -0,0 +1,8 @@
1
+ import type { FormDerivationScreen } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ /** The derived values that differ from the current form values, or undefined
3
+ * when everything is already up to date. A derived value that no longer
4
+ * resolves is reported as `undefined`. */
5
+ export declare function changedDerivedValues(values: unknown, screen: FormDerivationScreen): Record<string, unknown> | undefined;
6
+ /** `values` with every derived field already resolved, so the form's baseline
7
+ * matches what the live recomputation would produce and starts out clean. */
8
+ export declare function withDerivedValues<TValues extends Record<string, unknown>>(values: TValues, screen: FormDerivationScreen | undefined): TValues;
@@ -0,0 +1,24 @@
1
+ import { applyFormDerivations, isUnchangedValue } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ /** The derived values that differ from the current form values, or undefined
4
+ * when everything is already up to date. A derived value that no longer
5
+ * resolves is reported as `undefined`. */
6
+ export function changedDerivedValues(values, screen) {
7
+ const next = applyFormDerivations(values, screen);
8
+ if (!isPlainObject(values) || !isPlainObject(next))
9
+ return undefined;
10
+ const patch = {};
11
+ for (const target of Object.keys(screen.derive ?? {})) {
12
+ if (!isUnchangedValue(values[target], next[target]))
13
+ patch[target] = next[target];
14
+ }
15
+ return Object.keys(patch).length === 0 ? undefined : patch;
16
+ }
17
+ /** `values` with every derived field already resolved, so the form's baseline
18
+ * matches what the live recomputation would produce and starts out clean. */
19
+ export function withDerivedValues(values, screen) {
20
+ if (screen === undefined)
21
+ return values;
22
+ const patch = changedDerivedValues(values, screen);
23
+ return patch === undefined ? values : { ...values, ...patch };
24
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+ import { type FormWidth } from "../primitives.tsx";
3
+ export type FormScreenLayoutProps = {
4
+ readonly children: ReactNode;
5
+ readonly description?: ReactNode;
6
+ readonly actions?: ReactNode;
7
+ readonly secondaryActions?: ReactNode;
8
+ readonly onSubmit?: () => void;
9
+ readonly width?: FormWidth;
10
+ readonly testId?: string;
11
+ };
12
+ /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
13
+ export declare function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit, width, testId, }: FormScreenLayoutProps): ReactNode;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { usePrimitives } from "../primitives.js";
3
+ const FORM_SCREEN_LAYOUT_PROPS = {
4
+ screenForm: true,
5
+ fillHeight: true,
6
+ stickyActions: true,
7
+ };
8
+ const NOOP = () => { };
9
+ /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
10
+ export function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit = NOOP, width, testId, }) {
11
+ const { Form } = usePrimitives();
12
+ return (_jsx(Form, { onSubmit: onSubmit, ...FORM_SCREEN_LAYOUT_PROPS, ...(description !== undefined && { subtitle: description }), ...(actions !== undefined && { actions }), ...(secondaryActions !== undefined && { secondaryActions }), ...(width !== undefined && { width }), ...(testId !== undefined && { testId }), children: children }));
13
+ }
@@ -1,4 +1,4 @@
1
- import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, FormDerivationScreen, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { ReactNode } from "react";
3
3
  import type * as z from "zod";
4
4
  import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "../headless/index.ts";
@@ -204,6 +204,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
204
204
  * `disabled`, which locks the whole form. For a gate the host owns, e.g.
205
205
  * an acknowledgement checkbox in `leadContent`. */
206
206
  readonly submitBlocked?: boolean;
207
+ /** actionForm `derive`: derived fields are recomputed into the form state on every
208
+ * change and render read-only. */
209
+ readonly formDerivation?: FormDerivationScreen;
207
210
  /** Show the unsaved-changes footer (count, Discard, "Save changes") even
208
211
  * without an entity id or a screen-height form — for hosts that edit an
209
212
  * existing server-side record through `customSubmit` (configEdit, also when
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "../headless/index.js";
5
+ import { changedDerivedValues } from "./form-derivation-patch.js";
5
6
  import { RenderEditActionButton } from "./render-edit-action-button.js";
6
7
  import { AllFieldsRequiredProvider } from "./render-field.js";
7
8
  import { useActionMenuItems } from "./use-action-menu-items.js";
@@ -229,7 +230,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
229
230
  return translate(template, values);
230
231
  }
231
232
  export function RenderEdit(props) {
232
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
233
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, } = props;
233
234
  // View mode is a locked form, so everything keyed on `disabled` follows.
234
235
  const disabled = disabledProp || viewMode;
235
236
  const i18nScreenId = props.i18nScreenId ?? screen.id;
@@ -367,6 +368,16 @@ export function RenderEdit(props) {
367
368
  ...(ctx !== undefined && { ctx }),
368
369
  ...(submitConfig !== undefined && { submit: submitConfig }),
369
370
  });
371
+ // Derived fields are written into form state (not only displayed) so required
372
+ // validation and the submit payload carry them; only a real change is set, which
373
+ // keeps this from looping.
374
+ useEffect(() => {
375
+ if (formDerivation === undefined)
376
+ return;
377
+ const patch = changedDerivedValues(snapshot.values, formDerivation);
378
+ if (patch !== undefined)
379
+ controller.setValues(patch); // @cast-boundary form-values
380
+ }, [snapshot.values, formDerivation, controller]);
370
381
  // Wired at the user-input boundary, not in controller.setField: computed
371
382
  // fields, prefill, draft restore and action patches also call setValues
372
383
  // after a failed submit and must not wipe the errors.
@@ -654,7 +665,9 @@ export function RenderEdit(props) {
654
665
  featureName,
655
666
  mode: writeVerb,
656
667
  ...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
668
+ ...(formDerivation !== undefined && { derivation: formDerivation }),
657
669
  }), [
670
+ formDerivation,
658
671
  screen,
659
672
  entity,
660
673
  snapshot.values,
@@ -11,6 +11,7 @@ import { evalRowExtractor } from "../app/row-actions.js";
11
11
  import { screenAccessAllows } from "../app/screen-access.js";
12
12
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
13
13
  import { useUserRoles } from "../context/user-roles-context.js";
14
+ import { fileIdsOf } from "../file-ids-of.js";
14
15
  import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "../headless/index.js";
15
16
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
16
17
  import { useQuery } from "../hooks/use-query.js";
@@ -280,7 +281,7 @@ function inputId(field) {
280
281
  // Doesn't resolve the `string` (cross-feature QN) FieldRenderer variant — the
281
282
  // list-column path (DataTableCell) doesn't either, so this stays in parity.
282
283
  function FieldRendererOutput({ field, renderer, row, appLocale, }) {
283
- const { Text, JsonView } = usePrimitives();
284
+ const { Text, JsonView, FileValueList } = usePrimitives();
284
285
  const t = useTranslation();
285
286
  const componentName = !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
286
287
  ? extensionSectionName(renderer)
@@ -293,6 +294,12 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
293
294
  if (renderer.format === "json" && JsonView !== undefined) {
294
295
  return (_jsx(JsonView, { value: field.value, indent: renderer.indent, testId: `field-value-${field.field}` }));
295
296
  }
297
+ if (renderer.format === "file" && FileValueList !== undefined) {
298
+ const fileIds = fileIdsOf(field.value);
299
+ if (fileIds.length === 0)
300
+ return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
301
+ return _jsx(FileValueList, { fileIds: fileIds, testId: `field-value-${field.field}` });
302
+ }
296
303
  // App locale as default when the FormatSpec declares none of its own —
297
304
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
298
305
  // bigInt/unit) fell back to Intl's runtime default instead of the app
@@ -0,0 +1 @@
1
+ export declare function fileIdsOf(value: unknown): readonly string[];
@@ -0,0 +1,7 @@
1
+ export function fileIdsOf(value) {
2
+ if (typeof value === "string")
3
+ return value === "" ? [] : [value];
4
+ if (!Array.isArray(value))
5
+ return [];
6
+ return value.filter((entry) => typeof entry === "string" && entry !== "");
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { FlowIcon } from "./types.ts";
2
+ /** Inner markup for a 24x24 viewBox, stroked via the `.icon` CSS class. "ai" renders as a text badge instead — see render.ts. */
3
+ export declare const ICONS: Record<Exclude<FlowIcon, "ai">, string>;
@@ -0,0 +1,14 @@
1
+ /** Inner markup for a 24x24 viewBox, stroked via the `.icon` CSS class. "ai" renders as a text badge instead — see render.ts. */
2
+ export const ICONS = {
3
+ ui: '<rect class="icon" x="3" y="4" width="18" height="15" rx="1.5"/><path class="icon" d="M3 8h18"/>',
4
+ api: '<path class="icon" d="M8 6l-5 6 5 6"/><path class="icon" d="M16 6l5 6-5 6"/>',
5
+ handler: '<path class="icon" d="M8 4l-4 8 4 8"/><path class="icon" d="M16 4l4 8-4 8"/><path class="icon" d="M11 20l2-16"/>',
6
+ "event-store": '<path class="icon" d="M4 6c0-1.5 3.6-2.5 8-2.5S20 4.5 20 6s-3.6 2.5-8 2.5S4 7.5 4 6z"/><path class="icon" d="M4 6v12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5V6"/><path class="icon" d="M4 12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5"/>',
7
+ database: '<path class="icon" d="M4 6c0-1.5 3.6-2.5 8-2.5S20 4.5 20 6s-3.6 2.5-8 2.5S4 7.5 4 6z"/><path class="icon" d="M4 6v12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5V6"/>',
8
+ inbox: '<path class="icon" d="M3 6h18v13H3z"/><path class="icon" d="M3 6l9 7 9-7"/>',
9
+ broadcast: '<path class="icon" d="M12 20v-1"/><path class="icon" d="M8.5 15.5a5 5 0 017 0"/><path class="icon" d="M5 12a9 9 0 0114 0"/>',
10
+ person: '<circle class="icon" cx="12" cy="8" r="3.2"/><path class="icon" d="M5 20c0-4 3-6 7-6s7 2 7 6"/>',
11
+ check: '<path class="icon" d="M5 12l5 5L20 6"/>',
12
+ stop: '<rect class="icon" x="7" y="7" width="10" height="10" rx="1.5"/>',
13
+ box: '<rect class="icon" x="4" y="4" width="16" height="16" rx="2"/>',
14
+ };
@@ -0,0 +1,2 @@
1
+ export { renderFlow } from "./render.ts";
2
+ export type { FlowEdge, FlowIcon, FlowNode, FlowNodeKind, FlowSpec } from "./types.ts";
@@ -0,0 +1 @@
1
+ export { renderFlow } from "./render.js";
@@ -0,0 +1,3 @@
1
+ import type { FlowSpec } from "./types.ts";
2
+ /** Renders a FlowSpec (a small acyclic node/edge graph) to a self-contained, animated SVG diagram string for embedding in docs pages. */
3
+ export declare function renderFlow(spec: FlowSpec): string;
@@ -0,0 +1,234 @@
1
+ import { escapeHtml } from "../headless/index.js";
2
+ import { ICONS } from "./icons.js";
3
+ const BADGE = 24;
4
+ const ICON_SIZE = 13;
5
+ const PAD_X = 9.6;
6
+ const GAP_BADGE_TEXT = 8;
7
+ const GAP_RIGHT = 11.2;
8
+ const ROW_GAP = 35;
9
+ const COL_GAP = 22;
10
+ const MIN_CARD_WIDTH = 120;
11
+ const TITLE_SIZE = 11.2;
12
+ const SUB_SIZE = 9.2;
13
+ const STEP_SECONDS = 1.2;
14
+ function textWidth(text, fontSize, bold) {
15
+ return text.length * fontSize * (bold ? 0.62 : 0.56);
16
+ }
17
+ function cardSize(node) {
18
+ const titleWidth = textWidth(node.title, TITLE_SIZE, true);
19
+ const subWidth = node.sub ? textWidth(node.sub, SUB_SIZE, false) : 0;
20
+ const content = Math.max(titleWidth, subWidth);
21
+ const width = Math.max(MIN_CARD_WIDTH, BADGE + GAP_BADGE_TEXT + content + PAD_X + GAP_RIGHT);
22
+ const height = node.sub ? 42 : 32;
23
+ return { width, height };
24
+ }
25
+ function assertEdgesReferenceKnownNodes(spec) {
26
+ const ids = new Set(spec.nodes.map((n) => n.id));
27
+ for (const edge of spec.edges) {
28
+ if (!ids.has(edge.from))
29
+ throw new Error(`FlowSpec: edge references unknown node "${edge.from}"`);
30
+ if (!ids.has(edge.to))
31
+ throw new Error(`FlowSpec: edge references unknown node "${edge.to}"`);
32
+ }
33
+ }
34
+ function buildAdjacency(spec) {
35
+ const children = new Map();
36
+ const inDegree = new Map();
37
+ for (const node of spec.nodes)
38
+ inDegree.set(node.id, 0);
39
+ for (const edge of spec.edges) {
40
+ children.set(edge.from, [...(children.get(edge.from) ?? []), edge]);
41
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
42
+ }
43
+ return { children, inDegree };
44
+ }
45
+ /** Longest-path depth per node via Kahn's algorithm; throws if the graph has no root or contains a cycle. */
46
+ function longestPathDepths(spec, children, inDegree) {
47
+ const depth = new Map();
48
+ const queue = spec.nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id);
49
+ if (queue.length === 0 && spec.nodes.length > 0) {
50
+ throw new Error("FlowSpec: no root node found (every node has an incoming edge — check for a cycle)");
51
+ }
52
+ for (const id of queue)
53
+ depth.set(id, 0);
54
+ const remaining = new Map(inDegree);
55
+ let visited = 0;
56
+ for (const id of queue) {
57
+ visited += 1;
58
+ for (const edge of children.get(id) ?? []) {
59
+ const next = Math.max(depth.get(edge.to) ?? 0, (depth.get(id) ?? 0) + 1);
60
+ depth.set(edge.to, next);
61
+ const left = (remaining.get(edge.to) ?? 0) - 1;
62
+ remaining.set(edge.to, left);
63
+ if (left === 0)
64
+ queue.push(edge.to);
65
+ }
66
+ }
67
+ if (visited < spec.nodes.length) {
68
+ throw new Error("FlowSpec: graph has a cycle — a flow diagram must be acyclic");
69
+ }
70
+ return depth;
71
+ }
72
+ function computeDepths(spec) {
73
+ assertEdgesReferenceKnownNodes(spec);
74
+ const { children, inDegree } = buildAdjacency(spec);
75
+ return longestPathDepths(spec, children, inDegree);
76
+ }
77
+ function layout(spec) {
78
+ const depthById = computeDepths(spec);
79
+ const maxDepth = Math.max(0, ...[...depthById.values()]);
80
+ const rows = Array.from({ length: maxDepth + 1 }, () => []);
81
+ for (const node of spec.nodes)
82
+ rows[depthById.get(node.id) ?? 0]?.push(node);
83
+ // Siblings in the same row share one card size (the row's largest), so a
84
+ // row of 3+ always lands symmetrically under its parent — an odd sibling
85
+ // count with mismatched widths otherwise throws the middle card off-center.
86
+ const sizedRows = rows.map((row) => {
87
+ const sizes = row.map((n) => cardSize(n));
88
+ return {
89
+ row,
90
+ width: Math.max(MIN_CARD_WIDTH, ...sizes.map((s) => s.width)),
91
+ height: Math.max(0, ...sizes.map((s) => s.height)),
92
+ };
93
+ });
94
+ const rowWidths = sizedRows.map(({ row, width }) => row.length * width + COL_GAP * Math.max(0, row.length - 1));
95
+ const canvasWidth = Math.max(...rowWidths, MIN_CARD_WIDTH) + 40;
96
+ const centerX = canvasWidth / 2;
97
+ const placed = new Map();
98
+ let y = 20;
99
+ for (const { row, width, height } of sizedRows) {
100
+ const rowWidth = row.length * width + COL_GAP * Math.max(0, row.length - 1);
101
+ let x = centerX - rowWidth / 2;
102
+ for (const node of row) {
103
+ placed.set(node.id, { node, depth: depthById.get(node.id) ?? 0, x, y, width, height });
104
+ x += width + COL_GAP;
105
+ }
106
+ y += height + ROW_GAP;
107
+ }
108
+ const canvasHeight = y - ROW_GAP + 20;
109
+ return { placed, width: canvasWidth, height: canvasHeight, maxDepth };
110
+ }
111
+ function computeTiming(placed, spec, maxDepth) {
112
+ const totalSeconds = STEP_SECONDS * (maxDepth + 1) + STEP_SECONDS * 0.5;
113
+ const timingByEdge = new Map();
114
+ const arrivalByNode = new Map();
115
+ for (const edge of spec.edges) {
116
+ const from = placed.get(edge.from);
117
+ const to = placed.get(edge.to);
118
+ if (!from || !to)
119
+ continue;
120
+ const span = Math.max(1, to.depth - from.depth);
121
+ const startT = from.depth * STEP_SECONDS;
122
+ const travelT = STEP_SECONDS * 0.75 * span;
123
+ const startFrac = startT / totalSeconds;
124
+ const endFrac = Math.min(0.98, (startT + travelT) / totalSeconds);
125
+ timingByEdge.set(edge, { startFrac, endFrac });
126
+ arrivalByNode.set(edge.to, Math.max(arrivalByNode.get(edge.to) ?? 0, endFrac));
127
+ }
128
+ return { totalSeconds, timingByEdge, arrivalByNode };
129
+ }
130
+ const CORNER_RADIUS = 8; // 10 * 0.8
131
+ function edgePath(from, to) {
132
+ const x1 = from.x + from.width / 2;
133
+ const y1 = from.y + from.height;
134
+ const x2 = to.x + to.width / 2;
135
+ const y2 = to.y;
136
+ if (Math.abs(x1 - x2) < 1) {
137
+ return { d: `M ${x1} ${y1} L ${x2} ${y2}`, midX: x1, midY: (y1 + y2) / 2 };
138
+ }
139
+ const midY = (y1 + y2) / 2;
140
+ const dir = x2 > x1 ? 1 : -1;
141
+ const r = Math.min(CORNER_RADIUS, Math.abs(x2 - x1) / 2, midY - y1, y2 - midY);
142
+ const d = `M ${x1} ${y1} L ${x1} ${midY - r} ` +
143
+ `Q ${x1} ${midY} ${x1 + dir * r} ${midY} ` +
144
+ `L ${x2 - dir * r} ${midY} ` +
145
+ `Q ${x2} ${midY} ${x2} ${midY + r} ` +
146
+ `L ${x2} ${y2}`;
147
+ return { d, midX: (x1 + x2) / 2, midY };
148
+ }
149
+ /** Renders a FlowSpec (a small acyclic node/edge graph) to a self-contained, animated SVG diagram string for embedding in docs pages. */
150
+ export function renderFlow(spec) {
151
+ const { placed, width, height, maxDepth } = layout(spec);
152
+ const { totalSeconds, timingByEdge, arrivalByNode } = computeTiming(placed, spec, maxDepth);
153
+ const wires = [];
154
+ const dots = [];
155
+ const labels = [];
156
+ for (const edge of spec.edges) {
157
+ const from = placed.get(edge.from);
158
+ const to = placed.get(edge.to);
159
+ if (!from || !to)
160
+ continue;
161
+ const { d, midX, midY } = edgePath(from, to);
162
+ // html-ok: d is built in edgePath() from numeric coordinates only, never user input
163
+ wires.push(`<path class="wire" d="${d}"/>`);
164
+ if (edge.label) {
165
+ labels.push(`<text class="edge-label" x="${midX}" y="${midY - 6}" text-anchor="middle">${escapeHtml(edge.label)}</text>`);
166
+ }
167
+ const timing = timingByEdge.get(edge);
168
+ if (!timing)
169
+ continue;
170
+ const { startFrac, endFrac } = timing;
171
+ const fadeIn = Math.min(startFrac + 0.02, endFrac);
172
+ const fadeOut = Math.min(endFrac + 0.02, 1);
173
+ dots.push(`<circle r="3.2" class="dot">` +
174
+ `<animateMotion dur="${totalSeconds}s" repeatCount="indefinite" calcMode="linear" ` +
175
+ `keyPoints="0;0;1;1" keyTimes="0;${startFrac};${endFrac};1" path="${d}"/>` +
176
+ `<animate attributeName="opacity" dur="${totalSeconds}s" repeatCount="indefinite" ` +
177
+ `values="0;0;1;1;0;0" keyTimes="0;${startFrac};${fadeIn};${endFrac};${fadeOut};1"/>` +
178
+ `</circle>`);
179
+ }
180
+ const cards = spec.nodes.map((node) => {
181
+ const p = placed.get(node.id);
182
+ if (!p)
183
+ return "";
184
+ const arrival = arrivalByNode.get(node.id) ?? 0.02;
185
+ const pulseEnd = Math.min(arrival + 0.05, 1);
186
+ const badgeX = p.x + PAD_X;
187
+ const badgeY = p.y + (p.height - BADGE) / 2;
188
+ const textX = badgeX + BADGE + GAP_BADGE_TEXT;
189
+ const isTerminal = node.kind === "terminal";
190
+ const badgeFill = isTerminal
191
+ ? "var(--flow-card)"
192
+ : node.icon === "check"
193
+ ? "var(--flow-green)"
194
+ : "var(--flow-badge)";
195
+ const badgeClass = isTerminal ? "flow-badge flow-badge-terminal" : "flow-badge";
196
+ const iconGroupClass = isTerminal ? "flow-icon-terminal" : "";
197
+ const iconInset = (BADGE - ICON_SIZE) / 2;
198
+ const iconInner = node.icon === "ai"
199
+ ? `<text class="flow-ai-label" x="${BADGE / 2}" y="${BADGE / 2 + 3}" text-anchor="middle">AI</text>`
200
+ : // html-ok: ICONS is a static internal map keyed by the FlowIcon union, not user input
201
+ `<svg class="${iconGroupClass}" x="${iconInset}" y="${iconInset}" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24">${ICONS[node.icon ?? "box"]}</svg>`;
202
+ return `<g>
203
+ <rect class="flow-card" x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}" rx="9">
204
+ <animate attributeName="stroke" dur="${totalSeconds}s" repeatCount="indefinite"
205
+ values="var(--flow-card-border);var(--flow-card-border);var(--flow-violet);var(--flow-card-border);var(--flow-card-border)"
206
+ keyTimes="0;${Math.max(0, arrival - 0.02)};${arrival};${pulseEnd};1"/>
207
+ </rect>
208
+ <g transform="translate(${badgeX}, ${badgeY})">
209
+ <rect class="${badgeClass}" width="${BADGE}" height="${BADGE}" rx="6" fill="${badgeFill}"/>
210
+ ${iconInner}
211
+ </g>
212
+ <text class="flow-title" x="${textX}" y="${node.sub ? p.y + p.height / 2 - 2 : p.y + p.height / 2 + 3.5}">${escapeHtml(node.title)}</text>
213
+ ${node.sub ? `<text class="flow-sub" x="${textX}" y="${p.y + p.height / 2 + 11}">${escapeHtml(node.sub)}</text>` : ""}
214
+ </g>`;
215
+ });
216
+ // html-ok: wires/dots/labels/cards hold only fragments built from numeric
217
+ // coordinates or already-escaped text (escapeHtml above), never raw user input
218
+ return `<div class="kumiko-flow">
219
+ <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow diagram">
220
+ ${
221
+ // html-ok: see comment above return
222
+ wires.join("\n ")}
223
+ ${
224
+ // html-ok: see comment above return
225
+ dots.join("\n ")}
226
+ ${
227
+ // html-ok: see comment above return
228
+ labels.join("\n ")}
229
+ ${
230
+ // html-ok: see comment above return
231
+ cards.join("\n ")}
232
+ </svg>
233
+ </div>`;
234
+ }
@@ -0,0 +1,18 @@
1
+ export type FlowIcon = "ui" | "api" | "handler" | "event-store" | "database" | "broadcast" | "ai" | "inbox" | "person" | "check" | "stop" | "box";
2
+ export type FlowNodeKind = "process" | "decision" | "terminal";
3
+ export interface FlowNode {
4
+ id: string;
5
+ title: string;
6
+ sub?: string;
7
+ icon?: FlowIcon;
8
+ kind?: FlowNodeKind;
9
+ }
10
+ export interface FlowEdge {
11
+ from: string;
12
+ to: string;
13
+ label?: string;
14
+ }
15
+ export interface FlowSpec {
16
+ nodes: FlowNode[];
17
+ edges: FlowEdge[];
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
- import type { EditRelatedListSection, EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EditRelatedListSection, EntityDefinition, EntityEditScreenDefinition, FormDerivationScreen } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { EditRelatedListSectionViewModel, EditViewModel, Translate } from "./types.ts";
3
3
  export type ComputeEditViewModelInput<TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>> = {
4
4
  readonly screen: EntityEditScreenDefinition;
@@ -12,6 +12,9 @@ export type ComputeEditViewModelInput<TValues extends Readonly<Record<string, un
12
12
  readonly mode?: EditFormMode;
13
13
  /** The record being edited; omitted on create. */
14
14
  readonly entityId?: string;
15
+ /** actionForm `derive`: computed fields render read-only (and never block submit
16
+ * as required) unless their derivation currently leaves them to the user. */
17
+ readonly derivation?: FormDerivationScreen;
15
18
  };
16
19
  export type EditFormMode = "create" | "update";
17
20
  export declare function computeRelatedListSectionViewModel(sectionSpec: EditRelatedListSection, translate: Translate): EditRelatedListSectionViewModel;
@@ -1,4 +1,4 @@
1
- import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldsEditSection, isFormDerivationInputMode, isUnusedFallbackOperand, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { buildOptionLabels, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, } from "./list.js";
3
3
  // Resolves a writeForm section's own fieldDefs/fields through the same
4
4
  // per-field pipeline as a plain fields section, by reusing the pipeline
@@ -354,8 +354,17 @@ function buildExtensionSectionViewModel(sectionSpec, translate) {
354
354
  // Pure transform from screen-def + entity-def + row-values to the flat
355
355
  // section/field tree the renderer draws. FieldConditions are evaluated here
356
356
  // so the renderer never re-runs them during React render.
357
+ // A computed field the user can't currently type into, or a fallback operand
358
+ // that the list rows make irrelevant: read-only and never blocking as required.
359
+ function isLockedByDerivation(derivation, values, field) {
360
+ if (derivation === undefined)
361
+ return false;
362
+ const isDerivedAndLocked = derivation.derive?.[field] !== undefined &&
363
+ !isFormDerivationInputMode(derivation, values, field);
364
+ return isDerivedAndLocked || isUnusedFallbackOperand(derivation, values, field);
365
+ }
357
366
  export function computeEditViewModel(input) {
358
- const { screen, entity, values, translate, featureName, mode, entityId } = input;
367
+ const { screen, entity, values, translate, featureName, mode, entityId, derivation } = input;
359
368
  const excludedFields = new Set(mode === undefined ? [] : screen.writeExcludedFields?.[mode]);
360
369
  const isHiddenByExclusion = (fieldSpec) => mode === "create" && excludedFields.has(normalizeEditField(fieldSpec).field);
361
370
  const sections = screen.layout.sections.map((sectionSpec) => {
@@ -399,14 +408,16 @@ export function computeEditViewModel(input) {
399
408
  // `readOnly` (camelCase) is the name on both sides: EditFieldSpec
400
409
  // in the engine, and the view-model emitted here. One convention
401
410
  // through the stack beats translating at the boundary.
411
+ const lockedByDerivation = isLockedByDerivation(derivation, values, normalized.field);
402
412
  const readOnly = (mode === "update" && excludedFields.has(normalized.field)) ||
413
+ lockedByDerivation ||
403
414
  evalCondition(normalized.readOnly, false, values);
404
415
  // `required` on the field-spec overrides the entity-default. A
405
416
  // field that's required at the entity-level but marked required:
406
417
  // false on the screen (e.g. a soft-onboarding wizard that
407
418
  // collects less up-front) respects the screen override.
408
419
  const entityRequired = fieldDef.required === true;
409
- const required = evalCondition(normalized.required, entityRequired, values);
420
+ const required = !lockedByDerivation && evalCondition(normalized.required, entityRequired, values);
410
421
  // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
411
422
  // framework/src/engine/field-helpers.ts — headless has no dependency
412
423
  // on that module, so the literal is duplicated here instead of
@@ -245,7 +245,7 @@ export type EditFieldViewModel = {
245
245
  /** Sub-field name → how to compute its cell from other sub-fields of the
246
246
  * same row. From EmbeddedFieldDef.derived. */
247
247
  readonly embeddedListDerived?: Readonly<Record<string, {
248
- readonly op: "multiply" | "sum" | "subtract";
248
+ readonly op: "multiply" | "sum" | "subtract" | "percent";
249
249
  readonly from: readonly string[];
250
250
  }>>;
251
251
  /** Numeric sub-field names to sum in a totals row. From EmbeddedFieldDef.totals. */
@@ -40,6 +40,9 @@ export const kumikoDefaultTranslations = {
40
40
  "kumiko.field.locatedTzHint": "Time local to the given location",
41
41
  "kumiko.field.reference-created-no-id": "Record was created but could not be selected automatically. Please select it manually.",
42
42
  "kumiko.field.unsupported": "This field type can't be edited here yet.",
43
+ "kumiko.field.file-list.download": "Download",
44
+ "kumiko.field.file-list.hidePreview": "Hide preview",
45
+ "kumiko.field.file-list.preview": "Preview",
43
46
  "kumiko.field.fillAction.failed": "Could not fetch a suggestion.",
44
47
  "kumiko.field.writeOnly.remove": "Remove stored value",
45
48
  "kumiko.field.writeOnly.set": "Set",
@@ -155,6 +158,9 @@ export const kumikoDefaultTranslations = {
155
158
  "kumiko.widget.upload.done": "Uploaded",
156
159
  "kumiko.widget.upload.error": "Failed",
157
160
  "kumiko.widget.upload.rejected-type": "File type not allowed",
161
+ "kumiko.widget.pdf-preview.loading": "Loading PDF…",
162
+ "kumiko.widget.pdf-preview.pageOf": "Page {page} of {pages}",
163
+ "kumiko.widget.pdf-preview.renderFailed": "The PDF could not be displayed.",
158
164
  "kumiko.widget.step-bar.done": "Done",
159
165
  "kumiko.widget.drawer.restore": "Restore drawer width",
160
166
  "kumiko.widget.drawer.maximize": "Maximize drawer width",
package/dist/index.d.ts CHANGED
@@ -39,6 +39,8 @@ export { evalVisibleWhen, type VisibleWhenVerdict } from "./app/visible-when.ts"
39
39
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error.ts";
40
40
  export type { ErrorBoundaryFallbackProps, ErrorBoundaryProps, } from "./components/error-boundary.tsx";
41
41
  export { ErrorBoundary } from "./components/error-boundary.tsx";
42
+ export type { FormScreenLayoutProps } from "./components/form-screen-layout.tsx";
43
+ export { FormScreenLayout } from "./components/form-screen-layout.tsx";
42
44
  export { RelatedListSection } from "./components/related-list-section.tsx";
43
45
  export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./components/render-edit.tsx";
44
46
  export { RenderEdit } from "./components/render-edit.tsx";
@@ -79,7 +81,7 @@ export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTra
79
81
  export { kumikoDefaultTranslations } from "./i18n-defaults.ts";
80
82
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.ts";
81
83
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.ts";
82
- export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FooterActionMarker, FooterActionRole, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputFillAction, InputProps, JsonViewProps, LightboxImage, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PickedFile, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.tsx";
84
+ export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FileValueListProps, FillContainerProps, FooterActionMarker, FooterActionRole, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputFillAction, InputProps, JsonViewProps, LightboxImage, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PickedFile, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.tsx";
83
85
  export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.tsx";
84
86
  export type { SessionEndedSignal, SessionEndedSignalProviderProps, } from "./session/session-ended.tsx";
85
87
  export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.tsx";
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ export { VariableChips } from "./app/variable-chips.js";
32
32
  export { evalVisibleWhen } from "./app/visible-when.js";
33
33
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error.js";
34
34
  export { ErrorBoundary } from "./components/error-boundary.js";
35
+ export { FormScreenLayout } from "./components/form-screen-layout.js";
35
36
  export { RelatedListSection } from "./components/related-list-section.js";
36
37
  export { RenderEdit } from "./components/render-edit.js";
37
38
  export { needsActionConfirm } from "./components/render-edit-action-button.js";