Fix counter, add input binding, syntax highlighting, dark mode

- Fix increment button: custom action handler instead of no-op setState
- Toggle visibility on Show/Hide Details button via $cond
- Input uses $bindState + useBoundProp for two-way binding
- Add shiki syntax highlighting (catppuccin-latte/mocha dual theme)
- Dark mode via prefers-color-scheme with CSS variables
- Layout: Live UI left, JSX Source + JSON Output stacked right

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-02-28 00:56:59 +00:00
parent af85ad3b07
commit 968faac7f5
8 changed files with 250 additions and 85 deletions

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef } from "react";
import { Renderer, StateProvider, ActionProvider, VisibilityProvider } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { registry, handlers } from "./registry";
import { useHighlight } from "./useHighlight";
const SPECS = ["simple", "full"] as const;
@@ -30,6 +31,22 @@ function SpecRenderer({ spec }: { spec: Spec }) {
);
}
function CodeBlock({ html, fallback, maxHeight = "40vh" }: { html: string; fallback: string; maxHeight?: string }) {
if (html) {
return (
<div
dangerouslySetInnerHTML={{ __html: html }}
style={{ borderRadius: "8px", overflow: "auto", maxHeight, fontSize: "13px", lineHeight: "1.5" }}
/>
);
}
return (
<pre style={{ backgroundColor: "var(--code-bg)", color: "var(--code-fg)", padding: "16px", borderRadius: "8px", fontSize: "13px", lineHeight: "1.5", overflow: "auto", maxHeight, margin: 0 }}>
{fallback}
</pre>
);
}
export function App() {
const [activeSpec, setActiveSpec] = useState<string | null>(null);
const [spec, setSpec] = useState<Spec | null>(null);
@@ -37,6 +54,9 @@ export function App() {
const [jsxSource, setJsxSource] = useState<string>("");
const [loading, setLoading] = useState(false);
const jsxHtml = useHighlight(jsxSource, "tsx");
const jsonHtml = useHighlight(specJson, "json");
useEffect(() => {
if (!activeSpec) return;
setLoading(true);
@@ -56,7 +76,7 @@ export function App() {
return (
<div style={{ maxWidth: 600, margin: "60px auto", padding: "0 24px" }}>
<h1 style={{ fontSize: "24px", marginBottom: "8px" }}>jrx examples</h1>
<p style={{ color: "#666", fontSize: "14px", marginBottom: "24px" }}>
<p style={{ color: "var(--text-secondary)", fontSize: "14px", marginBottom: "24px" }}>
JSX &rarr; json-render Spec. Pick a spec to see the live UI and JSON
output.
</p>
@@ -68,15 +88,16 @@ export function App() {
style={{
padding: "12px 16px",
fontSize: "15px",
backgroundColor: "#fff",
border: "1px solid #d0d0d0",
backgroundColor: "var(--bg-surface)",
color: "var(--text)",
border: "1px solid var(--border)",
borderRadius: "8px",
cursor: "pointer",
textAlign: "left",
}}
>
<strong>{name}</strong>
<span style={{ color: "#888", marginLeft: "8px" }}>
<span style={{ color: "var(--text-muted)", marginLeft: "8px" }}>
{name === "simple"
? "Flat elements, no state"
: "Nested layout with state, events, visibility, watchers"}
@@ -93,8 +114,8 @@ export function App() {
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #e0e0e0",
backgroundColor: "#fff",
borderBottom: "1px solid var(--border-light)",
backgroundColor: "var(--bg-surface)",
display: "flex",
alignItems: "center",
gap: "16px",
@@ -108,7 +129,7 @@ export function App() {
style={{
background: "none",
border: "none",
color: "#4f46e5",
color: "var(--accent)",
cursor: "pointer",
fontSize: "14px",
}}
@@ -119,69 +140,39 @@ export function App() {
</div>
{loading ? (
<p style={{ padding: "24px", color: "#888" }}>Loading...</p>
<p style={{ padding: "24px", color: "var(--text-muted)" }}>Loading...</p>
) : (
spec && (
<div style={{ padding: "24px", display: "flex", flexDirection: "column", gap: "24px" }}>
{/* JSX Source */}
<div>
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#111" }}>
JSX Source
<div style={{ display: "flex", gap: "24px", padding: "24px", alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px" }}>
Live UI
</h2>
<pre
<div
style={{
backgroundColor: "#1e1e2e",
color: "#cdd6f4",
padding: "16px",
border: "1px solid var(--border-light)",
borderRadius: "8px",
fontSize: "13px",
lineHeight: "1.5",
overflow: "auto",
maxHeight: "40vh",
margin: 0,
padding: "16px",
backgroundColor: "var(--bg-surface)",
}}
>
{jsxSource}
</pre>
<SpecRenderer spec={spec} />
</div>
</div>
{/* Live UI + JSON side by side */}
<div style={{ display: "flex", gap: "24px", alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#111" }}>
Live UI
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: "24px" }}>
<div>
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px" }}>
JSX Source
</h2>
<div
style={{
border: "1px solid #e0e0e0",
borderRadius: "8px",
padding: "16px",
backgroundColor: "#fff",
}}
>
<SpecRenderer spec={spec} />
</div>
<CodeBlock html={jsxHtml} fallback={jsxSource} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#111" }}>
<div>
<h2 style={{ margin: "0 0 12px 0", fontSize: "16px" }}>
JSON Output
</h2>
<pre
style={{
backgroundColor: "#1e1e2e",
color: "#cdd6f4",
padding: "16px",
borderRadius: "8px",
fontSize: "13px",
lineHeight: "1.5",
overflow: "auto",
maxHeight: "80vh",
margin: 0,
}}
>
{specJson}
</pre>
<CodeBlock html={jsonHtml} fallback={specJson} />
</div>
</div>
</div>

View File

@@ -34,8 +34,9 @@ export const catalog = defineCatalog(schema, {
Input: {
props: z.object({
placeholder: z.string().nullable(),
value: z.string().nullable(),
}),
description: "Text input field",
description: "Text input field. Use { $bindState } on value for two-way binding.",
},
},
actions: {

View File

@@ -5,8 +5,50 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jrx example</title>
<style>
:root {
--bg: #f5f5f5;
--bg-surface: #fff;
--bg-surface-alt: #fafafa;
--text: #111;
--text-secondary: #666;
--text-muted: #888;
--text-body: #444;
--border: #d0d0d0;
--border-light: #e0e0e0;
--accent: #4f46e5;
--code-bg: #1e1e2e;
--code-fg: #cdd6f4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a2e;
--bg-surface: #232340;
--bg-surface-alt: #2a2a4a;
--text: #e0e0e0;
--text-secondary: #a0a0b0;
--text-muted: #808090;
--text-body: #c0c0d0;
--border: #3a3a5a;
--border-light: #333355;
--accent: #818cf8;
--code-bg: #11111b;
--code-fg: #cdd6f4;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #111; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); }
/* Shiki dual theme */
.shiki,
.shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; }
}
/* Code block padding */
.shiki { padding: 16px; border-radius: 8px; }
</style>
</head>
<body>

View File

@@ -1,4 +1,4 @@
import { defineRegistry } from "@json-render/react";
import { defineRegistry, useBoundProp } from "@json-render/react";
import { catalog } from "./catalog";
export const { registry, handlers } = defineRegistry(catalog, {
@@ -19,14 +19,14 @@ export const { registry, handlers } = defineRegistry(catalog, {
Card: ({ props, children }) => (
<div
style={{
border: "1px solid #d0d0d0",
border: "1px solid var(--border)",
borderRadius: "8px",
padding: "16px",
backgroundColor: "#fafafa",
backgroundColor: "var(--bg-surface-alt)",
}}
>
{props.title && (
<h3 style={{ margin: "0 0 12px 0", fontSize: "16px", color: "#333" }}>
<h3 style={{ margin: "0 0 12px 0", fontSize: "16px" }}>
{props.title}
</h3>
)}
@@ -37,7 +37,7 @@ export const { registry, handlers } = defineRegistry(catalog, {
),
Text: ({ props }) => (
<span style={{ fontSize: "14px", color: "#444" }}>
<span style={{ fontSize: "14px", color: "var(--text-body)" }}>
{String(props.content ?? "")}
</span>
),
@@ -48,7 +48,7 @@ export const { registry, handlers } = defineRegistry(catalog, {
style={{
padding: "8px 16px",
fontSize: "14px",
backgroundColor: "#4f46e5",
backgroundColor: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: "6px",
@@ -60,19 +60,25 @@ export const { registry, handlers } = defineRegistry(catalog, {
</button>
),
Input: ({ props, emit }) => (
<input
placeholder={props.placeholder ?? ""}
onChange={() => emit("change")}
style={{
padding: "8px 12px",
fontSize: "14px",
border: "1px solid #d0d0d0",
borderRadius: "6px",
outline: "none",
}}
/>
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
return (
<input
placeholder={props.placeholder ?? ""}
value={value ?? ""}
onChange={(e) => setValue(e.target.value)}
style={{
padding: "8px 12px",
fontSize: "14px",
border: "1px solid var(--border)",
borderRadius: "6px",
outline: "none",
backgroundColor: "var(--bg-surface)",
color: "var(--text)",
}}
/>
);
},
},
actions: {

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from "react";
import { createHighlighter, type Highlighter } from "shiki";
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ["catppuccin-mocha", "catppuccin-latte"],
langs: ["tsx", "json"],
});
}
return highlighterPromise;
}
export function useHighlight(code: string, lang: "tsx" | "json"): string {
const [html, setHtml] = useState("");
useEffect(() => {
if (!code) return;
let cancelled = false;
getHighlighter().then((highlighter) => {
if (cancelled) return;
setHtml(
highlighter.codeToHtml(code, {
lang,
themes: {
light: "catppuccin-latte",
dark: "catppuccin-mocha",
},
}),
);
});
return () => {
cancelled = true;
};
}, [code, lang]);
return html;
}