@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.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 (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. package/src/changes.json +6 -0
@@ -0,0 +1,291 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { rendererWarn } from "../dev-console.js";
4
+ // Qualified names of the bundled `form-draft` feature. Hardcoded because the
5
+ // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
6
+ // with `layout.draft: true` and that feature unmounted is a boot error, so
7
+ // these can never dangle silently.
8
+ const FORM_DRAFT_GET = "form-draft:query:get";
9
+ const FORM_DRAFT_SAVE = "form-draft:write:save";
10
+ const FORM_DRAFT_DISCARD = "form-draft:write:discard";
11
+ const FORM_DRAFT_LIST = "form-draft:query:list";
12
+ // Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
13
+ // — same hardcoding rationale as the QNs above (no dependency on that package).
14
+ const FORM_DRAFT_KEY_MAX_LENGTH = 256;
15
+ // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
16
+ // patch() call (VIN-decode, an extension section) should not save immediately
17
+ // per call, but patch() can also fire from inside onChange on every keystroke
18
+ // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
19
+ // save instead of one per keystroke, while still saving well before a user
20
+ // abandons the tab.
21
+ export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
22
+ // draftKey convention for a create-mode draft (framework-wizard-mode.md,
23
+ // lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
24
+ // fallback (strip the prefix to recover a candidate's draftId) and the
25
+ // list query itself (filter out edit-mode drafts, which share the same
26
+ // `${screenId}:%` LIKE-prefix scan server-side).
27
+ function newDraftPrefix(screenId) {
28
+ return `${screenId}:new:`;
29
+ }
30
+ // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
31
+ // without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
32
+ function mintDraftId() {
33
+ const webCrypto = propertyOf(globalThis, "crypto");
34
+ const randomUUID = propertyOf(webCrypto, "randomUUID");
35
+ if (typeof randomUUID === "function") {
36
+ const id = Reflect.apply(randomUUID, webCrypto, []);
37
+ if (typeof id === "string")
38
+ return id;
39
+ }
40
+ return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
41
+ }
42
+ export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draftStorage, }) {
43
+ // Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
44
+ // on mount so a same-tab reload finds the right one of several parallel
45
+ // create-sessions on this screen; `null` until the first step change
46
+ // mints one (saveDraft), or the mount-time `form-draft:query:list`
47
+ // fallback below adopts an existing one. Edit-mode never touches this —
48
+ // its draftKey is `${screenId}:${entityId}` unconditionally.
49
+ const [draftId, setDraftId] = useState(() => isCreateMode ? draftStorage.getDraftId(screenId) : null);
50
+ // Marks a draftId this instance minted itself (saveDraft, first step
51
+ // change) so the restore effect below doesn't immediately re-fetch what
52
+ // it just wrote — there is nothing to restore, the row is brand new.
53
+ const mintedDraftIdRef = useRef(null);
54
+ // Marks that the mount-time `form-draft:query:list` fallback already ran
55
+ // once for this mount. Without this, a create-mode discard resets `draftId`
56
+ // to `null` (see discardDraft below) and would re-arm the list effect —
57
+ // silently adopting an unrelated parallel draft on the same screen right
58
+ // after the user just submitted (#1908).
59
+ const didListRef = useRef(false);
60
+ const [draftCandidates, setDraftCandidates] = useState(null);
61
+ // Derived from the screen id + the host entity id only — never from
62
+ // `vm.id`, which lives in the form values a restore mutates (load under one
63
+ // key, save under another). Edit-mode: `${screenId}:${entityId}`,
64
+ // unchanged. Create-mode: `${screenId}:new:${draftId}` once a draftId
65
+ // exists (resumed, adopted, or minted), `undefined` before that — two
66
+ // parallel create sessions on the same screen must never collapse onto
67
+ // the same key (kumiko-framework#1908).
68
+ const draftKey = useMemo(() => isCreateMode
69
+ ? draftId !== null
70
+ ? `${screenId}:new:${draftId}`
71
+ : undefined
72
+ : `${screenId}:${entityId}`, [screenId, entityId, isCreateMode, draftId]);
73
+ return {
74
+ draftId,
75
+ setDraftId,
76
+ draftCandidates,
77
+ setDraftCandidates,
78
+ mintedDraftIdRef,
79
+ didListRef,
80
+ draftKey,
81
+ };
82
+ }
83
+ export function useWizardFormDraftRestore({ screenId, isCreateMode, draftEnabled, dispatcher, controller, hasNavigatedRef, setRawStep, draftState: { draftId, draftKey, mintedDraftIdRef, didListRef, setDraftCandidates }, }) {
84
+ useEffect(() => {
85
+ // skip: this screen does not persist a draft.
86
+ if (!draftEnabled || dispatcher === undefined)
87
+ return;
88
+ // skip: create-mode with no draftId yet — nothing to restore, either
89
+ // the list-fallback effect below finds one or the first step change
90
+ // mints a fresh one.
91
+ if (draftKey === undefined)
92
+ return;
93
+ // skip: this draftId was just minted by saveDraft — the row it wrote
94
+ // is exactly the current form state, re-fetching it would be a no-op
95
+ // round-trip at best and a stale-echo clobber at worst.
96
+ if (draftId !== null && draftId === mintedDraftIdRef.current)
97
+ return;
98
+ let cancelled = false;
99
+ void (async () => {
100
+ try {
101
+ const result = await dispatcher.query(FORM_DRAFT_GET, { draftKey });
102
+ // skip: superseded by a newer mount, or the draft lookup failed — an
103
+ // unreachable draft store must not break the form itself.
104
+ if (cancelled || !result.isSuccess)
105
+ return;
106
+ const draft = result.data?.draft ?? null;
107
+ // skip: nothing saved yet for this key.
108
+ if (draft === null)
109
+ return;
110
+ // skip: the user already typed or wizard-navigated while the lookup
111
+ // was in flight — their input wins over the stored draft.
112
+ if (controller.getSnapshot().isDirty || hasNavigatedRef.current)
113
+ return;
114
+ // @cast-boundary form-values draft blob: `values` round-trips through an opaque
115
+ // jsonb column and comes back untyped.
116
+ controller.setValues(draft.values);
117
+ setRawStep(Math.max(draft.stepIndex, 0));
118
+ }
119
+ catch (err) {
120
+ rendererWarn("render-edit: draft restore query failed", err);
121
+ }
122
+ })();
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ }, [
127
+ draftEnabled,
128
+ draftKey,
129
+ draftId,
130
+ dispatcher,
131
+ controller,
132
+ mintedDraftIdRef,
133
+ hasNavigatedRef,
134
+ setRawStep,
135
+ ]);
136
+ // Mount-time fallback (issue #1913) for create-mode when no draftId
137
+ // survived in storage (new tab, cleared storage): ask the server for this
138
+ // screen's open drafts and let the user choose via a picker (below) —
139
+ // even for exactly one candidate. Auto-adopting a lone candidate would
140
+ // silently hand this tab someone else's in-progress draft from a parallel
141
+ // session on the same screen (cross-tab hijack); the picker's "start new"
142
+ // action covers the common case (it really was this tab's own draft) with
143
+ // one extra click. Zero candidates → stay null, saveDraft mints a fresh
144
+ // draftId on the first step change same as any other fresh create.
145
+ useEffect(() => {
146
+ if (!draftEnabled ||
147
+ !isCreateMode ||
148
+ draftId !== null ||
149
+ didListRef.current ||
150
+ dispatcher === undefined) {
151
+ // skip: this screen does not persist a draft, this is edit-mode, a
152
+ // draftId is already known (from storage or an earlier adoption), or
153
+ // this mount already ran the list lookup once (didListRef).
154
+ return;
155
+ }
156
+ let cancelled = false;
157
+ void (async () => {
158
+ try {
159
+ const result = await dispatcher.query(FORM_DRAFT_LIST, { screenId: screenId });
160
+ // skip: superseded by a newer mount, or the lookup failed — an
161
+ // unreachable draft store must not break a fresh create.
162
+ if (cancelled || !result.isSuccess)
163
+ return;
164
+ didListRef.current = true;
165
+ const prefix = newDraftPrefix(screenId);
166
+ // list's LIKE-prefix scan also matches edit-mode drafts
167
+ // (`${screenId}:${entityId}`) — keep only create-mode ones.
168
+ const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
169
+ // skip: no open drafts for this screen — stay null, saveDraft mints a
170
+ // fresh draftId on the first step change same as any other create.
171
+ if (candidates.length === 0)
172
+ return;
173
+ setDraftCandidates(candidates);
174
+ }
175
+ catch (err) {
176
+ rendererWarn("render-edit: draft list query failed", err);
177
+ }
178
+ })();
179
+ return () => {
180
+ cancelled = true;
181
+ };
182
+ }, [draftEnabled, isCreateMode, draftId, dispatcher, screenId, didListRef, setDraftCandidates]);
183
+ }
184
+ export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled, disabled, dispatcher, draftStorage, controller, sections, draftSaveTimerRef, draftState: { draftId, draftKey, mintedDraftIdRef, setDraftId, setDraftCandidates }, }) {
185
+ // User picked one of several open drafts from the mount-time picker
186
+ // (see draftCandidates below) — adopt it the same way a single
187
+ // auto-adopted candidate would be.
188
+ function adoptDraft(candidate) {
189
+ // Locked state (#1896/fw#1909): `disabled` means "no write possible" —
190
+ // repointing draftKey and patching in the candidate's values is exactly
191
+ // that, so a locked form must not adopt a draft.
192
+ // skip: a locked form keeps its current draft (reason above).
193
+ if (disabled)
194
+ return;
195
+ const adoptedId = candidate.draftKey.slice(newDraftPrefix(screenId).length);
196
+ draftStorage.setDraftId(screenId, adoptedId);
197
+ setDraftId(adoptedId);
198
+ setDraftCandidates(null);
199
+ }
200
+ // Step transitions only — never per keystroke. Deliberately not awaited: a
201
+ // failed draft save must not block the step change.
202
+ //
203
+ // Create-mode, first draft save (step change OR a debounced patch() from
204
+ // controlled mode / extension sections, see patchAndScheduleDraftSave
205
+ // above — both call this on step 0 too): mints the draftId here (issue
206
+ // #1913) rather than at mount, so a form nobody ever interacted with
207
+ // never claims a draftId or writes a row. `draftKey`/`draftId` state
208
+ // won't reflect the mint until the next render, so the just-minted key is
209
+ // computed inline instead of read from the memoized `draftKey`.
210
+ function saveDraft(stepIndex) {
211
+ // skip: this screen does not persist a draft.
212
+ if (!draftEnabled || dispatcher === undefined)
213
+ return;
214
+ let key = draftKey;
215
+ if (isCreateMode && draftId === null) {
216
+ const mintedId = mintDraftId();
217
+ mintedDraftIdRef.current = mintedId;
218
+ draftStorage.setDraftId(screenId, mintedId);
219
+ setDraftId(mintedId);
220
+ key = `${screenId}:new:${mintedId}`;
221
+ // A stale picker from the mount-time list fallback must not survive a
222
+ // mint — picking a candidate afterwards would repoint draftKey at an
223
+ // unrelated draft mid-edit and overwrite it (#1908).
224
+ setDraftCandidates(null);
225
+ }
226
+ // skip: create-mode with a draftId that failed to mint — unreachable
227
+ // in practice (mint above always produces one), kept as a type-level
228
+ // guard against a stale `undefined` key ever reaching the write.
229
+ if (key === undefined)
230
+ return;
231
+ // draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
232
+ // catch it here instead of losing the save silently to the fire-and-forget
233
+ // write below (the user would only notice on resume, with nothing to resume).
234
+ if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
235
+ rendererWarn(`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screenId or entity id.`);
236
+ // skip: warned above, the oversized key would be rejected by the server anyway.
237
+ return;
238
+ }
239
+ const secretFieldNames = new Set(sections.flatMap((section) => section.kind === "fields"
240
+ ? section.fields
241
+ .filter((f) => f.format === "password" || f.writeOnly === true || f.sensitive === true)
242
+ .map((f) => f.field)
243
+ : []));
244
+ // A password, writeOnly or sensitive field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
245
+ const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !secretFieldNames.has(field)));
246
+ void dispatcher
247
+ .write(FORM_DRAFT_SAVE, {
248
+ draftKey: key,
249
+ values: draftValues,
250
+ stepIndex,
251
+ })
252
+ .catch((err) => {
253
+ rendererWarn("render-edit: draft save failed", err);
254
+ });
255
+ }
256
+ async function discardDraft() {
257
+ // skip: this screen does not persist a draft.
258
+ if (!draftEnabled || dispatcher === undefined)
259
+ return;
260
+ // A pending debounced patch-save must not fire after discard — it would
261
+ // resurrect the draft it just deleted.
262
+ if (draftSaveTimerRef.current !== null) {
263
+ clearTimeout(draftSaveTimerRef.current);
264
+ draftSaveTimerRef.current = null;
265
+ }
266
+ // skip: create-mode, no step change happened yet — no draftId was ever
267
+ // minted, so no row exists to discard.
268
+ if (draftKey === undefined)
269
+ return;
270
+ // Best-effort: a rejected write (e.g. network error) must not propagate
271
+ // out of handleSubmit AFTER the entity write already succeeded — that
272
+ // would break onSubmit/navigation/toast and risk a duplicate submit on
273
+ // retry. An orphaned draft row is swept later by cleanup.job.ts. Local
274
+ // cleanup below still runs on failure so this instance doesn't try to
275
+ // resume a draft it already considers submitted.
276
+ try {
277
+ await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
278
+ }
279
+ catch {
280
+ // best-effort, see comment above.
281
+ }
282
+ // A successful submit ends this draftId's life — a subsequent create on
283
+ // the same screen (new mount) must mint its own, not resume this one.
284
+ if (isCreateMode) {
285
+ draftStorage.clearDraftId(screenId);
286
+ mintedDraftIdRef.current = null;
287
+ setDraftId(null);
288
+ }
289
+ }
290
+ return { adoptDraft, saveDraft, discardDraft };
291
+ }
@@ -0,0 +1,3 @@
1
+ export declare function rendererWarn(...args: unknown[]): void;
2
+ export declare function rendererError(...args: unknown[]): void;
3
+ export declare function rendererDebug(...args: unknown[]): void;
@@ -0,0 +1,15 @@
1
+ // The renderer has no logger; the browser console is the developer-facing
2
+ // channel. Resolved at call time (not bound at import) so tests can spy on
3
+ // console.* per test.
4
+ export function rendererWarn(...args) {
5
+ // biome-ignore lint/suspicious/noConsole: renderer has no logger, the browser console is the developer-facing channel
6
+ console.warn(...args);
7
+ }
8
+ export function rendererError(...args) {
9
+ // biome-ignore lint/suspicious/noConsole: renderer has no logger, the browser console is the developer-facing channel
10
+ console.error(...args);
11
+ }
12
+ export function rendererDebug(...args) {
13
+ // biome-ignore lint/suspicious/noConsole: renderer has no logger, the browser console is the developer-facing channel
14
+ console.debug(...args);
15
+ }
@@ -1,21 +1,18 @@
1
- // Primitives-Contract — the minimal set of UI building blocks every
2
- // renderer must provide. A "primitive" is a unit the standard-renderers
3
- // (entityList, entityEdit, etc.) call into without knowing which library
4
- // implements it. On web that's shadcn; on mobile it's react-native-paper;
5
- // both packages (primitives-shadcn, primitives-paper) implement this
6
- // contract and the app wires one of them into the renderer via context.
1
+ // Primitives-Contract — the minimal set of UI building blocks the
2
+ // standard-renderers (entityList, entityEdit, etc.) call into without knowing
3
+ // which implementation provides them. It is the DOM-free seam through which
4
+ // apps override primitives and tests substitute fakes, both via
5
+ // PrimitivesProvider.
7
6
  //
