@cosmicdrift/kumiko-renderer 0.234.0 → 0.235.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.234.0",
3
+ "version": "0.235.1",
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>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.234.0",
19
- "@cosmicdrift/kumiko-headless": "0.234.0",
18
+ "@cosmicdrift/kumiko-framework": "0.235.1",
19
+ "@cosmicdrift/kumiko-headless": "0.235.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -24,8 +24,10 @@
24
24
  "devDependencies": {
25
25
  "@testing-library/react": "^16.3.2",
26
26
  "@types/react": "^19.2.14",
27
+ "@types/react-dom": "^19.2.3",
27
28
  "jsdom": "^29.1.1",
28
- "@cosmicdrift/kumiko-locale-de": "0.234.0"
29
+ "react-dom": "^19.2.6",
30
+ "@cosmicdrift/kumiko-locale-de": "0.235.1"
29
31
  },
30
32
  "repository": {
31
33
  "type": "git",
@@ -2,12 +2,14 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
3
3
  import { act, render, renderHook } from "@testing-library/react";
4
4
  import type { ReactNode } from "react";
5
+ import { renderToStaticMarkup } from "react-dom/server";
5
6
  import {
6
7
  createStaticLocaleResolver,
7
8
  LocaleProvider,
8
9
  type TranslationsByLocale,
9
10
  translationsByLocaleFromKeys,
10
11
  useLocale,
12
+ useOptionalTranslation,
11
13
  useTranslation,
12
14
  } from "../i18n";
13
15
 
@@ -171,3 +173,40 @@ describe("useLocale", () => {
171
173
  expect(result.current.locale()).toBe("de");
172
174
  });
173
175
  });
176
+ describe("useTranslation — SSR", () => {
177
+ // kumiko-framework#2595: getServerSnapshot hardcoded "en", so any
178
+ // server render came out English regardless of the resolver's actual
179
+ // locale — untestable via renderToStaticMarkup until getServerSnapshot
180
+ // read the resolver instead of a constant.
181
+ test(`renderToStaticMarkup with a non-English resolver renders the resolver's locale, not "en"`, () => {
182
+ const resolver = createStaticLocaleResolver({ locale: "de" });
183
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
184
+ function Probe(): ReactNode {
185
+ const t = useTranslation();
186
+ return <span>{t("greet")}</span>;
187
+ }
188
+ const html = renderToStaticMarkup(
189
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
190
+ <Probe />
191
+ </LocaleProvider>,
192
+ );
193
+ expect(html).toContain("Hallo");
194
+ expect(html).not.toContain("Hello");
195
+ });
196
+
197
+ test(`renderToStaticMarkup via useOptionalTranslation resolves the resolver's locale, not "en"`, () => {
198
+ const resolver = createStaticLocaleResolver({ locale: "de" });
199
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
200
+ function Probe(): ReactNode {
201
+ const t = useOptionalTranslation();
202
+ return <span>{t?.("greet")}</span>;
203
+ }
204
+ const html = renderToStaticMarkup(
205
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
206
+ <Probe />
207
+ </LocaleProvider>,
208
+ );
209
+ expect(html).toContain("Hallo");
210
+ expect(html).not.toContain("Hello");
211
+ });
212
+ });
@@ -61,6 +61,7 @@ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projec
61
61
  import { lastSegment, toKebab } from "./qn";
62
62
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
63
63
  import { screenAccessAllows } from "./screen-access";
64
+ import { SecretsEditBody } from "./secrets-edit-body";
64
65
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
65
66
 
