@cosmicdrift/kumiko-renderer-web 0.239.0 → 0.241.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.239.0",
3
+ "version": "0.241.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.239.0",
20
- "@cosmicdrift/kumiko-headless": "0.239.0",
21
- "@cosmicdrift/kumiko-renderer": "0.239.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.241.0",
20
+ "@cosmicdrift/kumiko-headless": "0.241.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.241.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.239.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.241.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -12,6 +12,7 @@ import { type ColumnRendererProps, ColumnRenderersProvider } from "@cosmicdrift/
12
12
  import userEvent from "@testing-library/user-event";
13
13
  import type { ReactNode } from "react";
14
14
  import { defaultPrimitives, END_LABEL_MIN_ROWS, FormScreenShell } from "../primitives";
15
+ import { DefaultJsonView } from "../primitives/json-view";
15
16
  import { PageSection, Stack } from "../primitives/layout";
16
17
  import { fireEvent, render, screen, waitFor } from "./test-utils";
17
18
 
@@ -1766,3 +1767,50 @@ describe("PageSection", () => {
1766
1767
  expect(screen.getByTestId("p-default").className).toContain("max-w-full");
1767
1768
  });
1768
1769
  });
1770
+
1771
+ describe("JsonView", () => {
1772
+ test("tokenisiert statt einen flachen String zu rendern — Keys/Strings/Zahlen/Booleans/null bekommen je eigene Klasse", () => {
1773
+ render(
1774
+ <DefaultJsonView testId="jv" value={{ name: "job-1", attempts: 3, ok: true, error: null }} />,
1775
+ );
1776
+ const spans = Array.from(screen.getByTestId("jv").querySelectorAll("span"));
1777
+ // 1 Key-Token pro Feld + 1 Value-Token (string/number/boolean/null) = 8.
1778
+ expect(spans.length).toBe(8);
1779
+ const classNames = new Set(spans.map((s) => s.className));
1780
+ expect(classNames.size).toBe(5);
1781
+ });
1782
+
1783
+ test("Whitespace bleibt erhalten (pre-wrap) — mehrzeiliger, eingerückter Output statt einer kollabierten Zeile", () => {
1784
+ const value = { a: { b: 1 } };
1785
+ render(<DefaultJsonView testId="jv" value={value} />);
1786
+ const el = screen.getByTestId("jv");
1787
+ expect(el.className).toContain("whitespace-pre-wrap");
1788
+ expect(el.className).toContain("break-words");
1789
+ expect(el.textContent).toBe(JSON.stringify(value, null, 2));
1790
+ expect(el.textContent).toContain("\n");
1791
+ });
1792
+
1793
+ test("indent-Prop steuert die Einrückung wie bei JSON.stringify", () => {
1794
+ const value = { a: 1 };
1795
+ render(<DefaultJsonView testId="jv" value={value} indent={4} />);
1796
+ expect(screen.getByTestId("jv").textContent).toBe(JSON.stringify(value, null, 4));
1797
+ });
1798
+
1799
+ test("zirkulärer Wert wirft nicht — rendert einen [Circular]-Marker statt die Seite zu killen", () => {
1800
+ const circular: Record<string, unknown> = { name: "job-1" };
1801
+ circular["self"] = circular;
1802
+ expect(() => render(<DefaultJsonView testId="jv" value={circular} />)).not.toThrow();
1803
+ expect(screen.getByTestId("jv").textContent).toContain("[Circular]");
1804
+ });
1805
+
1806
+ test("BigInt wirft nicht — wird als String serialisiert", () => {
1807
+ expect(() =>
1808
+ render(<DefaultJsonView testId="jv" value={{ amount: 9007199254740993n }} />),
1809
+ ).not.toThrow();
1810
+ expect(screen.getByTestId("jv").textContent).toContain("9007199254740993n");
1811
+ });
1812
+
1813
+ test("undefined wirft nicht", () => {
1814
+ expect(() => render(<DefaultJsonView testId="jv" value={undefined} />)).not.toThrow();
1815
+ });
1816
+ });
@@ -91,9 +91,13 @@ function StatPanelBody({
91
91
  }
92
92
  }, [panel.icon, panel.id, iconName, Icon, screenId]);
93
93
 
94
+ // filterParams first, panel.params second: a static author-set param is a
95
+ // deliberate pin (e.g. status: "failed") and must win over whatever the
96
+ // screen-wide filter happens to contribute under the same key.
97
+ const queryParams = { ...filterParams, ...panel.params };
94
98
  const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