8
- // Why a contract here (in ui-core) instead of in each renderer:
9
- // ui-core's view-model builder and form-controller produce metadata
10
- // (field-type, validation-state, label, placeholder) that the renderer
11
- // translates into primitive invocations. Having the contract shape in
12
- // ui-core means the metadata can carry references to primitives by name
13
- // ("text-input", "select") and the translation is a pure map-lookup on
14
- // the renderer side, not a switch statement that re-derives UI intent
15
- // from entity-schema.
7
+ // Why a contract in headless: the view-model builder and form-controller
8
+ // produce metadata (field-type, validation-state, label, placeholder) that the
9
+ // renderer translates into primitive invocations. Having the contract shape
10
+ // here means the metadata can carry references to primitives by name
11
+ // ("text-input", "select") and the translation is a pure map-lookup on the
12
+ // renderer side, not a switch statement that re-derives UI intent from
13
+ // entity-schema.
16
14
  //
17
15
  // Runtime-free: every primitive is expressed as its input contract only
18
- // (what props it takes). Concrete components live in each primitives-*
19
- // package and depend on React / React Native / etc.; ui-core never
20
- // imports them.
16
+ // (what props it takes). Concrete components live in web/primitives and
17
+ // headless never imports them.
21
18
  export {};
@@ -1,7 +1,8 @@
1
1
  // Pure format utilities — no web or platform dependencies.
