@cosmicdrift/kumiko-renderer-web 0.240.0 → 0.242.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 +9 -9
- package/src/__tests__/primitives.test.tsx +48 -0
- package/src/app/dashboard-body.tsx +5 -1
- package/src/layout/editor-panel.tsx +9 -4
- package/src/primitives/__tests__/textarea-rows.test.tsx +58 -0
- package/src/primitives/index.tsx +23 -2
- package/src/primitives/json-view.tsx +93 -0
- package/src/styles.css +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.242.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.242.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.242.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.242.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",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"react-resizable-panels": "^4.12.2",
|
|
40
40
|
"tailwind-merge": "^3.6.0",
|
|
41
41
|
"temporal-polyfill": "^0.3.2",
|
|
42
|
-
"@tiptap/core": "^3.
|
|
43
|
-
"@tiptap/pm": "^3.
|
|
44
|
-
"@tiptap/react": "^3.
|
|
45
|
-
"@tiptap/starter-kit": "^3.
|
|
42
|
+
"@tiptap/core": "^3.30.5",
|
|
43
|
+
"@tiptap/pm": "^3.30.5",
|
|
44
|
+
"@tiptap/react": "^3.30.5",
|
|
45
|
+
"@tiptap/starter-kit": "^3.30.5"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@tailwindcss/cli": "^4.3.0",
|
|
@@ -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.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.242.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
|
-
|
|
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
|
-
|
|
69
|
-
{
|
|
70
|
-
|
|
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
|
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// #2677 — `field-sizing: content` on the vendored shadcn Textarea makes the
|
|
2
|
+
// `rows` attribute inert, so a declared row count has to reach the rendered
|
|
3
|
+
// height as a min-height floor. Asserting the attribute alone would pass even
|
|
4
|
+
// with the bug present.
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { render } from "@testing-library/react";
|
|
7
|
+
import { defaultPrimitives } from "../index";
|
|
8
|
+
|
|
9
|
+
const { Input } = defaultPrimitives;
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
|
|
12
|
+
function renderTextarea(rows?: number): HTMLTextAreaElement {
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<Input
|
|
15
|
+
kind="textarea"
|
|
16
|
+
id="notes"
|
|
17
|
+
name="notes"
|
|
18
|
+
value=""
|
|
19
|
+
onChange={noop}
|
|
20
|
+
{...(rows !== undefined && { rows })}
|
|
21
|
+
/>,
|
|
22
|
+
);
|
|
23
|
+
const textarea = container.querySelector("textarea");
|
|
24
|
+
if (textarea === null) throw new Error("no textarea rendered");
|
|
25
|
+
return textarea;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowFactor(minHeight: string): number {
|
|
29
|
+
const match = /calc\((\d+(?:\.\d+)?) \* 1lh/.exec(minHeight);
|
|
30
|
+
if (match?.[1] === undefined) throw new Error(`min-height not row-derived: "${minHeight}"`);
|
|
31
|
+
return Number(match[1]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("textarea rows → min-height (#2677)", () => {
|
|
35
|
+
test("declared rows drive the min-height, not just the attribute", () => {
|
|
36
|
+
const textarea = renderTextarea(16);
|
|
37
|
+
expect(textarea.getAttribute("rows")).toBe("16");
|
|
38
|
+
expect(rowFactor(textarea.style.minHeight)).toBe(16);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("min-height scales with the declared row count", () => {
|
|
42
|
+
const small = rowFactor(renderTextarea(4).style.minHeight);
|
|
43
|
+
const large = rowFactor(renderTextarea(16).style.minHeight);
|
|
44
|
+
expect(large).toBeGreaterThan(small);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("without rows no min-height is imposed — the Tailwind floor stays", () => {
|
|
48
|
+
expect(renderTextarea().style.minHeight).toBe("");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a row count that is not a positive integer falls back to the default", () => {
|
|
52
|
+
for (const rows of [0, -3, 2.5]) {
|
|
53
|
+
const textarea = renderTextarea(rows);
|
|
54
|
+
expect(textarea.style.minHeight).toBe("");
|
|
55
|
+
expect(textarea.getAttribute("rows")).toBe("4");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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";
|
|
@@ -527,6 +528,22 @@ function SegmentedSelect({
|
|
|
527
528
|
);
|
|
528
529
|
}
|
|
529
530
|
|
|
531
|
+
// The row count reaches the textarea through an untyped view-model hint, so a
|
|
532
|
+
// sloppy schema can hand over a fraction or a zero — neither renders a sane
|
|
533
|
+
// attribute nor a sane min-height, so both fall back to the default instead.
|
|
534
|
+
function normalizedTextareaRows(rows: number | undefined): number | undefined {
|
|
535
|
+
return rows !== undefined && Number.isInteger(rows) && rows >= 1 ? rows : undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// The vendored shadcn Textarea carries `field-sizing: content`, which derives
|
|
539
|
+
// the box height from the content and makes the `rows` attribute inert (#2677).
|
|
540
|
+
// A declared row count therefore only survives as a min-height floor: the field
|
|
541
|
+
// starts at `rows` lines tall and still grows with its content. The addend is
|
|
542
|
+
// the textarea frame — py-2 top+bottom plus the 1px borders.
|
|
543
|
+
function textareaMinHeight(rows: number): CSSProperties {
|
|
544
|
+
return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
|
|
545
|
+
}
|
|
546
|
+
|
|
530
547
|
function DefaultInput(props: InputProps): ReactNode {
|
|
531
548
|
// Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
|
|
532
549
|
// selbst — kein manuelles border-destructive mehr nötig.
|
|
@@ -782,17 +799,20 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
782
799
|
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
783
800
|
/>
|
|
784
801
|
);
|
|
785
|
-
case "textarea":
|
|
802
|
+
case "textarea": {
|
|
803
|
+
const rows = normalizedTextareaRows(props.rows);
|
|
786
804
|
return (
|
|
787
805
|
<Textarea
|
|
788
806
|
{...common}
|
|
789
807
|
readOnly={props.readOnly}
|
|
790
808
|
value={props.value}
|
|
791
809
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
|
|
792
|
-
rows={
|
|
810
|
+
rows={rows ?? 4}
|
|
793
811
|
className="resize-y"
|
|
812
|
+
{...(rows !== undefined && { style: textareaMinHeight(rows) })}
|
|
794
813
|
/>
|
|
795
814
|
);
|
|
815
|
+
}
|
|
796
816
|
case "tz":
|
|
797
817
|
return (
|
|
798
818
|
<TzInput
|
|
@@ -2560,4 +2580,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2560
2580
|
Tabs: DefaultTabs,
|
|
2561
2581
|
StatusBadge: DefaultStatusBadge,
|
|
2562
2582
|
Metric: DefaultMetric,
|
|
2583
|
+
JsonView: DefaultJsonView,
|
|
2563
2584
|
};
|
|
@@ -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
|
* {
|