@cosmicdrift/kumiko-renderer 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-renderer",
3
+ "version": "0.1.0",
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
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "type": "module",
8
+ "kumiko": {
9
+ "runtime": "client"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts"
13
+ },
14
+ "dependencies": {
15
+ "@cosmicdrift/kumiko-framework": "workspace:*",
16
+ "@cosmicdrift/kumiko-headless": "workspace:*",
17
+ "react": "^19.2.0"
18
+ },
19
+ "devDependencies": {
20
+ "@testing-library/react": "^16.3.0",
21
+ "@types/react": "^19.2.0",
22
+ "jsdom": "^29.1.0"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
27
+ "directory": "packages/renderer"
28
+ },
29
+ "homepage": "https://kumiko.so",
30
+ "bugs": {
31
+ "url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
32
+ },
33
+ "publishConfig": {
34
+ "registry": "https://registry.npmjs.org",
35
+ "access": "public"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE"
41
+ ]
42
+ }
@@ -0,0 +1,127 @@
1
+ // @vitest-environment jsdom
2
+ import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
3
+ import { act, render, renderHook } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { describe, expect, test } from "vitest";
6
+ import {
7
+ createStaticLocaleResolver,
8
+ LocaleProvider,
9
+ type TranslationsByLocale,
10
+ useLocale,
11
+ useTranslation,
12
+ } from "../i18n";
13
+
14
+ // Stateful resolver fixture: we drive locale changes with setState
15
+ // and the test asserts re-render via subscribe.
16
+ function makeStatefulResolver(initial: string): LocaleResolver {
17
+ let current = initial;
18
+ const listeners = new Set<() => void>();
19
+ return {
20
+ translate: (key) => key,
21
+ locale: () => current,
22
+ timeZone: () => "UTC",
23
+ subscribe: (l) => {
24
+ listeners.add(l);
25
+ return () => {
26
+ listeners.delete(l);
27
+ };
28
+ },
29
+ setLocale: (next) => {
30
+ current = next;
31
+ for (const l of listeners) l();
32
+ },
33
+ };
34
+ }
35
+
36
+ const wrap =
37
+ (resolver: LocaleResolver, fallbackBundles?: TranslationsByLocale[]) =>
38
+ ({ children }: { readonly children: ReactNode }): ReactNode => (
39
+ <LocaleProvider resolver={resolver} {...(fallbackBundles !== undefined && { fallbackBundles })}>
40
+ {children}
41
+ </LocaleProvider>
42
+ );
43
+
44
+ describe("useTranslation — lookup order", () => {
45
+ test("App-Resolver wins when it returns a non-key value", () => {
46
+ const resolver: LocaleResolver = {
47
+ ...createStaticLocaleResolver({ locale: "de" }),
48
+ translate: (key) => (key === "hello" ? "Resolved by app" : key),
49
+ };
50
+ const { result } = renderHook(() => useTranslation(), { wrapper: wrap(resolver) });
51
+ expect(result.current("hello")).toBe("Resolved by app");
52
+ });
53
+
54
+ test("falls back to plugin-bundle for current locale", () => {
55
+ const resolver = createStaticLocaleResolver({ locale: "de" });
56
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
57
+ const { result } = renderHook(() => useTranslation(), {
58
+ wrapper: wrap(resolver, bundles),
59
+ });
60
+ expect(result.current("greet")).toBe("Hallo");
61
+ });
62
+
63
+ test("strips region for bundle lookup (de-AT → de)", () => {
64
+ const resolver = createStaticLocaleResolver({ locale: "de-AT" });
65
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" } }];
66
+ const { result } = renderHook(() => useTranslation(), {
67
+ wrapper: wrap(resolver, bundles),
68
+ });
69
+ expect(result.current("greet")).toBe("Hallo");
70
+ });
71
+
72
+ test("falls back to fallbackLocale (en) when current locale missing", () => {
73
+ const resolver = createStaticLocaleResolver({ locale: "fr" });
74
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
75
+ const { result } = renderHook(() => useTranslation(), {
76
+ wrapper: wrap(resolver, bundles),
77
+ });
78
+ expect(result.current("greet")).toBe("Hello");
79
+ });
80
+
81
+ test("returns key as-is when nothing resolves", () => {
82
+ const resolver = createStaticLocaleResolver({ locale: "de" });
83
+ const { result } = renderHook(() => useTranslation(), { wrapper: wrap(resolver) });
84
+ expect(result.current("missing.key")).toBe("missing.key");
85
+ });
86
+
87
+ test("interpolates {param}-placeholders from params arg", () => {
88
+ const resolver = createStaticLocaleResolver({ locale: "de" });
89
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo {name}!" } }];
90
+ const { result } = renderHook(() => useTranslation(), {
91
+ wrapper: wrap(resolver, bundles),
92
+ });
93
+ expect(result.current("greet", { name: "Marc" })).toBe("Hallo Marc!");
94
+ });
95
+ });
96
+
97
+ describe("useTranslation — re-render on locale change", () => {
98
+ test("subscribe fires when setLocale runs, hook returns new value", () => {
99
+ const resolver = makeStatefulResolver("de");
100
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
101
+
102
+ function Probe(): ReactNode {
103
+ const t = useTranslation();
104
+ return <span data-testid="msg">{t("greet")}</span>;
105
+ }
106
+
107
+ const { getByTestId } = render(
108
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
109
+ <Probe />
110
+ </LocaleProvider>,
111
+ );
112
+ expect(getByTestId("msg").textContent).toBe("Hallo");
113
+
114
+ act(() => {
115
+ resolver.setLocale?.("en");
116
+ });
117
+ expect(getByTestId("msg").textContent).toBe("Hello");
118
+ });
119
+ });
120
+
121
+ describe("useLocale", () => {
122
+ test("returns the resolver", () => {
123
+ const resolver = createStaticLocaleResolver({ locale: "de" });
124
+ const { result } = renderHook(() => useLocale(), { wrapper: wrap(resolver) });
125
+ expect(result.current.locale()).toBe("de");
126
+ });
127
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { lastSegment } from "../app/qn";
3
+
4
+ describe("lastSegment", () => {
5
+ test("strips feature-prefix from screen-QN", () => {
6
+ expect(lastSegment("publicstatus:screen:component-edit")).toBe("component-edit");
7
+ });
8
+
9
+ test("strips feature-prefix from nav-QN", () => {
10
+ expect(lastSegment("shop:nav:catalog")).toBe("catalog");
11
+ });
12
+
13
+ test("strips feature-prefix from workspace-QN", () => {
14
+ expect(lastSegment("admin:workspace:disposition")).toBe("disposition");
15
+ });
16
+
17
+ test("returns short-form input unchanged", () => {
18
+ // Defensive default — an Author who already passes a short id
19
+ // shouldn't get a misformatted result.
20
+ expect(lastSegment("component-edit")).toBe("component-edit");
21
+ });
22
+
23
+ test("strips only the LAST segment, not the first", () => {
24
+ // The QN convention is `<feature>:<kind>:<short-id>`. lastIndexOf
25
+ // ensures kebab-ids that themselves contain colons (defensive —
26
+ // kebab spec rejects them, but the helper shouldn't break).
27
+ expect(lastSegment("a:b:c:d")).toBe("d");
28
+ });
29
+
30
+ test("handles empty string", () => {
31
+ expect(lastSegment("")).toBe("");
32
+ });
33
+
34
+ test("handles trailing colon as empty segment", () => {
35
+ // E.g. when registry stamping is buggy and writes `feature:screen:`
36
+ // — the helper returns "" rather than throwing, the caller's
37
+ // navigate-then-not-found banner makes the bug visible.
38
+ expect(lastSegment("publicstatus:screen:")).toBe("");
39
+ });
40
+ });
@@ -0,0 +1,161 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // useListUrlState pinnt den URL-State-Vertrag pro Screen-ID-Namespace:
4
+ // `?<screenId>.sort=…&<screenId>.dir=…&<screenId>.q=…&<screenId>.page=…`.
5
+ // Zwei Listen auf der Route teilen sich die URL ohne Param-Konflikt.
6
+ // Page wird bei Sort/Filter-Wechsel reseted; Sort bleibt bei Search.
7
+
8
+ import { act, renderHook } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { describe, expect, test, vi } from "vitest";
11
+ import type { NavApi } from "../app/nav";
12
+ import { NavProvider } from "../app/nav";
13
+ import { useListUrlState } from "../hooks/use-list-url-state";
14
+
15
+ function makeNav(initial: Record<string, string> = {}): NavApi & {
16
+ readonly current: { params: Record<string, string> };
17
+ readonly captures: Array<Record<string, string | null>>;
18
+ } {
19
+ const params: Record<string, string> = { ...initial };
20
+ const captures: Array<Record<string, string | null>> = [];
21
+ const api: NavApi & {
22
+ current: { params: Record<string, string> };
23
+ captures: Array<Record<string, string | null>>;
24
+ } = {
25
+ route: undefined,
26
+ navigate: vi.fn(),
27
+ replace: vi.fn(),
28
+ hrefFor: () => "",
29
+ get searchParams() {
30
+ return params;
31
+ },
32
+ setSearchParams: (updates) => {
33
+ captures.push(updates);
34
+ for (const [k, v] of Object.entries(updates)) {
35
+ if (v === null) delete params[k];
36
+ else params[k] = v;
37
+ }
38
+ },
39
+ current: { params },
40
+ captures,
41
+ };
42
+ return api;
43
+ }
44
+
45
+ function wrapper(nav: NavApi): (props: { children: ReactNode }) => ReactNode {
46
+ return ({ children }) => <NavProvider value={nav}>{children}</NavProvider>;
47
+ }
48
+
49
+ describe("useListUrlState", () => {
50
+ test("Default-State: kein URL-Param → sort=null, q='', page=1", () => {
51
+ const nav = makeNav();
52
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
53
+ expect(result.current.sort).toBeNull();
54
+ expect(result.current.q).toBe("");
55
+ expect(result.current.page).toBe(1);
56
+ });
57
+
58
+ test("liest sort+dir aus URL-Params (mit screenId-Prefix)", () => {
59
+ const nav = makeNav({ "orders.sort": "createdAt", "orders.dir": "desc" });
60
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
61
+ expect(result.current.sort).toEqual({ field: "createdAt", dir: "desc" });
62
+ });
63
+
64
+ test("ignoriert sort einer ANDEREN Liste (Namespacing)", () => {
65
+ const nav = makeNav({ "incidents.sort": "severity", "incidents.dir": "asc" });
66
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
67
+ expect(result.current.sort).toBeNull();
68
+ });
69
+
70
+ test("invalid dir (z.B. 'foo') → sort=null (defensive parse)", () => {
71
+ const nav = makeNav({ "orders.sort": "name", "orders.dir": "foo" });
72
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
73
+ expect(result.current.sort).toBeNull();
74
+ });
75
+
76
+ test("setSort schreibt sort+dir, resettet page (atomic update)", () => {
77
+ const nav = makeNav({ "orders.page": "5" });
78
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
79
+ act(() => {
80
+ result.current.setSort({ field: "name", dir: "asc" });
81
+ });
82
+ expect(nav.captures).toHaveLength(1);
83
+ expect(nav.captures[0]).toEqual({
84
+ "orders.sort": "name",
85
+ "orders.dir": "asc",
86
+ "orders.page": null, // page-reset bei sort-change
87
+ });
88
+ });
89
+
90
+ test("setSort(null) löscht sort+dir+page", () => {
91
+ const nav = makeNav({
92
+ "orders.sort": "name",
93
+ "orders.dir": "asc",
94
+ "orders.page": "3",
95
+ });
96
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
97
+ act(() => {
98
+ result.current.setSort(null);
99
+ });
100
+ expect(nav.captures[0]).toEqual({
101
+ "orders.sort": null,
102
+ "orders.dir": null,
103
+ "orders.page": null,
104
+ });
105
+ });
106
+
107
+ test("setQ schreibt q + resettet page (Sort bleibt unangetastet)", () => {
108
+ const nav = makeNav({
109
+ "orders.sort": "name",
110
+ "orders.dir": "asc",
111
+ "orders.page": "3",
112
+ });
113
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
114
+ act(() => {
115
+ result.current.setQ("acme");
116
+ });
117
+ expect(nav.captures[0]).toEqual({
118
+ "orders.q": "acme",
119
+ "orders.page": null,
120
+ });
121
+ // Sort darf NICHT in den captures sein — search-change zerlegt
122
+ // die Sortierung nicht.
123
+ expect(nav.captures[0]).not.toHaveProperty("orders.sort");
124
+ });
125
+
126
+ test("setQ('') löscht den q-Key", () => {
127
+ const nav = makeNav({ "orders.q": "acme" });
128
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
129
+ act(() => {
130
+ result.current.setQ("");
131
+ });
132
+ expect(nav.captures[0]).toEqual({
133
+ "orders.q": null,
134
+ "orders.page": null,
135
+ });
136
+ });
137
+
138
+ test("setPage(1) löscht den Key (Default-Page = unprefixed URL)", () => {
139
+ const nav = makeNav({ "orders.page": "5" });
140
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
141
+ act(() => {
142
+ result.current.setPage(1);
143
+ });
144
+ expect(nav.captures[0]).toEqual({ "orders.page": null });
145
+ });
146
+
147
+ test("setPage(N>1) speichert die Page als String", () => {
148
+ const nav = makeNav();
149
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
150
+ act(() => {
151
+ result.current.setPage(7);
152
+ });
153
+ expect(nav.captures[0]).toEqual({ "orders.page": "7" });
154
+ });
155
+
156
+ test("invalid page (negativ, NaN, 0) → fallback auf 1", () => {
157
+ const nav = makeNav({ "orders.page": "-3" });
158
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
159
+ expect(result.current.page).toBe(1);
160
+ });
161
+ });
@@ -0,0 +1,50 @@
1
+ // Shim für ActionForm-Renderer (Tier 2.7d).
2
+ //
3
+ // RenderEdit verlangt heute eine `entity: EntityDefinition` + ein
4
+ // `screen: EntityEditScreenDefinition` Pair, nutzt aber intern nur
5
+ // `entity.fields` und `screen.layout`. ActionForm-Screens haben weder
6
+ // eine Entity-Reference noch sind sie type: "entityEdit" — die zwei
7
+ // Helper hier shapen den Daten-Input ad-hoc um, damit RenderEdit
8
+ // reused werden kann ohne den Stack zu duplizieren.
9
+ //
10
+ // Schulden-Flag: Sobald RenderEdit auf entity.transitions / idType /
11
+ // defaultCurrency / softDelete zugreift, oder ein zukünftiger Boot-
12
+ // Validator die schema.entities-Map cross-referenziert, brechen die
13
+ // Type-Lies hier silent. Dann ist es Zeit für eine echte
14
+ // RenderActionForm-Komponente, die `fields` direkt als Input nimmt.
15
+ // Der Boot-Validator kennt actionForm bereits eigenständig (kein
16
+ // entity-Lookup), also ist der Server-Pfad davon nicht betroffen.
17
+
18
+ import type {
19
+ ActionFormScreenDefinition,
20
+ EntityDefinition,
21
+ EntityEditScreenDefinition,
22
+ } from "@cosmicdrift/kumiko-framework/ui-types";
23
+
24
+ const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
25
+
26
+ /** Baut eine minimale EntityDefinition aus den Inline-Fields des
27
+ * ActionForm-Screens. RenderEdit + computeEditViewModel iterieren
28
+ * über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
29
+ * Felder bleiben undefined. */
30
+ export function synthesizeActionFormEntity(
31
+ fields: ActionFormScreenDefinition["fields"],
32
+ ): EntityDefinition {
33
+ return { fields } as EntityDefinition;
34
+ }
35
+
36
+ /** Wandelt ein ActionFormScreenDefinition in die EntityEditScreen-
37
+ * Shape die RenderEdit erwartet. type wird auf "entityEdit" gesetzt
38
+ * damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
39
+ * gepinnt — RenderEdit liest das Feld nicht. */
40
+ export function synthesizeActionFormScreen(
41
+ screen: ActionFormScreenDefinition,
42
+ ): EntityEditScreenDefinition {
43
+ return {
44
+ id: screen.id,
45
+ type: "entityEdit",
46
+ entity: ACTION_FORM_PSEUDO_ENTITY,
47
+ layout: screen.layout,
48
+ ...(screen.access !== undefined && { access: screen.access }),
49
+ };
50
+ }
@@ -0,0 +1,64 @@
1
+ // Column-Renderer-Map: client-side Lookup für ListColumn-Spalten die im
2
+ // Schema einen PlatformComponent-Renderer (`{ react: { __component: "Name" } }`)
3
+ // statt einer String-Funktion angeben. createKumikoApp im renderer-web sammelt
4
+ // alle clientFeatures.columnRenderers und mountet den Provider; der DataTable-
5
+ // Cell-Renderer schlägt den Component über `useColumnRenderer(name)` nach.
6
+ //
7
+ // Analog zu CustomScreensProvider — der Renderer-Web-Bootstrap mounted beide
8
+ // Provider an derselben Stelle. Schemas selbst bleiben serializable: der
9
+ // Component wird nur per String-Key referenziert, die Map liegt allein
10
+ // client-seitig.
11
+
12
+ import { type ComponentType, createContext, type ReactNode, useContext } from "react";
13
+
14
+ // Props die ein Column-Renderer-Component bekommt. `value` ist der
15
+ // Cell-Value für die Spalte (rohes Field-Value aus der Row), `row` ist
16
+ // die ganze Row als plain object — nice-to-have für Renderer die auf
17
+ // andere Spalten zugreifen wollen (z.B. Status-Badge der den
18
+ // Erstellungs-Zeitpunkt aus der Row mitnimmt). `column.field` ist der
19
+ // Field-Name; hilfreich für generische Renderer (JSON-Pretty-Printer
20
+ // oder Debug-Renderer die den Feldnamen anzeigen).
21
+ export type ColumnRendererProps = {
22
+ readonly value: unknown;
23
+ readonly row: Readonly<Record<string, unknown>>;
24
+ readonly column: {
25
+ readonly field: string;
26
+ };
27
+ };
28
+
29
+ export type ColumnRendererComponent = ComponentType<ColumnRendererProps>;
30
+
31
+ export type ColumnRenderersMap = Readonly<Record<string, ColumnRendererComponent>>;
32
+
33
+ const ColumnRenderersContext = createContext<ColumnRenderersMap | undefined>(undefined);
34
+
35
+ export type ColumnRenderersProviderProps = {
36
+ readonly children: ReactNode;
37
+ readonly value: ColumnRenderersMap;
38
+ };
39
+
40
+ export function ColumnRenderersProvider({
41
+ children,
42
+ value,
43
+ }: ColumnRenderersProviderProps): ReactNode {
44
+ return (
45
+ <ColumnRenderersContext.Provider value={value}>{children}</ColumnRenderersContext.Provider>
46
+ );
47
+ }
48
+
49
+ /** Schaut die Component für einen Renderer-Key nach. Returnt undefined
50
+ * wenn `name` leer/undefined ist (Caller hat keinen __component-Renderer)
51
+ * oder weder Provider gemounted noch der Key in der Map ist. Der Caller
52
+ * (DataTable.renderCell) loggt dann eine Warnung und fällt auf den
53
+ * Default-Type-Renderer zurück.
54
+ *
55
+ * `name` ist optional damit Caller den Hook unkonditional aufrufen
56
+ * können — Rules-of-Hooks verbieten einen Function-Branch der den Hook
57
+ * überspringt. So bleibt der Aufruf eine einzige Zeile, ohne dass der
58
+ * Caller einen Stub-Key wie `""` reichen muss. */
59
+ export function useColumnRenderer(name?: string): ColumnRendererComponent | undefined {
60
+ const map = useContext(ColumnRenderersContext);
61
+ if (name === undefined || name === "") return undefined;
62
+ if (map === undefined) return undefined;
63
+ return map[name];
64
+ }
@@ -0,0 +1,48 @@
1
+ // Shim für ConfigEdit-Renderer (analog zu action-form-shim.ts).
2
+ //
3
+ // RenderEdit verlangt eine `entity: EntityDefinition` + ein
4
+ // `screen: EntityEditScreenDefinition` Pair, nutzt aber intern nur
5
+ // `entity.fields` und `screen.layout` für das Rendern. ConfigEdit-
6
+ // Screens haben weder eine Entity-Reference noch sind sie
7
+ // type: "entityEdit" — die zwei Helper hier shapen den Daten-Input
8
+ // ad-hoc um, damit RenderEdit reused werden kann.
9
+ //
10
+ // Selbe Schulden-Reservation wie bei action-form-shim: sobald RenderEdit
11
+ // auf entity.transitions / idType / softDelete zugreift, oder ein
12
+ // zukünftiger Boot-Validator die schema.entities-Map cross-referenziert,
13
+ // brechen die Type-Lies hier silent. Dann ist Zeit für eine echte
14
+ // RenderConfigEdit-Komponente die direkt fields nimmt.
15
+
16
+ import type {
17
+ ConfigEditScreenDefinition,
18
+ EntityDefinition,
19
+ EntityEditScreenDefinition,
20
+ } from "@cosmicdrift/kumiko-framework/ui-types";
21
+
22
+ const CONFIG_EDIT_PSEUDO_ENTITY = "__config-edit__";
23
+
24
+ /** Baut eine minimale EntityDefinition aus den Inline-Fields des
25
+ * ConfigEdit-Screens. RenderEdit + computeEditViewModel iterieren
26
+ * über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
27
+ * Felder bleiben undefined. */
28
+ export function synthesizeConfigEditEntity(
29
+ fields: ConfigEditScreenDefinition["fields"],
30
+ ): EntityDefinition {
31
+ return { fields } as EntityDefinition;
32
+ }
33
+
34
+ /** Wandelt ein ConfigEditScreenDefinition in die EntityEditScreen-
35
+ * Shape die RenderEdit erwartet. type wird auf "entityEdit" gesetzt
36
+ * damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
37
+ * gepinnt — RenderEdit liest das Feld nicht. */
38
+ export function synthesizeConfigEditScreen(
39
+ screen: ConfigEditScreenDefinition,
40
+ ): EntityEditScreenDefinition {
41
+ return {
42
+ id: screen.id,
43
+ type: "entityEdit",
44
+ entity: CONFIG_EDIT_PSEUDO_ENTITY,
45
+ layout: screen.layout,
46
+ ...(screen.access !== undefined && { access: screen.access }),
47
+ };
48
+ }
@@ -0,0 +1,29 @@
1
+ // Custom-Screen-Components-Map: client-side Lookup für Screens vom Typ
2
+ // `custom`. KumikoScreen schaut hier nach `screen.id` (oder qn) und
3
+ // rendert die passende Component. createKumikoApp im renderer-web sammelt
4
+ // alle clientFeatures.components und mountet den Provider; Tests die
5
+ // einzelne Custom-Screens prüfen wollen, mounten den Provider direkt.
6
+
7
+ import { type ComponentType, createContext, type ReactNode, useContext } from "react";
8
+
9
+ export type CustomScreensMap = Readonly<Record<string, ComponentType>>;
10
+
11
+ const CustomScreensContext = createContext<CustomScreensMap | undefined>(undefined);
12
+
13
+ export type CustomScreensProviderProps = {
14
+ readonly children: ReactNode;
15
+ readonly value: CustomScreensMap;
16
+ };
17
+
18
+ export function CustomScreensProvider({ children, value }: CustomScreensProviderProps): ReactNode {
19
+ return <CustomScreensContext.Provider value={value}>{children}</CustomScreensContext.Provider>;
20
+ }
21
+
22
+ /** Schaut die Component für ein Custom-Screen nach. Returnt undefined
23
+ * wenn weder Provider gemounted noch screenId in der Map ist — der
24
+ * Caller (KumikoScreen) zeigt dann seinen Placeholder-Banner. */
25
+ export function useCustomScreenComponent(screenId: string): ComponentType | undefined {
26
+ const map = useContext(CustomScreensContext);
27
+ if (map === undefined) return undefined;
28
+ return map[screenId];
29
+ }
@@ -0,0 +1,59 @@
1
+ // Client-safe view of a feature: the subset the renderer needs to
2
+ // mount screens. Intentionally narrower than the server-side
3
+ // FeatureDefinition (no handlers, no hooks, no projections) so the
4
+ // file that exports a schema can be imported from the browser bundle
5
+ // without dragging in Node-only framework internals.
6
+ //
7
+ // Types wohnen in `framework/ui-types/app-schema.ts` damit der Server
8
+ // (buildAppSchema, dev-server) sie produzieren kann ohne renderer als
9
+ // Dependency zu ziehen. Hier nur Re-Export + Runtime-Helpers
10
+ // (toAppSchema, isAppSchema) die Client-Code zur Laufzeit braucht.
11
+ //
12
+ // Typical layout on the feature author's side:
13
+ //
14
+ // // feature-schema.ts (imported by client AND server)
15
+ // export const taskEntity = createEntity({ ... });
16
+ // export const editScreen: EntityEditScreenDefinition = { ... };
17
+ // export const clientSchema: FeatureSchema = {
18
+ // featureName: "tasks",
19
+ // entities: { task: taskEntity },
20
+ // screens: [editScreen, ...],
21
+ // };
22
+ //
23
+ // // feature.ts (server-only — imports defineFeature)
24
+ // import { taskEntity, editScreen, ... } from "./feature-schema";
25
+ // export const taskFeature = defineFeature("tasks", (r) => {
26
+ // r.entity("task", taskEntity);
27
+ // r.writeHandler(...);
28
+ // r.screen(editScreen);
29
+ // ...
30
+ // });
31
+
32
+ import type {
33
+ AppSchema,
34
+ FeatureSchema,
35
+ WorkspaceSchema,
36
+ } from "@cosmicdrift/kumiko-framework/ui-types";
37
+
38
+ export type { AppSchema, FeatureSchema, WorkspaceSchema };
39
+
40
+ // Normalisiert FeatureSchema → AppSchema. Idempotent für AppSchema.
41
+ // Hebt eine Feature-lokal deklarierte `workspaces`-Liste (Legacy) auf
42
+ // App-Ebene hoch, damit alle Layouts mit der neuen Form arbeiten können
43
+ // ohne dass alte clientSchemas migriert werden müssen.
44
+ export function toAppSchema(input: FeatureSchema | AppSchema): AppSchema {
45
+ if ("features" in input) return input;
46
+ // Old single-feature shape — wrap.
47
+ const { workspaces, ...feature } = input;
48
+ return {
49
+ features: [feature],
50
+ ...(workspaces !== undefined && { workspaces }),
51
+ };
52
+ }
53
+
54
+ // TypeGuard — Caller die schon zur Laufzeit unterscheiden müssen
55
+ // (selten; meist reicht toAppSchema). Nicht via `"features" in x` inline
56
+ // machen — narrow'd TS dann auf den join-Typ statt die echte Differenz.
57
+ export function isAppSchema(input: FeatureSchema | AppSchema): input is AppSchema {
58
+ return "features" in input;
59
+ }