From 657d0340a272bcfe20086df8262315b8a78bbc88 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Wed, 25 Feb 2026 00:18:03 +0200 Subject: [PATCH 1/8] feat: add initial browser demo setup with Vite and React components --- .gitignore | 5 + apps/browser-demo/.gitignore | 2 + apps/browser-demo/eslint.config.mjs | 8 + apps/browser-demo/index.html | 12 + apps/browser-demo/package.json | 23 + apps/browser-demo/project.json | 28 + apps/browser-demo/scripts/buffer-shim.mjs | 13 + .../scripts/build-enclave-bundle.mjs | 30 + apps/browser-demo/src/App.css | 475 +++++++++++ apps/browser-demo/src/App.tsx | 114 +++ .../src/components/CodeEditor.tsx | 21 + .../src/components/ConsoleOutput.tsx | 38 + .../src/components/ExampleSnippets.tsx | 36 + .../src/components/ExecutionControls.tsx | 28 + .../src/components/ResultDisplay.tsx | 44 + .../src/components/SecurityLevelPicker.tsx | 38 + .../src/components/StatsPanel.tsx | 31 + .../src/components/ToolHandlerConfig.tsx | 31 + apps/browser-demo/src/data/examples.ts | 64 ++ apps/browser-demo/src/data/mock-tools.ts | 48 ++ apps/browser-demo/src/enclave-loader.ts | 14 + .../src/hooks/use-console-capture.ts | 50 ++ apps/browser-demo/src/hooks/use-enclave.ts | 78 ++ apps/browser-demo/src/main.tsx | 11 + apps/browser-demo/src/types.ts | 29 + apps/browser-demo/tsconfig.app.json | 12 + apps/browser-demo/tsconfig.json | 10 + apps/browser-demo/vite.config.ts | 15 + eslint.config.mjs | 2 +- libs/browser/e2e/buffer-shim.mjs | 13 + libs/browser/e2e/build-test-bundle.mjs | 34 + libs/browser/e2e/console-capture.spec.ts | 93 +++ libs/browser/e2e/execution.spec.ts | 120 +++ libs/browser/e2e/fixtures/debug-inner.html | 752 +++++++++++++++++ libs/browser/e2e/fixtures/debug-inner2.html | 755 ++++++++++++++++++ libs/browser/e2e/fixtures/debug-outer.html | 349 ++++++++ libs/browser/e2e/fixtures/serve.json | 3 + libs/browser/e2e/fixtures/test-harness.html | 14 + libs/browser/e2e/global-setup.ts | 10 + libs/browser/e2e/helpers.ts | 73 ++ libs/browser/e2e/protocol.spec.ts | 95 +++ libs/browser/e2e/security-isolation.spec.ts | 119 +++ libs/browser/e2e/timeout.spec.ts | 70 ++ libs/browser/e2e/tool-calls.spec.ts | 109 +++ libs/browser/e2e/validation.spec.ts | 104 +++ libs/browser/package.json | 44 + libs/browser/playwright.config.ts | 28 + libs/browser/project.json | 52 ++ libs/browser/src/adapters/iframe-adapter.ts | 319 ++++++++ .../src/adapters/iframe-html-builder.ts | 93 +++ libs/browser/src/adapters/iframe-protocol.ts | 243 ++++++ .../src/adapters/inner-iframe-bootstrap.ts | 714 +++++++++++++++++ .../src/adapters/outer-iframe-bootstrap.ts | 336 ++++++++ libs/browser/src/browser-enclave.ts | 450 +++++++++++ libs/browser/src/index.ts | 67 ++ libs/browser/src/types.ts | 344 ++++++++ libs/browser/src/utils/utf8-byte-length.ts | 20 + libs/browser/tsconfig.json | 28 + libs/browser/tsconfig.lib.json | 10 + package.json | 4 +- tsconfig.base.json | 3 +- yarn.lock | 709 +++++++++++++++- 62 files changed, 7473 insertions(+), 14 deletions(-) create mode 100644 apps/browser-demo/.gitignore create mode 100644 apps/browser-demo/eslint.config.mjs create mode 100644 apps/browser-demo/index.html create mode 100644 apps/browser-demo/package.json create mode 100644 apps/browser-demo/project.json create mode 100644 apps/browser-demo/scripts/buffer-shim.mjs create mode 100644 apps/browser-demo/scripts/build-enclave-bundle.mjs create mode 100644 apps/browser-demo/src/App.css create mode 100644 apps/browser-demo/src/App.tsx create mode 100644 apps/browser-demo/src/components/CodeEditor.tsx create mode 100644 apps/browser-demo/src/components/ConsoleOutput.tsx create mode 100644 apps/browser-demo/src/components/ExampleSnippets.tsx create mode 100644 apps/browser-demo/src/components/ExecutionControls.tsx create mode 100644 apps/browser-demo/src/components/ResultDisplay.tsx create mode 100644 apps/browser-demo/src/components/SecurityLevelPicker.tsx create mode 100644 apps/browser-demo/src/components/StatsPanel.tsx create mode 100644 apps/browser-demo/src/components/ToolHandlerConfig.tsx create mode 100644 apps/browser-demo/src/data/examples.ts create mode 100644 apps/browser-demo/src/data/mock-tools.ts create mode 100644 apps/browser-demo/src/enclave-loader.ts create mode 100644 apps/browser-demo/src/hooks/use-console-capture.ts create mode 100644 apps/browser-demo/src/hooks/use-enclave.ts create mode 100644 apps/browser-demo/src/main.tsx create mode 100644 apps/browser-demo/src/types.ts create mode 100644 apps/browser-demo/tsconfig.app.json create mode 100644 apps/browser-demo/tsconfig.json create mode 100644 apps/browser-demo/vite.config.ts create mode 100644 libs/browser/e2e/buffer-shim.mjs create mode 100644 libs/browser/e2e/build-test-bundle.mjs create mode 100644 libs/browser/e2e/console-capture.spec.ts create mode 100644 libs/browser/e2e/execution.spec.ts create mode 100644 libs/browser/e2e/fixtures/debug-inner.html create mode 100644 libs/browser/e2e/fixtures/debug-inner2.html create mode 100644 libs/browser/e2e/fixtures/debug-outer.html create mode 100644 libs/browser/e2e/fixtures/serve.json create mode 100644 libs/browser/e2e/fixtures/test-harness.html create mode 100644 libs/browser/e2e/global-setup.ts create mode 100644 libs/browser/e2e/helpers.ts create mode 100644 libs/browser/e2e/protocol.spec.ts create mode 100644 libs/browser/e2e/security-isolation.spec.ts create mode 100644 libs/browser/e2e/timeout.spec.ts create mode 100644 libs/browser/e2e/tool-calls.spec.ts create mode 100644 libs/browser/e2e/validation.spec.ts create mode 100644 libs/browser/package.json create mode 100644 libs/browser/playwright.config.ts create mode 100644 libs/browser/project.json create mode 100644 libs/browser/src/adapters/iframe-adapter.ts create mode 100644 libs/browser/src/adapters/iframe-html-builder.ts create mode 100644 libs/browser/src/adapters/iframe-protocol.ts create mode 100644 libs/browser/src/adapters/inner-iframe-bootstrap.ts create mode 100644 libs/browser/src/adapters/outer-iframe-bootstrap.ts create mode 100644 libs/browser/src/browser-enclave.ts create mode 100644 libs/browser/src/index.ts create mode 100644 libs/browser/src/types.ts create mode 100644 libs/browser/src/utils/utf8-byte-length.ts create mode 100644 libs/browser/tsconfig.json create mode 100644 libs/browser/tsconfig.lib.json diff --git a/.gitignore b/.gitignore index 0c12749..d348f14 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,8 @@ docs/docs.backup.json **/.cache/ perf-results.json + +# Playwright +test-results/ +playwright-report/ +libs/browser/e2e/fixtures/enclave-browser-bundle.mjs diff --git a/apps/browser-demo/.gitignore b/apps/browser-demo/.gitignore new file mode 100644 index 0000000..81112ee --- /dev/null +++ b/apps/browser-demo/.gitignore @@ -0,0 +1,2 @@ +# Generated enclave bundle +vendor/enclave-browser-bundle.mjs diff --git a/apps/browser-demo/eslint.config.mjs b/apps/browser-demo/eslint.config.mjs new file mode 100644 index 0000000..a23f0f2 --- /dev/null +++ b/apps/browser-demo/eslint.config.mjs @@ -0,0 +1,8 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + ignores: ['vendor/**'], + }, +]; diff --git a/apps/browser-demo/index.html b/apps/browser-demo/index.html new file mode 100644 index 0000000..884661d --- /dev/null +++ b/apps/browser-demo/index.html @@ -0,0 +1,12 @@ + + + + + + Browser Enclave Demo + + +
+ + + diff --git a/apps/browser-demo/package.json b/apps/browser-demo/package.json new file mode 100644 index 0000000..24e3bec --- /dev/null +++ b/apps/browser-demo/package.json @@ -0,0 +1,23 @@ +{ + "name": "browser-demo", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "prebuild": "node scripts/build-enclave-bundle.mjs", + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.5.2", + "esbuild": "^0.25.0", + "typescript": "~5.9.3", + "vite": "^6.3.5" + } +} diff --git a/apps/browser-demo/project.json b/apps/browser-demo/project.json new file mode 100644 index 0000000..a99a3c2 --- /dev/null +++ b/apps/browser-demo/project.json @@ -0,0 +1,28 @@ +{ + "name": "browser-demo", + "sourceRoot": "apps/browser-demo/src", + "projectType": "application", + "targets": { + "prebuild": { + "executor": "nx:run-commands", + "options": { + "command": "node apps/browser-demo/scripts/build-enclave-bundle.mjs" + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["prebuild"], + "options": { + "command": "npx vite --config apps/browser-demo/vite.config.ts" + } + }, + "build": { + "executor": "nx:run-commands", + "dependsOn": ["prebuild"], + "options": { + "command": "npx vite build --config apps/browser-demo/vite.config.ts" + }, + "outputs": ["{workspaceRoot}/dist/apps/browser-demo"] + } + } +} diff --git a/apps/browser-demo/scripts/buffer-shim.mjs b/apps/browser-demo/scripts/buffer-shim.mjs new file mode 100644 index 0000000..b8a2348 --- /dev/null +++ b/apps/browser-demo/scripts/buffer-shim.mjs @@ -0,0 +1,13 @@ +/** + * Minimal Buffer shim for the browser test bundle. + * Only provides Buffer.byteLength which is used by @enclave-vm/ast's size-check. + */ +const _encoder = new TextEncoder(); + +if (typeof globalThis.Buffer === 'undefined') { + globalThis.Buffer = { + byteLength(str) { + return _encoder.encode(String(str)).byteLength; + }, + }; +} diff --git a/apps/browser-demo/scripts/build-enclave-bundle.mjs b/apps/browser-demo/scripts/build-enclave-bundle.mjs new file mode 100644 index 0000000..16ed3fe --- /dev/null +++ b/apps/browser-demo/scripts/build-enclave-bundle.mjs @@ -0,0 +1,30 @@ +/** + * Build a self-contained ESM bundle of @enclave-vm/browser for the demo app. + * + * Inlines all dependencies (ast, acorn, astring, zod) into a single ESM file. + * Output goes to vendor/ so Vite can import it as a normal module. + */ + +import { build } from 'esbuild'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '../../..'); + +await build({ + entryPoints: [path.resolve(root, 'libs/browser/src/index.ts')], + bundle: true, + format: 'esm', + outfile: path.resolve(__dirname, '../vendor/enclave-browser-bundle.mjs'), + platform: 'browser', + target: 'es2022', + external: [], + alias: { + '@enclave-vm/ast': path.resolve(root, 'libs/ast/src/index.ts'), + }, + inject: [path.resolve(__dirname, 'buffer-shim.mjs')], + sourcemap: false, + minify: false, + logLevel: 'info', +}); diff --git a/apps/browser-demo/src/App.css b/apps/browser-demo/src/App.css new file mode 100644 index 0000000..ecb4e1a --- /dev/null +++ b/apps/browser-demo/src/App.css @@ -0,0 +1,475 @@ +/* ── Reset & Base ── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #1a1a2e; + --bg-panel: #16213e; + --bg-input: #0f172a; + --bg-hover: #1e3a5f; + --text: #eee; + --text-dim: #94a3b8; + --accent: #4fc3f7; + --accent-hover: #81d4fa; + --success: #81c784; + --warning: #ffb74d; + --error: #ef5350; + --border: #334155; + --radius: 6px; +} + +html, +body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +#root { + height: 100%; +} + +/* ── App Layout ── */ +.app { + display: flex; + flex-direction: column; + height: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 16px 24px; +} + +.app-header { + text-align: center; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.app-header h1 { + font-size: 1.5rem; + color: var(--accent); + font-weight: 600; +} + +.app-subtitle { + color: var(--text-dim); + font-size: 0.85rem; +} + +.app-main { + display: grid; + grid-template-columns: 240px 1fr 360px; + gap: 16px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ── Panels ── */ +.left-panel, +.right-panel { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; +} + +.center-panel { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +/* ── Section Label ── */ +.section-label { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-bottom: 6px; + font-weight: 600; +} + +/* ── Security Picker ── */ +.security-picker { + background: var(--bg-panel); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.segmented-buttons { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.seg-btn { + flex: 1; + padding: 6px 2px; + background: transparent; + color: var(--text-dim); + border: none; + cursor: pointer; + font-size: 0.7rem; + font-weight: 500; + transition: all 0.15s; +} + +.seg-btn:not(:last-child) { + border-right: 1px solid var(--border); +} + +.seg-btn:hover:not(:disabled) { + background: var(--bg-hover); +} + +.seg-btn.active { + background: var(--accent); + color: #000; + font-weight: 700; +} + +.seg-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.security-desc { + display: block; + font-size: 0.7rem; + color: var(--text-dim); + margin-top: 6px; +} + +/* ── Tool Config ── */ +.tool-config { + background: var(--bg-panel); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.toggle-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--text); + text-transform: none; + letter-spacing: normal; + font-weight: normal; + cursor: pointer; +} + +.toggle-label input[type='checkbox'] { + accent-color: var(--accent); +} + +.tool-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.tool-item { + display: flex; + flex-direction: column; + padding: 6px 8px; + background: var(--bg-input); + border-radius: 4px; + font-size: 0.75rem; +} + +.tool-item code { + color: var(--accent); + font-weight: 500; +} + +.tool-desc { + color: var(--text-dim); + font-size: 0.7rem; +} + +/* ── Examples ── */ +.examples { + background: var(--bg-panel); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.example-group { + margin-bottom: 8px; +} + +.example-category { + display: block; + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; + letter-spacing: 0.05em; +} + +.example-btn { + display: inline-block; + padding: 3px 8px; + margin: 2px; + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + font-size: 0.72rem; + transition: all 0.15s; +} + +.example-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.example-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Code Editor ── */ +.code-editor { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.code-textarea { + flex: 1; + min-height: 200px; + padding: 12px; + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace; + font-size: 0.85rem; + line-height: 1.6; + resize: none; + outline: none; + tab-size: 2; +} + +.code-textarea:focus { + border-color: var(--accent); +} + +.code-textarea:disabled { + opacity: 0.6; +} + +/* ── Execution Controls ── */ +.execution-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.run-btn { + padding: 8px 32px; + background: var(--accent); + color: #000; + border: none; + border-radius: var(--radius); + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.15s; +} + +.run-btn:hover:not(:disabled) { + background: var(--accent-hover); +} + +.run-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status-indicator { + font-size: 0.75rem; + font-weight: 500; +} + +.status-ready { + color: var(--success); +} + +.status-loading { + color: var(--warning); +} + +.status-error { + color: var(--error); +} + +/* ── Result Display ── */ +.result-display { + background: var(--bg-panel); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.result-empty, +.console-empty, +.stats-empty { + color: var(--text-dim); + font-size: 0.8rem; + font-style: italic; +} + +.result-content { + padding: 8px; + border-radius: 4px; +} + +.result-success { + border-left: 3px solid var(--success); + background: rgba(129, 199, 132, 0.08); +} + +.result-error { + border-left: 3px solid var(--error); + background: rgba(239, 83, 80, 0.08); +} + +.result-error-name { + color: var(--error); + font-weight: 700; + font-size: 0.85rem; + margin-bottom: 4px; +} + +.result-pre { + font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +/* ── Console Output ── */ +.console-output { + background: var(--bg-panel); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.console-entries { + max-height: 200px; + overflow-y: auto; +} + +.console-entry { + display: flex; + gap: 8px; + padding: 3px 6px; + font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace; + font-size: 0.75rem; + border-bottom: 1px solid rgba(51, 65, 85, 0.4); +} + +.console-level { + font-weight: 600; + flex-shrink: 0; +} + +.console-msg { + word-break: break-word; +} + +.console-log .console-level { + color: var(--text-dim); +} + +.console-info .console-level { + color: var(--accent); +} + +.console-warn .console-level { + color: var(--warning); +} + +.console-error .console-level { + color: var(--error); +} + +/* ── Stats Panel ── */ +.stats-panel { + background: var(--bg-panel); + padding: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px; + background: var(--bg-input); + border-radius: 4px; +} + +.stat-label { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-dim); + letter-spacing: 0.03em; +} + +.stat-value { + font-size: 1.1rem; + font-weight: 700; + color: var(--accent); + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +/* ── Responsive ── */ +@media (max-width: 960px) { + .app-main { + grid-template-columns: 1fr; + overflow-y: auto; + } + + .left-panel, + .right-panel { + overflow-y: visible; + } + + .code-textarea { + min-height: 150px; + } +} diff --git a/apps/browser-demo/src/App.tsx b/apps/browser-demo/src/App.tsx new file mode 100644 index 0000000..c42627e --- /dev/null +++ b/apps/browser-demo/src/App.tsx @@ -0,0 +1,114 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useEnclave } from './hooks/use-enclave'; +import { useConsoleCapture } from './hooks/use-console-capture'; +import { createToolHandler, mockTools } from './data/mock-tools'; +import { CodeEditor } from './components/CodeEditor'; +import { SecurityLevelPicker } from './components/SecurityLevelPicker'; +import { ExampleSnippets } from './components/ExampleSnippets'; +import { ToolHandlerConfig } from './components/ToolHandlerConfig'; +import { ExecutionControls } from './components/ExecutionControls'; +import { ConsoleOutput } from './components/ConsoleOutput'; +import { ResultDisplay } from './components/ResultDisplay'; +import { StatsPanel } from './components/StatsPanel'; +import type { SecurityLevel, ConsoleEntry, ExecutionStats } from './types'; + +export function App() { + const [code, setCode] = useState('return 2 + 2;'); + const [securityLevel, setSecurityLevel] = useState('STANDARD'); + const [toolsEnabled, setToolsEnabled] = useState(false); + const [running, setRunning] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [result, setResult] = useState(null); + const [consoleEntries, setConsoleEntries] = useState([]); + const [stats, setStats] = useState(null); + + const toolHandler = useMemo(() => (toolsEnabled ? createToolHandler(mockTools) : undefined), [toolsEnabled]); + + const { + ready, + loading: enclaveLoading, + error: enclaveError, + run, + } = useEnclave({ + securityLevel, + toolHandler, + }); + + const { startCapture, stopCapture } = useConsoleCapture(); + + const handleRun = useCallback(async () => { + if (!ready || running) return; + setRunning(true); + setResult(null); + setConsoleEntries([]); + setStats(null); + + startCapture(); + try { + const execResult = await run(code); + const captured = stopCapture(); + setConsoleEntries(captured); + setResult(execResult); + if (execResult.stats) { + setStats({ + duration: execResult.stats.duration, + toolCallCount: execResult.stats.toolCallCount, + iterationCount: execResult.stats.iterationCount, + }); + } + } catch (err) { + stopCapture(); + setResult({ + success: false, + error: { + name: 'AppError', + message: err instanceof Error ? err.message : String(err), + }, + stats: { duration: 0, toolCallCount: 0, iterationCount: 0 }, + }); + } finally { + setRunning(false); + } + }, [ready, running, code, run, startCapture, stopCapture]); + + const handleExampleSelect = useCallback((exampleCode: string) => { + setCode(exampleCode); + setResult(null); + setConsoleEntries([]); + setStats(null); + }, []); + + return ( +
+
+

@enclave-vm/browser Demo

+

Sandboxed JavaScript execution using double iframe isolation

+
+ +
+
+ + + +
+ +
+ + +
+ +
+ + + +
+
+
+ ); +} diff --git a/apps/browser-demo/src/components/CodeEditor.tsx b/apps/browser-demo/src/components/CodeEditor.tsx new file mode 100644 index 0000000..103f46a --- /dev/null +++ b/apps/browser-demo/src/components/CodeEditor.tsx @@ -0,0 +1,21 @@ +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export function CodeEditor({ value, onChange, disabled }: CodeEditorProps) { + return ( +
+ +