@cosmicdrift/kumiko-renderer 0.2.3 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,110 @@
1
1
  # @cosmicdrift/kumiko-renderer
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
8
+
9
+ **V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
10
+ kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
11
+ (ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
12
+ erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
13
+ Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
14
+
15
+ **V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
16
+ stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
17
+ Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
18
+ Pfad (legacy bleibt für Test-Hooks).
19
+
20
+ **V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
21
+ Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
22
+ Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
23
+ flippt nach save automatisch.
24
+
25
+ **V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
26
+ VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
27
+ guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
28
+ Verschachtelung) und text-content ("📁 Content").
29
+
30
+ **V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
31
+ walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
32
+ treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
33
+
34
+ 35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
35
+ Browser + Keyboard lokal validated.
36
+
37
+ **Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
38
+ session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
39
+
40
+ **V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
41
+ Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
42
+ file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
43
+
44
+ ### Patch Changes
45
+
46
+ - Updated dependencies [825e7d2]
47
+ - @cosmicdrift/kumiko-framework@0.4.0
48
+ - @cosmicdrift/kumiko-headless@0.4.0
49
+
50
+ ## 0.3.0
51
+
52
+ ### Minor Changes
53
+
54
+ - 0.3.0 bringt zwei neue Subsysteme (Step-Engine Tier-3 + Visual-Tree) plus
55
+ eine AST-Codemod-Pipeline als Vorarbeit für den L2-AI-Layer.
56
+
57
+ ### Breaking Changes
58
+
59
+ - `skipTransitionGuard` → `unsafeSkipTransitionGuard` (Rename in
60
+ feature-ast + engine). Der `unsafe`-Prefix macht die Tragweite des
61
+ Casts sichtbar und ist konsistent zur `unsafeProjectionUpsert`- und
62
+ `r.rawTable`-Konvention. Migration: 1:1-Ersetzung, keine Verhaltens-Änderung.
63
+
64
+ ### Features
65
+
66
+ - **Step-Engine M.4 — Tier-3 Workflow-Engine.** Neue Step-Vocabulary
67
+ `wait`, `waitForEvent`, `retry` ermöglicht persistierte Long-Running-Flows
68
+ über Job-Boundaries hinweg. Q7 Snapshot-at-Start hängt jedem Step-Run
69
+ einen SHA-256-Fingerprint des Aggregat-Zustands an, sodass Replays
70
+ deterministisch gegen den ursprünglichen Eingangszustand laufen.
71
+ - **Visual-Tree V.1.x — Tree-API + Editor-Panel.** Neue `VisualTree`-
72
+ Component plus TreeProvider-Pattern; erste TreeProviders für
73
+ `text-content` und `legal-pages` (CMS-light + Impressum/Privacy).
74
+ Fundament für den späteren No-Code-Designer (~3000 LOC, 98 Tests).
75
+ - **Codemod-Pipeline.** AST-basierte Patcher-Module für strukturelle
76
+ Feature-Edits — wird vom kommenden L2-AI-Layer als Tool-Surface
77
+ verwendet, ist aber eigenständig nutzbar für ts-morph-style Migrationen.
78
+ - **user-data-rights Sample-Recipe.** DSGVO Art. 15/17/18/20 vollständig
79
+ als Sample-Recipe (`samples/recipes/`) inklusive README — zeigt die
80
+ Export- und Forget-Pipeline gegen den `compliance-profiles`-Default
81
+ (`eu-dsgvo`).
82
+
83
+ ### Fixes
84
+
85
+ - `tier-engine`: auto-default-tier-Hook benutzt jetzt `ctx.db.raw` für
86
+ Event-Store-Operationen (#37, vorher: stiller Bug, 22 Tage live).
87
+ - `engine`: unsafe-projection-upsert nutzt `as never` statt `as any` —
88
+ schmaler Cast-Surface, weniger Compiler-Knebel.
89
+ - `visual-tree`: runtime-isolation marker für client-konsumierte Files,
90
+ damit der Multi-Entry-Build den richtigen Bundle-Split bekommt.
91
+ - `feature-ast`: vollständiger `unsafeSkipTransitionGuard`-Rename (war
92
+ in zwei Modulen noch der alte Name).
93
+ - `framework`: Error-Reasons + `noConsole`-Lint + No-Date-API-Guard
94
+ wieder push-ready.
95
+
96
+ ### Library-Updates
97
+
98
+ hono 4.12, jose 6.2, stripe 22.1, meilisearch 0.58, marked 18,
99
+ bun-types 1.3.13, lucide-react 1.14, bullmq 5.76, ioredis 5.10,
100
+ i18next 26.0, react + radix-ui-primitives auf aktuelle Minors.
101
+
102
+ ### Patch Changes
103
+
104
+ - Updated dependencies
105
+ - @cosmicdrift/kumiko-framework@0.3.0
106
+ - @cosmicdrift/kumiko-headless@0.3.0
107
+
3
108
  ## 0.2.3
4
109
 
5
110
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -9,17 +9,20 @@
9
9
  "runtime": "client"
10
10
  },