95
99
  panel.query,
96
- filterParams,
100
+ queryParams,
97
101
  { live: true },
98
102
  );
99
103
  if (loading && data === null) return <LoadingState rows={2} />;
@@ -13,7 +13,7 @@
13
13
  // Siehe visual-tree.md V.1.2 + V.1.1-B + V.1.4b.
14
14
 
15
15
  import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
16
- import { useNav } from "@cosmicdrift/kumiko-renderer";
16
+ import { useNav, usePrimitives } from "@cosmicdrift/kumiko-renderer";
17
17
  import { X } from "lucide-react";
18
18
  import type { ComponentType, ReactNode } from "react";
19
19
  import { useCallback, useMemo } from "react";
@@ -37,6 +37,7 @@ function EditorPanelInner({
37
37
  readonly resolvers: ReadonlyMap<string, ResolverComponent>;
38
38
  readonly onClose: () => void;
39
39
  }): ReactNode {
40
+ const { JsonView } = usePrimitives();
40
41
  const resolverKey = `${target.featureId}:${target.action}`;
41
42
  const Resolver = resolvers.get(resolverKey);
42
43
 
@@ -65,9 +66,13 @@ function EditorPanelInner({
65
66
  </code>{" "}
66
67
  registriert.
67
68
  </p>
68
- <pre className="bg-muted p-2 rounded text-xs overflow-auto">
69
- {JSON.stringify(target.args, null, 2)}
70
- </pre>
69
+ {JsonView !== undefined ? (
70
+ <JsonView value={target.args} />
71
+ ) : (
72
+ <pre className="bg-muted p-2 rounded text-xs overflow-auto">
73
+ {JSON.stringify(target.args, null, 2)}
74
+ </pre>
75
+ )}
71
76
  </div>
72
77
  </div>
73
78
  );
@@ -109,6 +109,7 @@ import {
109
109
  } from "./dropdown-menu";
110
110
  import { EmbeddedListInput } from "./embedded-list-input";
111
111
  import { FileUploadInput } from "./file-upload";
112
+ import { DefaultJsonView } from "./json-view";
112
113
  import { screenWidthClassName } from "./layout";
113
114
  import { DefaultLightbox } from "./lightbox";
114
115
  import { LocatedTimestampInput } from "./located-timestamp-input";
@@ -2560,4 +2561,5 @@ export const defaultPrimitives: CorePrimitives = {
2560
2561
  Tabs: DefaultTabs,
2561
2562
  StatusBadge: DefaultStatusBadge,
2562
2563
  Metric: DefaultMetric,
2564
+ JsonView: DefaultJsonView,
2563
2565
  };
@@ -0,0 +1,93 @@
1
+ import type { JsonViewProps } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+
4
+ // Recursive pre-pass (not JSX) that turns `value` into a JSON.stringify-safe
5
+ // plain structure: BigInt → string, and a real ancestor-path cycle check
6
+ // (not a global "seen" set — a value referenced twice via two different,
7
+ // non-circular paths must still render twice, not collapse to "[Circular]").
8
+ function toSafeJson(value: unknown, ancestors: readonly unknown[] = []): unknown {
9
+ if (typeof value === "bigint") return `${value.toString()}n`;
10
+ if (typeof value !== "object" || value === null) return value;
11
+ if (ancestors.includes(value)) return "[Circular]";
12
+ const nextAncestors = [...ancestors, value];
13
+ if (Array.isArray(value)) return value.map((item) => toSafeJson(item, nextAncestors));
14
+ const out: Record<string, unknown> = {};
15
+ for (const [k, v] of Object.entries(value)) out[k] = toSafeJson(v, nextAncestors);
16
+ return out;
17
+ }
18
+
19
+ // A broken audit/log payload must not take the whole screen down with it —
20
+ // double try/catch so even a throwing getter on the input falls back to a
21
+ // plain string instead of propagating.
22
+ function safeStringify(value: unknown, indent: number): string {
23
+ try {
24
+ const json = JSON.stringify(toSafeJson(value), null, indent);
25
+ return json ?? String(value);
26
+ } catch {
27
+ try {
28
+ return String(value);
29
+ } catch {
30
+ return "[unserializable value]";
31
+ }
32
+ }
33
+ }
34
+
35
+ type TokenKind = "key" | "string" | "number" | "boolean" | "null";
36
+
37
+ const TOKEN_CLASS_NAME: Record<TokenKind, string> = {
38
+ key: "text-syntax-key font-medium",
39
+ string: "text-syntax-string",
40
+ number: "text-syntax-number",
41
+ boolean: "text-syntax-literal",
42
+ null: "text-syntax-literal italic",
43
+ };
44
+
45
+ function classifyToken(token: string): TokenKind {
46
+ if (token.startsWith('"')) return token.endsWith(":") ? "key" : "string";
47
+ if (token === "true" || token === "false") return "boolean";
48
+ if (token === "null") return "null";
49
+ return "number";
50
+ }
51
+
52
+ // The stringify output is canonical JSON — a regex tokenizer over that
53
+ // string is a fraction of the code of a recursive value-tree renderer, and
54
+ // tokens become plain React elements (never dangerouslySetInnerHTML): audit
55
+ // payloads and job logs carry untrusted third-party data, so this must not
56
+ // become an HTML-injection path.
57
+ function tokenizeJson(json: string): ReactNode[] {
58
+ const tokenRe =
59
+ /("(?:\\u[a-fA-F0-9]{4}|\\.|[^\\"])*"(?:\s*:)?|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\btrue\b|\bfalse\b|\bnull\b)/g;
60
+ const nodes: ReactNode[] = [];
61
+ let lastIndex = 0;
62
+ let match: RegExpExecArray | null = tokenRe.exec(json);
63
+ let key = 0;
64
+ while (match !== null) {
65
+ if (match.index > lastIndex) nodes.push(json.slice(lastIndex, match.index));
66
+ const token = match[0];
67
+ nodes.push(
68
+ <span key={key++} className={TOKEN_CLASS_NAME[classifyToken(token)]}>
69
+ {token}
70
+ </span>,
71
+ );
72
+ lastIndex = tokenRe.lastIndex;
73
+ match = tokenRe.exec(json);
74
+ }
75
+ if (lastIndex < json.length) nodes.push(json.slice(lastIndex));
76
+ return nodes;
77
+ }
78
+
79
+ /** Syntax-highlighted, whitespace-preserving JSON display. Bounded height
80
+ * with its own vertical scroll (job logs get long) instead of growing the
81
+ * page; wraps rather than overflowing horizontally. Colors come from the
82
+ * dedicated syntax-* theme tokens (styles.css), readable in light + dark. */
83
+ export function DefaultJsonView({ value, indent = 2, testId }: JsonViewProps): ReactNode {
84
+ const json = safeStringify(value, indent);
85
+ return (
86
+ <pre
87
+ data-testid={testId}
88
+ className="max-h-80 overflow-y-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs"
89
+ >
90
+ {tokenizeJson(json)}
91
+ </pre>
92
+ );
93
+ }
package/src/styles.css CHANGED
@@ -89,6 +89,15 @@
89
89
  --color-status-warn: #f59e0b;
90
90
  --color-status-bad: #ef4444;
91
91
  --color-status-critical: #f87171;
92
+
93
+ /* JSON-Syntax-Highlighting (JsonView). Eigene, gedämpfte Palette statt der
94
+ Status-Ampel — ein `false` oder eine Zahl ist kein Fehler-/Warn-Signal.
95
+ Dark-Werte; Light-Overrides unten. Kontrast gegen bg-muted geprüft (>=6:1
96
+ in beiden Themes). */
97
+ --color-syntax-key: hsl(210 15% 75%);
98
+ --color-syntax-string: hsl(150 25% 60%);
99
+ --color-syntax-number: hsl(35 40% 65%);
100
+ --color-syntax-literal: hsl(270 25% 70%);
92
101
  }
93
102
 
94
103
  /* Card-Chrome-Maße — Framework-Defaults. Eine App überschreibt selektiv in
@@ -151,6 +160,13 @@
151
160
  --color-status-warn: #d97706;
152
161
  --color-status-bad: #dc2626;
153
162
  --color-status-critical: #991b1b;
163
+
164
+ /* JSON-Syntax-Highlighting Light — dunkler/gesättigter, Kontrast gegen
165
+ bg-muted geprüft (>=6:1). */
166
+ --color-syntax-key: hsl(210 20% 35%);
167
+ --color-syntax-string: hsl(150 30% 30%);
168
+ --color-syntax-number: hsl(30 45% 32%);
169
+ --color-syntax-literal: hsl(270 30% 40%);
154
170
  }
155
171
 
156
172
  * {