66
67
  function evalRowExtractor(
@@ -262,6 +263,8 @@ export function KumikoScreen({
262
263
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
263
264
  case "configEdit":
264
265
  return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
266
+ case "secretsEdit":
267
+ return <SecretsEditBody screen={screen} translate={translate} />;
265
268
  case "custom":
266
269
  return <CustomScreenBody screenId={screen.id} featureName={schema.featureName} />;
267
270
  }
@@ -2223,15 +2226,14 @@ function ProjectionDetailBody({
2223
2226
  const idParam = screen.idParam ?? "id";
2224
2227
  const isTabsMode = screen.layout.mode === "tabs";
2225
2228
  const activeSection = useMemo(() => {
2226
- if (!isTabsMode) return undefined;
2229
+ if (!isTabsMode || Tabs === undefined) return undefined;
2227
2230
  const tabParam = nav.searchParams["tab"];
2228
2231
  return (
2229
2232
  screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
2230
2233
  );
2231
- }, [isTabsMode, screen.layout.sections, nav.searchParams]);
2232
- // Tabs is an optional Core-Primitive (additive rollout) same "skip +
2233
- // warn once" precedent as Drawer above, instead of crashing when a web
2234
- // app hasn't upgraded its createKumikoApp wiring yet.
2234
+ }, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
2235
+ // Tabs is an optional Core-Primitive: without it the screen falls back to
2236
+ // the stacked all-sections layout instead of silently truncating to section 1.
2235
2237
  useEffect(() => {
2236
2238
  if (isTabsMode && Tabs === undefined) {
2237
2239
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
@@ -2534,7 +2536,7 @@ function ProjectionDetailBody({
2534
2536
  customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2535
2537
  {...(headerActions !== undefined && { actions: headerActions })}
2536
2538
  {...(translate !== undefined && { translate })}
2537
- {...(isTabsMode && { hideSectionTitles: true })}
2539
+ {...(hasTabs && { hideSectionTitles: true })}
2538
2540
  {...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
2539
2541
  valueDisplay={screen.valueDisplay ?? "text"}
2540
2542
  />
@@ -0,0 +1,190 @@
1
+ import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
3
+ import { type ReactNode, useCallback, useMemo, useState } from "react";
4
+ import { useDispatcher } from "../context/dispatcher-context";
5
+ import { useQuery } from "../hooks/use-query";
6
+ import { useTranslation } from "../i18n";
7
+ import { usePrimitives } from "../primitives";
8
+ import { dispatcherErrorText } from "./write-failed-error";
9
+
10
+ type SecretListRow = {
11
+ readonly key: string;
12
+ readonly redactedPreview: string | null;
13
+ readonly hint: string | null;
14
+ };
15
+
16
+ export type SecretsEditBodyProps = {
17
+ readonly screen: SecretsEditScreenDefinition;
18
+ readonly translate?: Translate;
19
+ };
20
+
21
+ // secrets:query:list never returns plaintext (only a redacted preview), so
22
+ // unlike ConfigEditBody there is no server value to pre-fill a draft with —
23
+ // every input starts at "" and stays that way unless the user types into it.
24
+ export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): ReactNode {
25
+ const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
26
+ const t = useTranslation();
27
+ const effectiveTranslate = translate ?? t;
28
+ const dispatcher = useDispatcher();
29
+ const listQuery = useQuery<readonly SecretListRow[]>("secrets:query:list", {});
30
+
31
+ const [drafts, setDrafts] = useState<Readonly<Record<string, string>>>({});
32
+ const [submitting, setSubmitting] = useState(false);
33
+ const [submitError, setSubmitError] = useState<string | null>(null);
34
+
35
+ const rowsByQualifiedKey = useMemo(() => {
36
+ const out = new Map<string, SecretListRow>();
37
+ for (const row of listQuery.data ?? []) out.set(row.key, row);
38
+ return out;
39
+ }, [listQuery.data]);
40
+
41
+ const setDraft = useCallback((fieldId: string, value: string) => {
42
+ setDrafts((prev) => ({ ...prev, [fieldId]: value }));
43
+ }, []);
44
+
45
+ const handleSubmit = useCallback(async (): Promise<void> => {
46
+ const commands = Object.entries(screen.secretKeys).flatMap(([fieldId, qualified]) => {
47
+ const value = drafts[fieldId]?.trim();
48
+ return value ? [{ type: "secrets:write:set", payload: { key: qualified, value } }] : [];
49
+ });
50
+ if (commands.length === 0) return;
51
+ setSubmitting(true);
52
+ setSubmitError(null);
53
+ const result = await dispatcher.batch(commands);
54
+ setSubmitting(false);
55
+ if (!result.isSuccess) {
56
+ setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
57
+ return;
58
+ }
59
+ setDrafts({});
60
+ await listQuery.refetch();
61
+ }, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
62
+
63
+ const handleDelete = useCallback(
64
+ async (qualified: string): Promise<void> => {
65
+ const result = await dispatcher.write("secrets:write:delete", { key: qualified });
66
+ if (!result.isSuccess) {
67
+ setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
68
+ return;
69
+ }
70
+ await listQuery.refetch();
71
+ },
72
+ [dispatcher, listQuery.refetch, effectiveTranslate],
73
+ );
74
+
75
+ if (listQuery.loading && listQuery.data === null) {
76
+ return (
77
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
78
+ Loading…
79
+ </Banner>
80
+ );
81
+ }
82
+ if (listQuery.error) {
83
+ return (
84
+ <Banner padded variant="error" testId="kumiko-screen-error">
85
+ {dispatcherErrorText(listQuery.error, effectiveTranslate)}
86
+ </Banner>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <Form
92
+ onSubmit={() => {
93
+ void handleSubmit();
94
+ }}
95
+ testId="secrets-edit-form"
96
+ actions={
97
+ <Button
98
+ type="submit"
99
+ variant="primary"
100
+ loading={submitting}
101
+ disabled={submitting}
102
+ testId="secrets-edit-submit"
103
+ >
104
+ {effectiveTranslate("kumiko.actions.save")}
105
+ </Button>
106
+ }
107
+ >
108
+ {submitError !== null && (
109
+ <Banner variant="error" testId="secrets-edit-error">
110
+ {submitError}
111
+ </Banner>
112
+ )}
113
+ {screen.sections.map((section, index) => (
114
+ <Section
115
+ key={section.title ?? `section-${index}`}
116
+ {...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
117
+ >
118
+ {section.fields.map((fieldId) => {
119
+ const qualified = screen.secretKeys[fieldId];
120
+ if (qualified === undefined) return null;
121
+ const row = rowsByQualifiedKey.get(qualified);
122
+ const hintKey = screen.fieldHints?.[fieldId];
123
+ const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
124
+ return (
125
+ <Field
126
+ key={fieldId}
127
+ id={fieldId}
128
+ label={effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId)}
129
+ required={isRequired}
130
+ testId={`field-${fieldId}`}
131
+ fieldAppendix={
132
+ <>
133
+ {hintKey !== undefined && (
134
+ <Text variant="small">{effectiveTranslate(hintKey)}</Text>
135
+ )}
136
+ {row !== undefined ? (
137
+ <>
138
+ <Text variant="small" testId={`secret-preview-${fieldId}`}>
139
+ {row.redactedPreview ?? effectiveTranslate("config.secrets.set")}
140
+ </Text>
141
+ <Button
142
+ type="button"
143
+ variant="danger-ghost"
144
+ size="sm"
145
+ onClick={() => handleDelete(qualified)}
146
+ testId={`secret-delete-${fieldId}`}
147
+ >
148
+ {effectiveTranslate("config.secrets.delete")}
149
+ </Button>
150
+ </>
151
+ ) : (
152
+ <>
153
+ <Text variant="small" testId={`secret-not-set-${fieldId}`}>
154
+ {effectiveTranslate("config.secrets.notSet")}
155
+ </Text>
156
+ {isRequired && (
157
+ <Text variant="small" testId={`required-marker-${fieldId}`}>
158
+ {effectiveTranslate("config.secrets.required")}
159
+ </Text>
160
+ )}
161
+ </>
162
+ )}
163
+ </>
164
+ }
165
+ >
166
+ <Text variant="small">
167
+ {effectiveTranslate(
168
+ row !== undefined
169
+ ? "config.secrets.replacePlaceholder"
170
+ : "config.secrets.placeholder",
171
+ )}
172
+ </Text>
173
+ <Input
174
+ kind="password"
175
+ id={fieldId}
176
+ name={fieldId}
177
+ value={drafts[fieldId] ?? ""}
178
+ onChange={(v) => setDraft(fieldId, v)}
179
+ autoComplete="new-password"
180
+ disabled={submitting}
181
+ testId={`secret-input-${fieldId}`}
182
+ />
183
+ </Field>
184
+ );
185
+ })}
186
+ </Section>
187
+ ))}
188
+ </Form>
189
+ );
190
+ }
package/src/i18n.tsx CHANGED
@@ -120,7 +120,7 @@ export function useLocale(): LocaleResolver {
120
120
  useSyncExternalStore(
121
121
  ctx.resolver.subscribe,
122
122
  () => ctx.resolver.locale(),
123
- () => "en",
123
+ () => ctx.resolver.locale(),
124
124
  );
125
125
  return ctx.resolver;
126
126
  }
@@ -147,7 +147,7 @@ export function useTranslation(): (
147
147
  const locale = useSyncExternalStore(
148
148
  ctx.resolver.subscribe,
149
149
  () => ctx.resolver.locale(),
150
- () => "en",
150
+ () => ctx.resolver.locale(),
151
151
  );
152
152
 
153
153
  // `t` MUSS referenz-stabil sein solange sich Resolver/Bundles/Locale
@@ -200,7 +200,7 @@ export function useOptionalLocale(): string | undefined {
200
200
  useSyncExternalStore(
201
201
  (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
202
202
  () => (ctx ? ctx.resolver.locale() : "en"),
203
- () => "en",
203
+ () => (ctx ? ctx.resolver.locale() : "en"),
204
204
  );
205
205
  return ctx === undefined ? undefined : ctx.resolver.locale();
206
206
  }
@@ -213,7 +213,7 @@ export function useOptionalTranslation():
213
213
  const locale = useSyncExternalStore(
214
214
  (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
215
215
  () => (ctx ? ctx.resolver.locale() : "en"),
216
- () => "en",
216
+ () => (ctx ? ctx.resolver.locale() : "en"),
217
217
  );
218
218
  const t = useCallback(
219
219
  (key: string, params?: Readonly<Record<string, unknown>>): string => {