2
- // Shared between renderer/web, renderer-native, and server-side tests.
2
+ // Shared between renderer/web and server-side tests.
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
+ import { rendererWarn } from "../../dev-console.js";
5
6
  export const EMPTY_VALUE_PLACEHOLDER = "—";
6
7
  function toPlainDate(raw) {
7
8
  try {
@@ -248,8 +249,7 @@ export function applyFormatSpec(spec, value, translate) {
248
249
  return stringifyJsonOrString(value, typeof spec["indent"] === "number" ? spec["indent"] : 2);
249
250
  default:
250
251
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
251
- // biome-ignore lint/suspicious/noConsole: dev-only warning
252
- console.warn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
252
+ rendererWarn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
253
253
  }
254
254
  return typeof value === "string" ? value : String(value);
255
255
  }
@@ -2,7 +2,7 @@ import type { EditRelatedListSection, EntityDefinition, EntityEditScreenDefiniti
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;
5
- readonly entity: EntityDefinition;
5
+ readonly entity: Pick<EntityDefinition, "fields" | "defaultCurrency">;
6
6
  readonly values: TValues;
7
7
  readonly translate: Translate;
8
8
  readonly featureName: string;
@@ -184,11 +184,12 @@ function deriveTextFieldHints(fieldDef) {
184
184
  ...("sensitive" in fieldDef && fieldDef.sensitive === true && { sensitive: true }),
185
185
  };
186
186
  }
187
+ // files/images field defs carry no `required` property.
188
+ function isRequiredByEntity(fieldDef) {
189
+ return "required" in fieldDef && fieldDef.required === true;
190
+ }
187
191
  function deriveDateFieldHints(fieldDef) {
188
- const wallClock = fieldDef.type === "timestamp" &&
189
- fieldDef.locatedBy !== undefined
190
- ? true
191
- : undefined;
192
+ const wallClock = fieldDef.type === "timestamp" && fieldDef.locatedBy !== undefined ? true : undefined;
192
193
  const dateBounds = fieldDef.type === "date" ||
193
194
  fieldDef.type === "timestamp" ||
194
195
  fieldDef.type === "locatedTimestamp"
@@ -212,9 +213,7 @@ function deriveNumericFieldHints(fieldDef, resolvedCurrency) {
212
213
  // Declared reference metadata (EditFieldSpec.refEntity) takes priority over
213
214
  // fieldDef.type — see computeEditViewModel's effectiveType, same reasoning.
214
215
  function deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName) {
215
- const refRaw = declaredRefTarget === undefined && fieldDef.type === "reference"
216
- ? fieldDef.entity
217
- : undefined;
216
+ const refRaw = declaredRefTarget === undefined && fieldDef.type === "reference" ? fieldDef.entity : undefined;
218
217
  const refTarget = declaredRefTarget ?? (refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined);
219
218
  const refLabelField = declaredRefTarget !== undefined
220
219
  ? (normalized.refLabelField ?? "id")
@@ -232,9 +231,7 @@ function deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, feat
232
231
  // (it targets row-meta/derived fields, always single-valued and resolved
233
232
  // through the target entity) — only a real ReferenceFieldDef carries them.
234
233
  function deriveReferenceMultiHints(fieldDef, declaredRefTarget) {
235
- const ownReferenceDef = declaredRefTarget === undefined && fieldDef.type === "reference"
236
- ? fieldDef
237
- : undefined;
234
+ const ownReferenceDef = declaredRefTarget === undefined && fieldDef.type === "reference" ? fieldDef : undefined;
238
235
  const refMultiple = ownReferenceDef === undefined ? undefined : (ownReferenceDef.multiple ?? false);
239
236
  return {
240
237
  ...(ownReferenceDef?.optionsQuery !== undefined && {
@@ -318,11 +315,7 @@ function buildEmbeddedListCell(subFieldName, subField, translate, featureName, e
318
315
  // apart by whether embeddedListCells is set, not by `type` (which stays
319
316
  // "embedded" either way).
320
317
  function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec) {
321
- const isEmbeddedList = fieldDef.type === "embedded" &&
322
- fieldDef.multiple === true;
323
- const embeddedListDef = isEmbeddedList
324
- ? fieldDef
325
- : undefined;
318
+ const embeddedListDef = fieldDef.type === "embedded" && fieldDef.multiple === true ? fieldDef : undefined;
326
319
  const embeddedListCells = embeddedListDef !== undefined
327
320
  ? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec))
328
321
  : undefined;
@@ -427,7 +420,7 @@ export function computeEditViewModel(input) {
427
420
  // field that's required at the entity-level but marked required:
428
421
  // false on the screen (e.g. a soft-onboarding wizard that
429
422
  // collects less up-front) respects the screen override.
430
- const entityRequired = fieldDef.required === true;
423
+ const entityRequired = isRequiredByEntity(fieldDef);
431
424
  const required = !lockedByDerivation && evalCondition(normalized.required, entityRequired, values);
432
425
  // ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
433
426
  // framework/src/engine/field-helpers.ts — headless has no dependency
@@ -2,7 +2,7 @@ import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/
2
2
  import type { ListViewModel, Translate } from "./types.ts";
3
3
  export type ComputeListViewModelInput = {
4
4
  readonly screen: EntityListScreenDefinition;
5
- readonly entity: EntityDefinition;
5
+ readonly entity: Pick<EntityDefinition, "fields" | "derivedFields">;
6
6
  readonly rows: readonly Readonly<Record<string, unknown>>[];
7
7
  readonly translate: Translate;
8
8
  readonly featureName: string;
@@ -113,15 +113,11 @@ export function computeListViewModel(input) {
113
113
  // sein. parseRefTarget gibt (featureName, entityName), der
114
114
  // Renderer baut die Lookup-QN als
115
115
  // `<refFeature>:query:<refEntity>:list`.
116
- const refRaw = fieldDef.type === "reference"
117
- ? fieldDef.entity
118
- : undefined;
116
+ const refRaw = fieldDef.type === "reference" ? fieldDef.entity : undefined;
119
117
  const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
120
118
  const refEntity = refTarget?.entityName;
121
119
  const refFeature = refTarget?.featureName;
122
- const refLabelField = fieldDef.type === "reference"
123
- ? (fieldDef.labelField ?? "id")
124
- : undefined;
120
+ const refLabelField = fieldDef.type === "reference" ? (fieldDef.labelField ?? "id") : undefined;
125
121
  // For select and multiSelect fields: bake in translated option labels.
126
122
  // Convention matches the form path → one translation source for list +
127
123
  // form. A missing key returns the key itself per convention; the
@@ -224,6 +220,5 @@ export function buildOptionLabels(translate, keyFor, options) {
224
220
  // (ORDER BY safety) and by the UI (column header click indicator). All
225
221
  // field types can bear the flag; it's off by default.
226
222
  function fieldIsSortable(field) {
227
- const flag = field.sortable;
228
- return flag === true;
223
+ return "sortable" in field && field.sortable === true;
229
224
  }
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export type { AppSchema, FeatureSchema, QualifiedContentCollection, WorkspaceSch
23
23
  export { isAppSchema, toAppSchema } from "./app/feature-schema.ts";
24
24
  export { HeaderActionsBar } from "./app/header-actions-bar.tsx";
25
25
  export type { KumikoScreenProps } from "./app/kumiko-screen.tsx";
26
- export { KumikoScreen, qualifyNavId, qualifyScreenId, ScreenMissingState, } from "./app/kumiko-screen.tsx";
26
+ export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen.tsx";
27
27
  export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets.ts";
28
28
  export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.ts";
29
29
  export type { NavApi, NavigateOptions, NavProviderProps, NavRoute, NavTarget, ObjectTarget, ScreenTarget, } from "./app/nav.tsx";
@@ -31,6 +31,7 @@ export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, use
31
31
  export { lastSegment } from "./app/qn.ts";
32
32
  export type { ReturnHost, ReturnTo } from "./app/return-to.tsx";
33
33
  export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.tsx";
34
+ export { ScreenMissingState } from "./app/screen-shared.tsx";
34
35
  export type { EmbeddedScreenTarget } from "./app/use-embedded-screen.ts";
35
36
  export { useEmbeddedScreen } from "./app/use-embedded-screen.ts";
36
37
  export { useRecordActions } from "./app/use-record-actions.ts";
package/dist/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // Platform-agnostic React renderer for Kumiko screens. Das ist der
2
- // Shared-Layer: Components, Hooks, Contexts, Types. Plattform-Impls
3
- // (Web-DOM, React-Native) leben in den jeweiligen Plattform-Packages
4
- // und reichen ihre konkreten Primitives/Nav/SSE-Impls via Provider
5
- // in diesen Baum.
2
+ // Shared-Layer: Components, Hooks, Contexts, Types. Die Web-DOM-Impls
3
+ // leben unter ./web und reichen ihre konkreten Primitives/Nav/SSE-Impls via
4
+ // Provider in diesen Baum.
6
5
  //
7
6
  // Direct consumers of this layer: platform bindings
8
7
  // (@cosmicdrift/kumiko-renderer/web) or an app writing its own bootstrap.
@@ -22,11 +21,12 @@ export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/ex
22
21
  export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.js";
23
22
  export { isAppSchema, toAppSchema } from "./app/feature-schema.js";
24
23
  export { HeaderActionsBar } from "./app/header-actions-bar.js";
25
- export { KumikoScreen, qualifyNavId, qualifyScreenId, ScreenMissingState, } from "./app/kumiko-screen.js";
24
+ export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen.js";
26
25
  export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.js";
27
26
  export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, useOptionalNav, } from "./app/nav.js";
28
27
  export { lastSegment } from "./app/qn.js";
29
28
  export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.js";
29
+ export { ScreenMissingState } from "./app/screen-shared.js";
30
30
  export { useEmbeddedScreen } from "./app/use-embedded-screen.js";
31
31
  export { useRecordActions } from "./app/use-record-actions.js";
32
32
  export { VariableChips } from "./app/variable-chips.js";
@@ -1,4 +1,5 @@
1
1
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { rendererWarn } from "../dev-console.js";
2
3
  // Narrow cast into the DispatcherError shape. Pass-through for everything
3
4
  // except `details.fields`, which we normalize: the server uses
4
5
  // ValidationFieldIssue (path/code/i18nKey/params), which is structurally
@@ -33,16 +34,14 @@ function normalizeDetails(details) {
33
34
  // Server sent a non-object entry in details.fields — contract
34
35
  // breach (ValidationFieldIssue shape is required). Warn so the
35
36
  // skip doesn't hide a broken server build.
36
- // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
37
- console.warn("[dispatcher-live] dropping malformed field issue (not an object):", r);
37
+ rendererWarn("[dispatcher-live] dropping malformed field issue (not an object):", r);
38
38
  continue;
39
39
  }
40
40
  if (typeof r["path"] !== "string" ||
41
41
  typeof r["code"] !== "string" ||
42
42
  typeof r["i18nKey"] !== "string") {
43
43
  // Entry is an object but missing required keys — same reasoning.
44
- // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
45
- console.warn("[dispatcher-live] dropping malformed field issue (missing keys):", r);
44
+ rendererWarn("[dispatcher-live] dropping malformed field issue (missing keys):", r);
46
45
  continue;
47
46
  }
48
47
  mappedFields.push({
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
+ import { rendererError, rendererWarn } from "../../dev-console.js";
5
6
  import { AppFeaturesProvider, ClientActionsProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, createSessionEndedSignal, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ErrorBoundary, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LeaveGuardDialog, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, ScreenMissingState, SessionEndedSignalProvider, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, withSessionEndedDetection, } from "../../index.js";
6
7
  import { createLiveDispatcher } from "../../live/index.js";
7
8
  import { lastSegment } from "../layout/nav-tree.js";
@@ -65,8 +66,7 @@ export function buildNavProviderMaps(clientFeatures, collections) {
65
66
  for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
66
67
  const qn = qualifyNavProviderKey(f.name, navId);
67
68
  if (navProviders.has(qn) && !derivedQns.delete(qn)) {
68
- // biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
69
- console.warn(`[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`);
69
+ rendererWarn(`[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`);
70
70
  }
71
71
  navProviders.set(qn, provider);
72
72
  }
@@ -160,8 +160,7 @@ function mergeByKey(clientFeatures, pick, label) {
160
160
  continue;
161
161
  for (const [key, value] of Object.entries(entries)) {
162
162
  if (merged[key] !== undefined) {
163
- // biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
164
- console.warn(`[kumiko] ${label} "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`);
163
+ rendererWarn(`[kumiko] ${label} "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`);
165
164
  }
166
165
  merged[key] = value;
167
166
  }
@@ -195,8 +194,7 @@ function warnMissingCustomScreens(app, customScreens) {
195
194
  .filter((s) => s.type === "custom" && s.dormant !== true && customScreens[s.id] === undefined)
196
195
  .map((s) => `${f.featureName}:${s.id}`));
197
196
  if (missingCustomScreens.length > 0) {
198
- // biome-ignore lint/suspicious/noConsole: dev-only diagnostic for missing client plugins
199
- console.error(`[kumiko] ${missingCustomScreens.length} custom screen(s) have no registered component in clientFeatures.components — they render an error placeholder instead of the intended UI: ${missingCustomScreens.join(", ")}. Add the feature's web client plugin to clientFeatures.`);
197
+ rendererError(`[kumiko] ${missingCustomScreens.length} custom screen(s) have no registered component in clientFeatures.components — they render an error placeholder instead of the intended UI: ${missingCustomScreens.join(", ")}. Add the feature's web client plugin to clientFeatures.`);
200
198
  }
201
199
  }
202
200
  // Everything schema-INdependent (dispatcher, providers, gates, merged
@@ -331,8 +329,7 @@ export function createKumikoApp(options = {}) {
331
329
  continue;
332
330
  for (const [key, component] of Object.entries(f.resolvers)) {
333
331
  if (resolvers.has(key)) {
334
- // biome-ignore lint/suspicious/noConsole: client-bundle has no logger; collision is a dev-time warning that must surface in the browser DevTools.
335
- console.warn(`[kumiko] resolver "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`);
332
+ rendererWarn(`[kumiko] resolver "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`);
336
333
  }
337
334
  resolvers.set(key, component);
338
335
  }