11
11
  "exports": {
12
- ".": "./src/index.ts"
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ }
13
16
  },
14
17
  "dependencies": {
15
- "@cosmicdrift/kumiko-framework": "0.2.3",
16
- "@cosmicdrift/kumiko-headless": "0.2.3",
17
- "react": "^19.2.0"
18
+ "@cosmicdrift/kumiko-framework": "0.4.0",
19
+ "@cosmicdrift/kumiko-headless": "0.4.0",
20
+ "react": "^19.2.6"
18
21
  },
19
22
  "devDependencies": {
20
- "@testing-library/react": "^16.3.0",
21
- "@types/react": "^19.2.0",
22
- "jsdom": "^29.1.0"
23
+ "@testing-library/react": "^16.3.2",
24
+ "@types/react": "^19.2.14",
25
+ "jsdom": "^29.1.1"
23
26
  },
24
27
  "repository": {
25
28
  "type": "git",
@@ -17,16 +17,16 @@ function makeStatefulResolver(initial: string): LocaleResolver {
17
17
  let current = initial;
18
18
  const listeners = new Set<() => void>();
19
19
  return {
20
- translate: (key) => key,
20
+ translate: (key: string) => key,
21
21
  locale: () => current,
22
22
  timeZone: () => "UTC",
23
- subscribe: (l) => {
23
+ subscribe: (l: () => void) => {
24
24
  listeners.add(l);
25
25
  return () => {
26
26
  listeners.delete(l);
27
27
  };
28
28
  },
29
- setLocale: (next) => {
29
+ setLocale: (next: string) => {
30
30
  current = next;
31
31
  for (const l of listeners) l();
32
32
  },
@@ -45,7 +45,7 @@ describe("useTranslation — lookup order", () => {
45
45
  test("App-Resolver wins when it returns a non-key value", () => {
46
46
  const resolver: LocaleResolver = {
47
47
  ...createStaticLocaleResolver({ locale: "de" }),
48
- translate: (key) => (key === "hello" ? "Resolved by app" : key),
48
+ translate: (key: string) => (key === "hello" ? "Resolved by app" : key),
49
49
  };
50
50
  const { result } = renderHook(() => useTranslation(), { wrapper: wrap(resolver) });
51
51
  expect(result.current("hello")).toBe("Resolved by app");
@@ -4,7 +4,10 @@ import type {
4
4
  EntityDefinition,
5
5
  EntityEditScreenDefinition,
6
6
  EntityListScreenDefinition,
7
+ RowAction,
8
+ RowActionWriteHandler,
7
9
  ScreenDefinition,
10
+ ToolbarAction,
8
11
  } from "@cosmicdrift/kumiko-framework/ui-types";
9
12
  import type {
10
13
  Command,
@@ -16,12 +19,12 @@ import type {
16
19
  } from "@cosmicdrift/kumiko-headless";
17
20
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
18
21
  import { RenderEdit } from "../components/render-edit";
19
- import { RenderList } from "../components/render-list";
22
+ import { RenderList, type ToolbarActionButton } from "../components/render-list";
20
23
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
21
24
  import { useListUrlState } from "../hooks/use-list-url-state";
22
25
  import { useQuery } from "../hooks/use-query";
23
26
  import { useTranslation } from "../i18n";
24
- import { usePrimitives } from "../primitives";
27
+ import { type DataTableRowAction, usePrimitives } from "../primitives";
25
28
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
26
29
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
27
30
  import { useCustomScreenComponent } from "./custom-screens";
@@ -82,7 +85,10 @@ export function KumikoScreen({
82
85
  }: KumikoScreenProps): ReactNode {
83
86
  const { Banner, Text } = usePrimitives();
84
87
  const screen = useMemo(
85
- () => schema.screens.find((s) => qualifyScreenId(schema.featureName, s.id) === qn),
88
+ () =>
89
+ schema.screens.find(
90
+ (s: ScreenDefinition) => qualifyScreenId(schema.featureName, s.id) === qn,
91
+ ),
86
92
  [schema.featureName, schema.screens, qn],
87
93
  );
88
94
 
@@ -162,7 +168,9 @@ function entityWriteCommand(
162
168
  function useNavigateToListAfter(schema: FeatureSchema, entityName: string): () => void {
163
169
  const nav = useNav();
164
170
  return useCallback(() => {
165
- const list = schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
171
+ const list = schema.screens.find(
172
+ (s: ScreenDefinition) => s.type === "entityList" && s.entity === entityName,
173
+ );
166
174
  if (!list) return;
167
175
  // schema.screens.id ist QN-form (registry-stamped); nav.navigate
168
176
  // erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
@@ -184,7 +192,9 @@ function useNavigateToCreateFor(
184
192
  ): (() => void) | undefined {
185
193
  const nav = useNav();
186
194
  const editScreenId = useMemo(() => {
187
- const edit = schema.screens.find((s) => s.type === "entityEdit" && s.entity === entityName);
195
+ const edit = schema.screens.find(
196
+ (s: ScreenDefinition) => s.type === "entityEdit" && s.entity === entityName,
197
+ );
188
198
  return edit !== undefined ? lastSegment(edit.id) : undefined;
189
199
  }, [schema.screens, entityName]);
190
200
  const navigate = useCallback(() => {
@@ -643,7 +653,7 @@ function EntityListBody({
643
653
  const rowActions = useMemo(() => {
644
654
  if (screen.rowActions === undefined) return undefined;
645
655
  return screen.rowActions
646
- .map((action) => {
656
+ .map((action: RowAction): DataTableRowAction | null => {
647
657
  // navigate-Variante braucht keinen Dispatcher; nav ist
648
658
  // immer da (Provider von createKumikoApp).
649
659
  if (action.kind === "navigate") {
@@ -671,31 +681,32 @@ function EntityListBody({
671
681
  }),
672
682
  };
673
683
  }
674
- // writeHandler-Variante (default kind, Backwards-Compat).
675
- // Braucht Dispatcher — null returnen → filter unten dropt es,
676
- // damit das useEffect-Warning oben einmal feuert + die Action
677
- // einfach nicht rendert (statt Crash).
678
684
  if (dispatcher === undefined) return null;
685
+ if (action.kind !== "writeHandler" && action.kind !== undefined) return null;
686
+ const writeAction = action as RowActionWriteHandler;
679
687
  return {
680
- id: action.id,
681
- label: effectiveTranslate(action.label),
682
- ...(action.style !== undefined && { style: action.style }),
683
- ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
684
- ...(action.confirmLabel !== undefined && {
685
- confirmLabel: effectiveTranslate(action.confirmLabel),
686
- }),
688
+ id: writeAction.id,
689
+ label: effectiveTranslate(writeAction.label),
690
+ style: writeAction.style,
691
+ confirm:
692
+ writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
693
+ confirmLabel:
694
+ writeAction.confirmLabel !== undefined
695
+ ? effectiveTranslate(writeAction.confirmLabel)
696
+ : undefined,
687
697
  onTrigger: async (row: ListRowViewModel) => {
688
- const buildPayload = action.payload;
698
+ const buildPayload = writeAction.payload;
689
699
  const payload =
690
700
  buildPayload !== undefined ? buildPayload(row.values) : { id: row.values["id"] };
691
- await dispatcher.write(action.handler, payload);
701
+ await dispatcher.write(writeAction.handler, payload);
692
702
  },
693
- ...(action.visible !== undefined && {
694
- isVisible: (row: ListRowViewModel) => action.visible?.(row.values, undefined) ?? true,
695
- }),
703
+ isVisible:
704
+ writeAction.visible !== undefined
705
+ ? (row: ListRowViewModel) => writeAction.visible?.(row.values, undefined) ?? true
706
+ : undefined,
696
707
  };
697
708
  })
698
- .filter((a): a is NonNullable<typeof a> => a !== null);
709
+ .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
699
710
  }, [screen.rowActions, effectiveTranslate, dispatcher, nav]);
700
711
 
701
712
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
@@ -705,45 +716,34 @@ function EntityListBody({
705
716
  const toolbarActions = useMemo(() => {
706
717
  if (screen.toolbarActions === undefined) return undefined;
707
718
  return screen.toolbarActions
708
- .map(
709
- (
710
- action,
711
- ): {
712
- id: string;
713
- label: string;
714
- style?: "primary" | "secondary" | "danger";
715
- confirm?: string;
716
- confirmLabel?: string;
717
- onTrigger: () => Promise<void> | void;
718
- } | null => {
719
- if (action.kind === "navigate") {
720
- return {
721
- id: action.id,
722
- label: effectiveTranslate(action.label),
723
- ...(action.style !== undefined && { style: action.style }),
724
- onTrigger: () => nav.navigate({ screenId: action.screen }),
725
- };
726
- }
727
- // writeHandler — braucht Dispatcher. Wenn keiner mounted ist,
728
- // skippen wir die Action statt zu crashen (gleiche Logik wie
729
- // bei rowActions; einmaliger Warn-Log dort reicht).
730
- if (dispatcher === undefined) return null;
719
+ .map((action: ToolbarAction): ToolbarActionButton | null => {
720
+ if (action.kind === "navigate") {
731
721
  return {
732
722
  id: action.id,
733
723
  label: effectiveTranslate(action.label),
734
724
  ...(action.style !== undefined && { style: action.style }),
735
- ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
736
- ...(action.confirmLabel !== undefined && {
737
- confirmLabel: effectiveTranslate(action.confirmLabel),
738
- }),
739
- onTrigger: async () => {
740
- const payload = action.payload?.() ?? {};
741
- await dispatcher.write(action.handler, payload);
742
- },
725
+ onTrigger: () => nav.navigate({ screenId: action.screen }),
743
726
  };
744
- },
745
- )
746
- .filter((a): a is NonNullable<typeof a> => a !== null);
727
+ }
728
+ // writeHandler — braucht Dispatcher. Wenn keiner mounted ist,
729
+ // skippen wir die Action statt zu crashen (gleiche Logik wie
730
+ // bei rowActions; einmaliger Warn-Log dort reicht).
731
+ if (dispatcher === undefined) return null;
732
+ return {
733
+ id: action.id,
734
+ label: effectiveTranslate(action.label),
735
+ ...(action.style !== undefined && { style: action.style }),
736
+ ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
737
+ ...(action.confirmLabel !== undefined && {
738
+ confirmLabel: effectiveTranslate(action.confirmLabel),
739
+ }),
740
+ onTrigger: async () => {
741
+ const payload = action.payload?.() ?? {};
742
+ await dispatcher.write(action.handler, payload);
743
+ },
744
+ };
745
+ })
746
+ .filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
747
747
  }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
748
748
 
749
749
  if (rowsQuery.loading && rowsQuery.data === null) {
@@ -6,6 +6,7 @@ import { normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import type {
7
7
  DispatcherError,
8
8
  EditFieldViewModel,
9
+ EditSectionViewModel,
9
10
  FieldConditions,
10
11
  FieldIssue,
11
12
  FormSnapshot,
@@ -252,10 +253,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
252
253
  actions={formActions}
253
254
  testId="render-edit-form"
254
255
  >
255
- {vm.sections.map((section) => (
256
+ {vm.sections.map((section: EditSectionViewModel) => (
256
257
  <Section key={section.title} title={section.title} testId={`section-${section.title}`}>
257
258
  <Grid columns={section.columns}>
258
- {section.fields.map((field) => (
259
+ {section.fields.map((field: EditFieldViewModel) => (
259
260
  <GridCellForField
260
261
  key={field.field}
261
262
  field={field}
@@ -249,7 +249,7 @@ function renderInput({
249
249
  const labels = field.optionLabels;
250
250
  const selectOptions =
251
251
  labels !== undefined
252
- ? rawOptions.map((value) => ({ value, label: labels[value] ?? value }))
252
+ ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
253
253
  : rawOptions;
254
254
  return (
255
255
  <Input
@@ -3,7 +3,11 @@ import type {
3
3
  EntityDefinition,
4
4
  EntityListScreenDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
6
+ import type {
7
+ ListColumnViewModel,
8
+ ListRowViewModel,
9
+ Translate,
10
+ } from "@cosmicdrift/kumiko-headless";
7
11
  import { computeListViewModel } from "@cosmicdrift/kumiko-headless";
8
12
  import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
9
13
  import type { ListSort } from "../hooks/use-list-url-state";
@@ -160,8 +164,8 @@ export function RenderList(props: RenderListProps): ReactNode {
160
164
  const referenceColumns = useMemo(
161
165
  () =>
162
166
  vm.columns
163
- .filter((c) => c.type === "reference" && c.refEntity !== undefined)
164
- .map((c) => ({
167
+ .filter((c: ListColumnViewModel) => c.type === "reference" && c.refEntity !== undefined)
168
+ .map((c: ListColumnViewModel) => ({
165
169
  field: c.field,
166
170
  refEntity: c.refEntity ?? "",
167
171
  // Tier 2.7e Cross-Feature: refFeature kommt aus parseRefTarget
@@ -183,7 +187,7 @@ export function RenderList(props: RenderListProps): ReactNode {
183
187
  }, []);
184
188
  const enrichedColumns = useMemo(() => {
185
189
  if (referenceColumns.length === 0) return vm.columns;
186
- return vm.columns.map((col) => {
190
+ return vm.columns.map((col: ListColumnViewModel) => {
187
191
  if (col.type !== "reference") return col;
188
192
  // Author-deklarierter Renderer übersteuert immer — Default greift
189
193
  // nur wenn keiner gesetzt ist.
@@ -284,16 +288,18 @@ export function RenderList(props: RenderListProps): ReactNode {
284
288
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
285
289
  return (
286
290
  <>
287
- {referenceColumns.map((rc) => (
288
- <ReferenceLookupBridge
289
- key={rc.field}
290
- field={rc.field}
291
- refEntity={rc.refEntity}
292
- labelField={rc.labelField}
293
- featureName={rc.refFeature}
294
- onMap={handleLookupMap}
295
- />
296
- ))}
291
+ {referenceColumns.map(
292
+ (rc: { field: string; refEntity: string; refFeature: string; labelField: string }) => (
293
+ <ReferenceLookupBridge
294
+ key={rc.field}
295
+ field={rc.field}
296
+ refEntity={rc.refEntity}
297
+ labelField={rc.labelField}
298
+ featureName={rc.refFeature}
299
+ onMap={handleLookupMap}
300
+ />
301
+ ),
302
+ )}
297
303
  <DataTable
298
304
  columns={enrichedVm.columns}
299
305
  rows={enrichedVm.rows}
package/src/i18n.tsx CHANGED
@@ -148,7 +148,7 @@ export function createStaticLocaleResolver(
148
148
  const locale = options.locale ?? "en";
149
149
  const timeZone = options.timeZone ?? "UTC";
150
150
  return {
151
- translate: (key) => key,
151
+ translate: (key: string) => key,
152
152
  locale: () => locale,
153
153
  timeZone: () => timeZone,
154
154
  // No-op subscribe: unsere Locale ist statisch, es gibt nie ein