From 0d470a918afa6ea13c5b132fe4f3bee7472bc504 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 12:21:39 +0000 Subject: [PATCH 1/2] IMPLEMENT: multi-schema rendering in Schema panel (closes #236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Zod / JSON Schema / Convex tab strip to SchemaPanel so users can switch between all three generated formats. Pre-warms the Shiki highlighter on component mount to eliminate the colour-delay UX snag. Key decisions: - Single `error` reflects Zod failure (all schemas share the same IR; if Zod fails the others almost certainly will too). JSON/Convex emit failures are non-fatal so their tabs just render empty. - `deriveFileName` maps the Zod filename to the per-type filename (`*.schema.ts` → `*.schema.json` / `convex/schema.ts`). - JSON lang added to the Shiki highlighter for JSON Schema syntax colouring. - `warmHighlighter()` exported for future use; pre-warm effect fires on SchemaPanel mount (app startup) rather than on first tab open. Files changed: - apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx - apps/desktop/src/renderer/src/components/schema/SchemaPanel.stories.tsx - apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts - apps/desktop/src/renderer/src/App.tsx - apps/desktop/tests/components/schema/SchemaPanel.test.tsx --- apps/desktop/src/renderer/src/App.tsx | 52 +++++-- .../components/schema/SchemaPanel.stories.tsx | 2 + .../src/components/schema/SchemaPanel.tsx | 138 ++++++++++++------ .../components/schema/shiki-highlighter.ts | 10 +- .../components/schema/SchemaPanel.test.tsx | 104 ++++++++++--- 5 files changed, 227 insertions(+), 79 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 630e0d4..0d437bd 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -54,6 +54,7 @@ import { useDrift } from './hooks/useDrift'; import { useFileMenu } from './hooks/useFileMenu'; import { useNewProject } from './hooks/useNewProject'; import { useProjectAutoSave } from './hooks/useProjectAutoSave'; +import { emitConvexSchema } from './model/emit-convex'; import { emit as emitJsonSchema } from './model/emit-json-schema'; import { emit as emitZod } from './model/emit-zod'; import allotment from './samples/allotment.contexture.json' with { type: 'json' }; @@ -246,18 +247,45 @@ export default function App(): React.JSX.Element { // crashing the sidebar. const filePath = useDocumentStore((s) => s.filePath); const documentMode = useDocumentStore((s) => s.mode); - const zodEmission = useMemo((): { source: string; error: string | null } => { - if (activeTab !== 'schema') return { source: '', error: null }; + + // Emit all three schema formats when the Schema tab is active. + // Gated on activeTab so we don't burn cycles on every IR change when + // the user is looking at Chat / Eval / Properties. + const schemaEmissions = useMemo((): { + zodSource: string; + jsonSource: string; + convexSource: string; + error: string | null; + } => { + if (activeTab !== 'schema') { + return { zodSource: '', jsonSource: '', convexSource: '', error: null }; + } + const irPath = filePath ?? '.contexture.json'; + let zodSource = ''; + let jsonSource = ''; + let convexSource = ''; + let error: string | null = null; + try { - return { - source: emitZod(schema, filePath ?? '.contexture.json', { - stdlibNamespaces: STDLIB_REGISTRY.namespaces, - }), - error: null, - }; + zodSource = emitZod(schema, irPath, { stdlibNamespaces: STDLIB_REGISTRY.namespaces }); } catch (e) { - return { source: '', error: e instanceof Error ? e.message : String(e) }; + error = e instanceof Error ? e.message : String(e); + return { zodSource, jsonSource, convexSource, error }; + } + + try { + jsonSource = JSON.stringify(emitJsonSchema(schema, undefined, irPath), null, 2); + } catch { + // Non-fatal: JSON Schema tab will render empty } + + try { + convexSource = emitConvexSchema(schema, irPath); + } catch { + // Non-fatal: Convex tab will render empty + } + + return { zodSource, jsonSource, convexSource, error }; }, [activeTab, schema, filePath]); // Filename shown in the SchemaPanel header: the document's basename @@ -352,9 +380,11 @@ export default function App(): React.JSX.Element {
diff --git a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.stories.tsx b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.stories.tsx index 36057ea..6251641 100644 --- a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.stories.tsx +++ b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.stories.tsx @@ -23,6 +23,8 @@ const meta = { component: SchemaPanel, args: { onCopy: fn(), + jsonSource: '', + convexSource: '', }, parameters: { layout: 'fullscreen', diff --git a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx index a6ea438..58d52db 100644 --- a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx +++ b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx @@ -1,39 +1,28 @@ /** - * SchemaPanel — read-only preview of the emitted Zod TypeScript - * source. + * SchemaPanel — read-only preview of the emitted schema source. * - * The canvas IR is the source of truth; this panel shows exactly - * what would be written to `.schema.ts` on save, re-rendered - * whenever the caller hands us a new `zodSource` string (the - * caller gates re-emission on `activeTab === 'schema'` so we only - * do the work when the user is looking). + * Supports three schema formats via an in-panel tab strip: + * - Zod (TypeScript) → .schema.ts + * - JSON Schema → .schema.json + * - Convex → convex/schema.ts * * Three visual states: * - Empty (schema has no types): an `Empty` nudge telling the * user to add a type on the canvas. * - Error (emit threw): a muted error line. Transient — the * next valid IR clears it on re-render. - * - OK: a header with the derived schema filename + font-size - * and copy controls, above a shiki-highlighted code block. - * Horizontal scroll, no wrap — preserves the emitter's exact - * formatting. + * - OK: a tab strip, header with filename + font-size and copy + * controls, above a shiki-highlighted code block. * - * The header mirrors the shadcn `ai-elements` CodeBlock pattern - * used on the marketing site's /brand page so the two surfaces - * feel like the same component family. + * Shiki init is lazy (first mount) via `getHighlighter`. We pre-warm + * the highlighter on mount so it loads in the background — by the time + * the user opens the panel it is usually already initialised. While + * loading, a plain `
` fallback keeps the code readable immediately.
  *
- * Shiki init is lazy (first mount) via `getHighlighter`. While
- * the highlighter is still loading we render a plain `
`
- * fallback so the code is readable immediately.
- *
- * Security note: the highlighted HTML is injected via shiki's
- * escaped output (see rendering block below). Shiki tokenises
- * input via TextMate grammars and emits its own HTML with all
- * user text escaped in `` text nodes — there is no path
- * for user-authored Zod source to introduce raw tags. The
- * alternative (`hast-util-to-jsx-runtime`) buys nothing here
- * since the source is already trusted local output from
- * `emit-zod`.
+ * Security note: the highlighted HTML is injected via shiki's escaped
+ * output. Shiki tokenises input via TextMate grammars and emits its own
+ * HTML with all user text escaped in `` text nodes — there is no
+ * path for user-authored source to introduce raw tags.
  */
 import { AArrowDown, AArrowUp, Check, Copy, FileBracesCorner, FileCode } from 'lucide-react';
 import { useEffect, useRef, useState } from 'react';
@@ -41,12 +30,18 @@ import { Button } from '../ui/button';
 import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
 import { getHighlighter, SHIKI_THEMES } from './shiki-highlighter';
 
+export type SchemaType = 'zod' | 'json' | 'convex';
+
 export interface SchemaPanelProps {
-  /** Emitted Zod TypeScript. Non-empty even for empty schemas (header + z import). */
+  /** Emitted Zod TypeScript source. */
   zodSource: string;
+  /** Emitted JSON Schema (pre-stringified JSON). */
+  jsonSource: string;
+  /** Emitted Convex schema TypeScript source. */
+  convexSource: string;
   /** True when the IR has zero types; drives the empty state. */
   isEmpty: boolean;
-  /** Non-null when `emit()` threw. The message is rendered as-is. */
+  /** Non-null when the primary (Zod) emit threw. Rendered as-is. */
   error: string | null;
   /** Copy full source to clipboard — host wires `navigator.clipboard`. */
   onCopy?: (text: string) => void;
@@ -69,24 +64,52 @@ const DEFAULT_FONT_SIZE_INDEX = 2; // 13px
 /** How long the Copy icon flips to a check after a successful copy. */
 const COPY_FEEDBACK_MS = 2000;
 
+const SCHEMA_TABS: { type: SchemaType; label: string }[] = [
+  { type: 'zod', label: 'Zod' },
+  { type: 'json', label: 'JSON Schema' },
+  { type: 'convex', label: 'Convex' },
+];
+
+function deriveFileName(schemaFileName: string, type: SchemaType): string {
+  if (type === 'zod') return schemaFileName;
+  if (type === 'convex') return 'convex/schema.ts';
+  // json: replace .schema.ts → .schema.json, or .ts → .json as fallback
+  const replaced = schemaFileName.replace(/\.schema\.ts$/i, '.schema.json');
+  return replaced !== schemaFileName ? replaced : schemaFileName.replace(/\.ts$/i, '.json');
+}
+
+function langForType(type: SchemaType): string {
+  return type === 'json' ? 'json' : 'typescript';
+}
+
 export function SchemaPanel({
   zodSource,
+  jsonSource,
+  convexSource,
   isEmpty,
   error,
   onCopy,
   schemaFileName = 'schema.ts',
 }: SchemaPanelProps): React.JSX.Element {
+  const [activeSchema, setActiveSchema] = useState('zod');
   const [highlightedHtml, setHighlightedHtml] = useState(null);
   const [fontSizeIndex, setFontSizeIndex] = useState(DEFAULT_FONT_SIZE_INDEX);
   const [copied, setCopied] = useState(false);
   const copyTimeoutRef = useRef(null);
 
-  // Re-highlight whenever the source changes. `codeToHtml` is sync
-  // after init, so we only `await` the highlighter itself. The
-  // effect bails for the empty / error states which don't render
-  // the code block.
+  // Pre-warm shiki on first mount so it loads in the background.
+  // By the time the user opens the Schema tab the highlighter is
+  // usually already initialised and the code colours appear immediately.
   useEffect(() => {
-    if (isEmpty || error !== null || zodSource === '') {
+    getHighlighter().catch(() => undefined);
+  }, []);
+
+  const activeSource =
+    activeSchema === 'zod' ? zodSource : activeSchema === 'json' ? jsonSource : convexSource;
+
+  // Re-highlight whenever the active source or schema type changes.
+  useEffect(() => {
+    if (isEmpty || error !== null || activeSource === '') {
       setHighlightedHtml(null);
       return;
     }
@@ -95,26 +118,22 @@ export function SchemaPanel({
       try {
         const hl = await getHighlighter();
         if (cancelled) return;
-        const html = hl.codeToHtml(zodSource, {
-          lang: 'typescript',
+        const html = hl.codeToHtml(activeSource, {
+          lang: langForType(activeSchema),
           themes: SHIKI_THEMES,
           defaultColor: false,
         });
         setHighlightedHtml(html);
       } catch {
-        // Highlighter init or render failed — fall back to plain
-        // 
. Don't surface to the user; the source itself is
-        // still readable.
         if (!cancelled) setHighlightedHtml(null);
       }
     })();
     return () => {
       cancelled = true;
     };
-  }, [zodSource, isEmpty, error]);
+  }, [activeSource, activeSchema, isEmpty, error]);
 
-  // Clear the "copied" feedback timer on unmount so a late-firing
-  // setState can't hit an unmounted component.
+  // Clear the "copied" feedback timer on unmount.
   useEffect(
     () => () => {
       if (copyTimeoutRef.current !== null) {
@@ -125,7 +144,7 @@ export function SchemaPanel({
   );
 
   const handleCopy = (): void => {
-    onCopy?.(zodSource);
+    onCopy?.(activeSource);
     setCopied(true);
     if (copyTimeoutRef.current !== null) {
       window.clearTimeout(copyTimeoutRef.current);
@@ -150,7 +169,7 @@ export function SchemaPanel({
             
             No schema yet
             
-              Add a type to the canvas to see the generated Zod schema.
+              Add a type to the canvas to see the generated schemas.
             
           
         
@@ -166,20 +185,47 @@ export function SchemaPanel({
             data-testid="schema-error"
             className="font-mono text-xs text-destructive whitespace-pre-wrap"
           >
-            Couldn't emit Zod: {error}
+            Couldn't emit schema: {error}
           

); } + const displayFileName = deriveFileName(schemaFileName, activeSchema); + return (
+ {/* Schema type tab strip */} +
+ {SCHEMA_TABS.map(({ type, label }) => ( + + ))} +
+
- {schemaFileName} + {displayFileName}
diff --git a/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts b/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts index 462ea6c..a1683d5 100644 --- a/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts +++ b/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts @@ -20,14 +20,15 @@ let highlighterPromise: Promise | null = null; export function getHighlighter(): Promise { if (highlighterPromise === null) { highlighterPromise = (async () => { - const [ts, light, dark] = await Promise.all([ + const [ts, json, light, dark] = await Promise.all([ import('shiki/langs/typescript.mjs'), + import('shiki/langs/json.mjs'), import('shiki/themes/github-light.mjs'), import('shiki/themes/github-dark.mjs'), ]); return createHighlighterCore({ themes: [light.default, dark.default], - langs: [ts.default], + langs: [ts.default, json.default], engine: createJavaScriptRegexEngine(), }); })(); @@ -35,6 +36,11 @@ export function getHighlighter(): Promise { return highlighterPromise; } +/** Call once at app startup to pre-warm the highlighter before the user opens the Schema tab. */ +export function warmHighlighter(): void { + getHighlighter().catch(() => undefined); +} + /** * Light/dark theme names passed to `codeToHtml`. Centralised so the * panel and any future caller stay in sync with what the highlighter diff --git a/apps/desktop/tests/components/schema/SchemaPanel.test.tsx b/apps/desktop/tests/components/schema/SchemaPanel.test.tsx index 3bee666..d12639d 100644 --- a/apps/desktop/tests/components/schema/SchemaPanel.test.tsx +++ b/apps/desktop/tests/components/schema/SchemaPanel.test.tsx @@ -1,5 +1,5 @@ /** - * SchemaPanel — renders the emitted Zod source handed to it. + * SchemaPanel — renders the emitted schema source handed to it. * * Shiki is mocked (the real highlighter does async WASM-free init * we don't need here); the panel falls back to a plain
 when
@@ -24,9 +24,17 @@ afterEach(() => {
   vi.clearAllMocks();
 });
 
+const DEFAULT_PROPS = {
+  zodSource: '',
+  jsonSource: '',
+  convexSource: '',
+  isEmpty: false,
+  error: null,
+} as const;
+
 describe('SchemaPanel', () => {
   it('renders the empty state when the schema has no types', () => {
-    render();
+    render();
     expect(screen.getByText(/no schema yet/i)).toBeInTheDocument();
     expect(screen.queryByTestId('schema-copy')).not.toBeInTheDocument();
     expect(screen.queryByTestId('schema-code')).not.toBeInTheDocument();
@@ -34,29 +42,24 @@ describe('SchemaPanel', () => {
 
   it('renders the emitted source in a 
 while shiki initialises', () => {
     const source = "// Generated\nimport { z } from 'zod';\nexport const Foo = z.object({});\n";
-    render();
+    render();
     const code = screen.getByTestId('schema-code');
     expect(code).toBeInTheDocument();
     expect(code.textContent).toContain("import { z } from 'zod'");
     expect(code.textContent).toContain('export const Foo = z.object({})');
   });
 
-  it('invokes onCopy with the full source when Copy is clicked', () => {
+  it('invokes onCopy with the zod source when Copy is clicked on the Zod tab', () => {
     const source = '// code\nexport const A = 1;\n';
     const onCopy = vi.fn();
-    render();
+    render();
     fireEvent.click(screen.getByTestId('schema-copy'));
     expect(onCopy).toHaveBeenCalledWith(source);
   });
 
   it('renders an error message when `error` is non-null and hides the code/copy controls', () => {
     render(
-      ,
+      ,
     );
     const err = screen.getByTestId('schema-error');
     expect(err).toBeInTheDocument();
@@ -66,7 +69,9 @@ describe('SchemaPanel', () => {
   });
 
   it('prefers the empty state over the error state when the schema is empty', () => {
-    render();
+    render(
+      ,
+    );
     expect(screen.getByText(/no schema yet/i)).toBeInTheDocument();
     expect(screen.queryByTestId('schema-error')).not.toBeInTheDocument();
   });
@@ -74,22 +79,17 @@ describe('SchemaPanel', () => {
   it('shows the supplied filename in the header and a default when none is given', () => {
     const source = 'export const A = 1;\n';
     const { rerender } = render(
-      ,
+      ,
     );
     expect(screen.getByTestId('schema-filename').textContent).toContain('allotment.schema.ts');
 
-    rerender();
+    rerender();
     expect(screen.getByTestId('schema-filename').textContent).toContain('schema.ts');
   });
 
   it('steps the code font size up and down and disables at the bounds', () => {
     const source = 'export const A = 1;\n';
-    render();
+    render();
     const code = screen.getByTestId('schema-code') as HTMLElement;
     const decrease = screen.getByTestId('schema-font-decrease');
     const increase = screen.getByTestId('schema-font-increase');
@@ -110,4 +110,68 @@ describe('SchemaPanel', () => {
     expect(code.style.fontSize).toBe('20px');
     expect(increase).toBeDisabled();
   });
+
+  describe('multi-schema tabs', () => {
+    it('shows Zod tab active by default', () => {
+      const zodSrc = "import { z } from 'zod';\n";
+      render();
+      const zodTab = screen.getByTestId('schema-tab-zod');
+      expect(zodTab).toHaveAttribute('aria-selected', 'true');
+    });
+
+    it('switches to JSON Schema source when the JSON tab is clicked', () => {
+      const zodSrc = "import { z } from 'zod';\n";
+      const jsonSrc = '{\n  "$schema": "https://json-schema.org/draft/2020-12/schema"\n}';
+      render();
+
+      fireEvent.click(screen.getByTestId('schema-tab-json'));
+      expect(screen.getByTestId('schema-code').textContent).toContain('$schema');
+    });
+
+    it('switches to Convex source when the Convex tab is clicked', () => {
+      const convexSrc = 'import { defineSchema } from "convex/server";\n';
+      render();
+
+      fireEvent.click(screen.getByTestId('schema-tab-convex'));
+      expect(screen.getByTestId('schema-code').textContent).toContain('defineSchema');
+    });
+
+    it('copies the active tab source when Copy is clicked', () => {
+      const jsonSrc = '{ "$schema": "..." }';
+      const onCopy = vi.fn();
+      render(
+        ,
+      );
+
+      fireEvent.click(screen.getByTestId('schema-tab-json'));
+      fireEvent.click(screen.getByTestId('schema-copy'));
+      expect(onCopy).toHaveBeenCalledWith(jsonSrc);
+    });
+
+    it('shows the JSON Schema filename when on the JSON tab', () => {
+      render(
+        ,
+      );
+      fireEvent.click(screen.getByTestId('schema-tab-json'));
+      expect(screen.getByTestId('schema-filename').textContent).toContain('allotment.schema.json');
+    });
+
+    it('shows the Convex filename when on the Convex tab', () => {
+      render(
+        ,
+      );
+      fireEvent.click(screen.getByTestId('schema-tab-convex'));
+      expect(screen.getByTestId('schema-filename').textContent).toContain('convex/schema.ts');
+    });
+  });
 });

From eb363acf663d545d3ef0ef2207621e53b7b2462f Mon Sep 17 00:00:00 2001
From: Dave Hudson 
Date: Fri, 1 May 2026 12:24:25 +0000
Subject: [PATCH 2/2] REVIEW: clean up nested ternary, dead export, and add
 filename fallback test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Replace nested ternary for activeSource with a Record
  lookup — coding standards prohibit nested ternaries
- Remove warmHighlighter() from shiki-highlighter: it was exported but never
  called; pre-warming already happens in SchemaPanel's mount effect
- Add test covering deriveFileName's .ts → .json fallback branch (the path
  taken when schemaFileName is the default 'schema.ts', which has no
  '.schema.ts' segment)
---
 .../src/renderer/src/components/schema/SchemaPanel.tsx    | 8 ++++++--
 .../renderer/src/components/schema/shiki-highlighter.ts   | 5 -----
 apps/desktop/tests/components/schema/SchemaPanel.test.tsx | 8 ++++++++
 3 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx
index 58d52db..f12e825 100644
--- a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx
+++ b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx
@@ -104,8 +104,12 @@ export function SchemaPanel({
     getHighlighter().catch(() => undefined);
   }, []);
 
-  const activeSource =
-    activeSchema === 'zod' ? zodSource : activeSchema === 'json' ? jsonSource : convexSource;
+  const sourceByType: Record = {
+    zod: zodSource,
+    json: jsonSource,
+    convex: convexSource,
+  };
+  const activeSource = sourceByType[activeSchema];
 
   // Re-highlight whenever the active source or schema type changes.
   useEffect(() => {
diff --git a/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts b/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts
index a1683d5..18ebb61 100644
--- a/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts
+++ b/apps/desktop/src/renderer/src/components/schema/shiki-highlighter.ts
@@ -36,11 +36,6 @@ export function getHighlighter(): Promise {
   return highlighterPromise;
 }
 
-/** Call once at app startup to pre-warm the highlighter before the user opens the Schema tab. */
-export function warmHighlighter(): void {
-  getHighlighter().catch(() => undefined);
-}
-
 /**
  * Light/dark theme names passed to `codeToHtml`. Centralised so the
  * panel and any future caller stay in sync with what the highlighter
diff --git a/apps/desktop/tests/components/schema/SchemaPanel.test.tsx b/apps/desktop/tests/components/schema/SchemaPanel.test.tsx
index d12639d..30fb65a 100644
--- a/apps/desktop/tests/components/schema/SchemaPanel.test.tsx
+++ b/apps/desktop/tests/components/schema/SchemaPanel.test.tsx
@@ -173,5 +173,13 @@ describe('SchemaPanel', () => {
       fireEvent.click(screen.getByTestId('schema-tab-convex'));
       expect(screen.getByTestId('schema-filename').textContent).toContain('convex/schema.ts');
     });
+
+    it('derives JSON filename via .ts → .json fallback when name has no .schema.ts suffix', () => {
+      render();
+      // Default schemaFileName is 'schema.ts' — no '.schema.ts' segment, so the
+      // fallback .ts → .json replacement must fire.
+      fireEvent.click(screen.getByTestId('schema-tab-json'));
+      expect(screen.getByTestId('schema-filename').textContent).toContain('schema.json');
+    });
   });
 });