diff --git a/.changeset/dull-weeks-sell.md b/.changeset/dull-weeks-sell.md new file mode 100644 index 0000000..5f088e7 --- /dev/null +++ b/.changeset/dull-weeks-sell.md @@ -0,0 +1,10 @@ +--- +"@mobile-reality/mdma-renderer-react": major +"@mobile-reality/mdma-prompt-pack": major +"@mobile-reality/mdma-validator": major +"@mobile-reality/mdma-runtime": major +"@mobile-reality/mdma-parser": major +"@mobile-reality/mdma-demo": patch +--- + +Added validator to the project diff --git a/biome.json b/biome.json index a04e50b..d494e18 100644 --- a/biome.json +++ b/biome.json @@ -40,6 +40,12 @@ } }, "files": { - "ignore": ["**/dist/**", "**/node_modules/**", "**/.turbo/**", "**/pnpm-lock.yaml"] + "ignore": [ + "**/dist/**", + "**/node_modules/**", + "**/.turbo/**", + "**/pnpm-lock.yaml", + "evals/results*.json" + ] } } diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 4e62bbe..0b4aa16 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -28,9 +28,7 @@ interface NavGroup { const NAV_GROUPS: NavGroup[] = [ { label: 'Documents', - items: [ - { mode: 'examples', label: 'Examples' }, - ], + items: [{ mode: 'examples', label: 'Examples' }], }, { label: 'AI', @@ -42,10 +40,7 @@ const NAV_GROUPS: NavGroup[] = [ }, { label: 'Tools', - items: [ - { mode: 'validator', label: 'Validator' }, - { mode: 'stepper', label: 'Stepper' }, - ], + items: [{ mode: 'validator', label: 'Validator' }], }, ]; @@ -152,7 +147,14 @@ export function App() { onClick={() => setDropdownOpen((v) => !v)} > {getModeLabel(mode)} - + @@ -166,7 +168,10 @@ export function App() { key={item.mode} type="button" className={`demo-nav-item ${mode === item.mode ? 'demo-nav-item--active' : ''}`} - onClick={() => { setMode(item.mode); setDropdownOpen(false); }} + onClick={() => { + setMode(item.mode); + setDropdownOpen(false); + }} > {item.label} @@ -192,9 +197,7 @@ export function App() { - {mode === 'stepper' ? ( - - ) : mode === 'validator' ? ( + {mode === 'validator' ? ( ) : mode === 'playground' ? ( @@ -214,9 +217,7 @@ export function App() {

Event Log

{events.length === 0 && ( -

- Interact with the document to see events here. -

+

Interact with the document to see events here.

)} {events.map((entry) => (
@@ -224,12 +225,11 @@ export function App() { {entry.action.type} - - {entry.action.componentId} - + {entry.action.componentId} {'field' in entry.action && ( - .{entry.action.field} = {JSON.stringify((entry.action as { value: unknown }).value)} + .{entry.action.field} ={' '} + {JSON.stringify((entry.action as { value: unknown }).value)} )} {'actionId' in entry.action && ( diff --git a/demo/src/ChatView.tsx b/demo/src/ChatView.tsx index fbb94c6..50efc1b 100644 --- a/demo/src/ChatView.tsx +++ b/demo/src/ChatView.tsx @@ -27,7 +27,13 @@ export interface ChatViewProps { editable?: boolean; } -export function ChatView({ customizations, systemPrompt, userSuffix, storageKey, editable }: ChatViewProps = {}) { +export function ChatView({ + customizations, + systemPrompt, + userSuffix, + storageKey, + editable, +}: ChatViewProps = {}) { const chatOptions: UseChatOptions = { ...(customizations?.schemas && { parserOptions: { customSchemas: customizations.schemas } }), ...(systemPrompt !== undefined && { systemPrompt }), @@ -72,16 +78,21 @@ export function ChatView({ customizations, systemPrompt, userSuffix, storageKey, // Clean up on unmount useEffect(() => { - return () => { subscribedStores.current.clear(); }; + return () => { + subscribedStores.current.clear(); + }; }, []); - const handleLoadFlow = useCallback((e: React.ChangeEvent) => { - const key = e.target.value; - if (!key) return; - const flow = exampleFlows[key]; - if (flow) startFlow(flow.steps, flow.customPrompt); - e.target.value = ''; - }, [startFlow]); + const handleLoadFlow = useCallback( + (e: React.ChangeEvent) => { + const key = e.target.value; + if (!key) return; + const flow = exampleFlows[key]; + if (flow) startFlow(flow.steps, flow.customPrompt); + e.target.value = ''; + }, + [startFlow], + ); const { events, isOpen, setIsOpen, clearEvents } = useChatActionLog(messages); @@ -107,18 +118,15 @@ export function ChatView({ customizations, systemPrompt, userSuffix, storageKey, return (
- +
{messages.length === 0 && (

MDMA Chat

- Describe an interactive document and the AI will generate it, or try an example flow: + Describe an interactive document and the AI will generate it, or try an example + flow:

@@ -170,11 +182,7 @@ export function ChatView({ customizations, systemPrompt, userSuffix, storageKey, />
- setIsOpen((prev) => !prev)} - /> + setIsOpen((prev) => !prev)} />
); } diff --git a/demo/src/CustomChatView.tsx b/demo/src/CustomChatView.tsx index 11c8a87..c3865b2 100644 --- a/demo/src/CustomChatView.tsx +++ b/demo/src/CustomChatView.tsx @@ -7,10 +7,10 @@ export function CustomChatView() {
Custom Components Mode - New types: progress, rating, metric. - Built-in: chart (recharts override). - Restyled built-ins: button, table, callout. - Form elements: glass inputs, toggle switches, gradient submit. + New types: progress, rating, metric. Built-in:{' '} + chart (recharts override). Restyled built-ins: button,{' '} + table, callout. Form elements: glass inputs, toggle switches, + gradient submit.
diff --git a/demo/src/PlaygroundView.tsx b/demo/src/PlaygroundView.tsx index 3f1b9b4..026e6df 100644 --- a/demo/src/PlaygroundView.tsx +++ b/demo/src/PlaygroundView.tsx @@ -9,8 +9,8 @@ export function PlaygroundView() {
Playground - Free-form chat. The AI knows MDMA basics and will generate interactive - components when you ask for them. + Free-form chat. The AI knows MDMA basics and will generate interactive components when you + ask for them.
{issue.ruleId} - {issue.componentId && ( - #{issue.componentId} - )} + {issue.componentId && #{issue.componentId}} {issue.message} {issue.fixed && fixed}
@@ -118,10 +118,7 @@ function ValidationPanel({ results: Map; stepLabels: Map; }) { - const entries = useMemo( - () => Array.from(results.entries()).reverse(), - [results], - ); + const entries = useMemo(() => Array.from(results.entries()).reverse(), [results]); if (entries.length === 0) { return ( @@ -137,10 +134,10 @@ function ValidationPanel({
{entries.map(([msgId, result]) => (
-
- - {result.ok ? 'PASS' : 'FAIL'} - +
+ {result.ok ? 'PASS' : 'FAIL'} {stepLabels.get(msgId) ?? `msg #${msgId}`} @@ -161,9 +158,7 @@ function ValidationPanel({ )} {result.fixCount > 0 && ( - - {result.fixCount} auto-fixed - + {result.fixCount} auto-fixed )}
@@ -214,7 +209,9 @@ export function StepperView() { ...(customizations.schemas && { parserOptions: { customSchemas: customizations.schemas } }), }); - const [validationResults, setValidationResults] = useState>(new Map()); + const [validationResults, setValidationResults] = useState>( + new Map(), + ); const [stepLabelMap, setStepLabelMap] = useState>(new Map()); const validatedRef = useRef>(new Set()); @@ -309,11 +306,7 @@ export function StepperView() { ))}
{!isGenerating && currentStep < 3 && ( - )} @@ -324,19 +317,15 @@ export function StepperView() {
- +
{messages.length === 0 && (

Stepper Forms Playground

- Generate a 3-step onboarding flow one step at a time. - Each step is a separate AI response, validated by @mobile-reality/mdma-validator. + Generate a 3-step onboarding flow one step at a time. Each step is a separate AI + response, validated by @mobile-reality/mdma-validator.

)} diff --git a/demo/src/ValidatorView.tsx b/demo/src/ValidatorView.tsx index da9b59d..660f1f4 100644 --- a/demo/src/ValidatorView.tsx +++ b/demo/src/ValidatorView.tsx @@ -3,26 +3,32 @@ import { useChat } from './chat/use-chat.js'; import { ChatSettings } from './chat/ChatSettings.js'; import { ChatMessage } from './chat/ChatMessage.js'; import { ChatInput } from './chat/ChatInput.js'; -import { validate, type ValidationResult, type ValidationIssue } from '@mobile-reality/mdma-validator'; +import { ChatActionLog } from './chat/ChatActionLog.js'; +import { useChatActionLog } from './chat/use-chat-action-log.js'; +import { + validate, + validateFlow, + type ValidationResult, + type ValidationIssue, + type FlowValidationResult, + type ValidationRuleId, +} from '@mobile-reality/mdma-validator'; +import { + buildFixerPrompt, + buildFixerMessage, + buildSystemPrompt, +} from '@mobile-reality/mdma-prompt-pack'; +import { chatCompletion } from './llm-client.js'; +import type { DocumentStore } from '@mobile-reality/mdma-runtime'; import { customizations } from './custom-components.js'; - -const VALIDATOR_PROMPT = `You are an AI assistant for testing the MDMA validator. -Generate MDMA components with various issues so the validator can demonstrate its capabilities. - -IMPORTANT: When the user asks you to generate MDMA content, you MUST intentionally introduce bugs and issues into your output so the validator can catch and fix them. Mix in these problems across your components: - -1. **Duplicate IDs** — Use the same id for two different components (e.g. two callouts both with id: notice) -2. **Bad ID format** — Use camelCase or snake_case IDs instead of kebab-case (e.g. id: myForm, id: user_table) -3. **Missing sensitive flags** — Include PII fields like email, phone, ssn, address without sensitive: true -4. **Missing thinking block** — Omit the thinking block entirely -5. **Bad binding syntax** — Use single braces {var.path} instead of {{var.path}}, or add extra whitespace {{ var.path }} -6. **Empty callout content** — Create a callout with content: "" or omit the content field -7. **Missing table headers** — Define table columns with just key: but no header: -8. **Missing form labels** — Define form fields with just name: but no label: -9. **YAML document separators** — Add --- at the end of an mdma block -10. **Bare binding in table data** — Use data: some-component.rows instead of data: "{{some-component.rows}}" - -Try to include at least 4-5 different issues in each response. The goal is to stress-test the validator's detection and auto-fix capabilities. Generate real, useful-looking components (forms, tables, callouts, etc.) — just with these intentional mistakes baked in.`; +import { + VALIDATOR_PROMPT_VARIANTS, + ALL_RULE_IDS, + FIXER_FLOW_RULES, + FIXER_CORRECT_STRUCTURE, + FLOW_STEPS, + SAMPLE_BINDING_DATA, +} from './validator-prompts.js'; function severityClass(severity: string): string { if (severity === 'error') return 'validator-severity--error'; @@ -37,20 +43,21 @@ function IssueRow({ issue }: { issue: ValidationIssue }) { {issue.severity} {issue.ruleId} - {issue.componentId && ( - #{issue.componentId} - )} + {issue.componentId && #{issue.componentId}} {issue.message} {issue.fixed && fixed}
); } -function ValidationPanel({ results }: { results: Map }) { - const entries = useMemo( - () => Array.from(results.entries()).reverse(), - [results], - ); +interface ValidationPanelProps { + results: Map; + onRequestFix?: (msgId: number) => void; + isGenerating?: boolean; +} + +function ValidationPanel({ results, onRequestFix, isGenerating }: ValidationPanelProps) { + const entries = useMemo(() => Array.from(results.entries()).reverse(), [results]); if (entries.length === 0) { return ( @@ -64,63 +71,150 @@ function ValidationPanel({ results }: { results: Map } return (
- {entries.map(([msgId, result]) => ( -
-
- - {result.ok ? 'PASS' : 'FAIL'} - - msg #{msgId} - - {result.summary.errors > 0 && ( - - {result.summary.errors} error{result.summary.errors > 1 ? 's' : ''} - - )} - {result.summary.warnings > 0 && ( - - {result.summary.warnings} warning{result.summary.warnings > 1 ? 's' : ''} - - )} - {result.summary.infos > 0 && ( - - {result.summary.infos} info{result.summary.infos > 1 ? 's' : ''} - - )} - {result.fixCount > 0 && ( - - {result.fixCount} auto-fixed - - )} - -
+ {entries.map(([msgId, result]) => { + const unfixedErrors = result.issues.filter((i) => !i.fixed && i.severity === 'error'); + const unfixedWarnings = result.issues.filter((i) => !i.fixed && i.severity === 'warning'); + const hasUnfixed = unfixedErrors.length > 0 || unfixedWarnings.length > 0; - {result.issues.length > 0 && ( -
-

Issues ({result.issues.length})

-
- {result.issues.map((issue, i) => ( - - ))} -
+ return ( +
+
+ {result.ok ? 'PASS' : 'FAIL'} + msg #{msgId} + + {result.summary.errors > 0 && ( + + {result.summary.errors} error{result.summary.errors > 1 ? 's' : ''} + + )} + {result.summary.warnings > 0 && ( + + {result.summary.warnings} warning{result.summary.warnings > 1 ? 's' : ''} + + )} + {result.summary.infos > 0 && ( + + {result.summary.infos} info{result.summary.infos > 1 ? 's' : ''} + + )} + {result.fixCount > 0 && ( + {result.fixCount} auto-fixed + )} +
- )} - {result.fixCount > 0 && ( -
-
- View fixed output -
{result.output}
-
+ {result.issues.length > 0 && ( +
+

Issues ({result.issues.length})

+
+ {result.issues.map((issue, i) => ( + + ))} +
+
+ )} + + {hasUnfixed && onRequestFix && ( + + )} + + {result.fixCount > 0 && ( +
+
+ View fixed output +
{result.output}
+
+
+ )} +
+ ); + })} +
+ ); +} + +function FlowProgressPanel({ + steps, + result, +}: { + steps: import('@mobile-reality/mdma-validator').FlowStepDefinition[]; + result: FlowValidationResult | null; +}) { + // Match issues to steps by "Step N" prefix + const stepStatuses = steps.map((step, i) => { + if (!result) return 'pending' as const; + const stepPrefix = `Step ${i + 1} `; + const matchingIssue = result.issues.find((iss) => iss.message.startsWith(stepPrefix)); + if (!matchingIssue) { + // Check "not yet shown" + const notShown = result.issues.find( + (iss) => iss.message.includes(step.id) && iss.message.includes('not yet shown'), + ); + return notShown ? ('pending' as const) : ('pending' as const); + } + if (matchingIssue.severity === 'info' && matchingIssue.message.includes('correct')) + return 'done' as const; + if (matchingIssue.severity === 'error') return 'error' as const; + return 'pending' as const; + }); + + const completedSteps = stepStatuses.filter((s) => s === 'done').length; + + return ( +
+

Flow Progress

+
+ {steps.map((step, i) => { + const status = stepStatuses[i]; + const stepPrefix = `Step ${i + 1} `; + const issue = result?.issues.find( + (iss) => iss.message.startsWith(stepPrefix) && iss.severity === 'error', + ); + + return ( +
+ {i + 1} + {step.label} + + {step.type}#{step.id} + + {status === 'done' && ( + done + )} + {status === 'error' && issue && ( + {issue.message} + )}
- )} -
- ))} + ); + })} +
+
+ {completedSteps}/{steps.length} steps completed + {result && !result.ok && ( + + flow errors detected + + )} +
); } -export function ValidatorView() { +function ValidatorChatInner({ promptKey }: { promptKey: string }) { + const variant = VALIDATOR_PROMPT_VARIANTS.find((v) => v.key === promptKey)!; + const variantRuleSet = useMemo(() => new Set(variant.rules), [variant.rules]); + const { config, messages, @@ -136,12 +230,84 @@ export function ValidatorView() { clear, updateMessage, } = useChat({ - systemPrompt: VALIDATOR_PROMPT, - storageKey: 'validator', + systemPrompt: variant.prompt, + storageKey: `validator-${promptKey}`, ...(customizations.schemas && { parserOptions: { customSchemas: customizations.schemas } }), }); - const [validationResults, setValidationResults] = useState>(new Map()); + const [fixerModel, setFixerModel] = useState( + () => localStorage.getItem('mdma-fixer-model') || '', + ); + const [customFixerModel, setCustomFixerModel] = useState( + () => localStorage.getItem('mdma-fixer-custom-model') || '', + ); + const [autoFixWithLlm, setAutoFixWithLlm] = useState( + () => localStorage.getItem('mdma-auto-fix-llm') !== 'false', + ); + + const [validationResults, setValidationResults] = useState>( + new Map(), + ); + + // Subscribe to user-action events to auto-advance the conversation + const subscribedStores = useRef(new Set()); + const pendingSendRef = useRef(false); + const setInputRef = useRef(setInput); + setInputRef.current = setInput; + const flowCompleteRef = useRef(false); + const [showFlowComplete, setShowFlowComplete] = useState(false); + + // Seed stores with sample binding data for the active variant + const seededStores = useRef(new Set()); + useEffect(() => { + const sampleData = SAMPLE_BINDING_DATA[promptKey]; + if (!sampleData) return; + for (const msg of messages) { + if (msg.role === 'assistant' && msg.store && !seededStores.current.has(msg.store)) { + seededStores.current.add(msg.store); + for (const [componentId, fields] of Object.entries(sampleData)) { + for (const [field, value] of Object.entries(fields)) { + msg.store.dispatch({ type: 'FIELD_CHANGED', componentId, field, value }); + } + } + } + } + }, [messages, promptKey]); + + useEffect(() => { + const ADVANCE_EVENTS = ['ACTION_TRIGGERED', 'APPROVAL_GRANTED', 'APPROVAL_DENIED'] as const; + for (const msg of messages) { + if (msg.role === 'assistant' && msg.store && !subscribedStores.current.has(msg.store)) { + subscribedStores.current.add(msg.store); + for (const eventType of ADVANCE_EVENTS) { + msg.store.getEventBus().on(eventType, () => { + if (flowCompleteRef.current) { + setShowFlowComplete(true); + return; + } + setTimeout(() => { + pendingSendRef.current = true; + setInputRef.current('Continue to the next step'); + }, 500); + }); + } + } + } + }, [messages]); + + // Auto-send when pending action trigger sets the input + useEffect(() => { + if (pendingSendRef.current && input.trim() && !isGenerating) { + pendingSendRef.current = false; + send(); + } + }, [input, isGenerating, send]); + + useEffect(() => { + return () => { + subscribedStores.current.clear(); + }; + }, []); // Track which messages we've already validated const validatedRef = useRef>(new Set()); @@ -152,7 +318,27 @@ export function ValidatorView() { for (const msg of messages) { if (msg.role === 'assistant' && msg.content && !validatedRef.current.has(msg.id)) { validatedRef.current.add(msg.id); - const result = validate(msg.content); + + // Collect component IDs from all prior assistant messages + // so flow-ordering can detect regenerated steps + const priorComponentIds: string[] = []; + for (const prev of messages) { + if (prev.id >= msg.id) break; + if (prev.role !== 'assistant' || !prev.content) continue; + const idMatches = prev.content.matchAll(/```mdma[\s\S]*?```/g); + for (const match of idMatches) { + const idMatch = match[0].match(/id:\s*(\S+)/); + if (idMatch) priorComponentIds.push(idMatch[1]); + } + } + + // Only run the rules specified for this variant + const excludeRules = ALL_RULE_IDS.filter((r) => !variantRuleSet.has(r)); + + const result = validate(msg.content, { + ...(priorComponentIds.length > 0 && { priorComponentIds }), + ...(excludeRules.length > 0 && { exclude: excludeRules as ValidationRuleId[] }), + }); setValidationResults((prev) => { const next = new Map(prev); next.set(msg.id, result); @@ -165,7 +351,14 @@ export function ValidatorView() { } } } - }, [messages, isGenerating, updateMessage]); + }, [messages, isGenerating, updateMessage, variantRuleSet]); + + const { + events, + isOpen: logOpen, + setIsOpen: setLogOpen, + clearEvents, + } = useChatActionLog(messages); const chatEndRef = useRef(null); const prevMsgCountRef = useRef(messages.length); @@ -179,67 +372,331 @@ export function ValidatorView() { const handleClear = useCallback(() => { clear(); + clearEvents(); setValidationResults(new Map()); validatedRef.current = new Set(); - }, [clear]); + }, [clear, clearEvents]); + + const [fixingMsgId, setFixingMsgId] = useState(null); + const isFixing = fixingMsgId !== null; + const fixAbortRef = useRef(null); + + const handleRequestFix = useCallback( + async (msgId: number) => { + const result = validationResults.get(msgId); + const msg = messages.find((m) => m.id === msgId); + if (!result || !msg || isFixing) return; + + const unfixed = result.issues.filter( + (i) => !i.fixed && (i.severity === 'error' || i.severity === 'warning'), + ); + if (unfixed.length === 0) return; + + setFixingMsgId(msgId); + fixAbortRef.current = new AbortController(); + + try { + const systemPrompt = `${buildSystemPrompt()}\n\n---\n\n${buildFixerPrompt(promptKey)}`; + + // Build conversation history from messages before the broken one + const history = messages + .filter((m) => m.id < msgId) + .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); + + const userMessage = buildFixerMessage(result.output, unfixed, { + conversationHistory: history.length > 0 ? history : undefined, + promptContext: + FIXER_FLOW_RULES[promptKey] ?? FIXER_CORRECT_STRUCTURE[promptKey] ?? undefined, + }); + + const resolvedModel = fixerModel === '__custom__' ? customFixerModel : fixerModel; + const fixerConfig = resolvedModel ? { ...config, model: resolvedModel } : config; + + const fixedContent = await chatCompletion( + fixerConfig, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + fixAbortRef.current.signal, + ); + + if (fixedContent) { + // Overwrite the original message with the fixed content + updateMessage(msg.id, fixedContent); + // Clear old validation and seeded stores so they re-run on the new store + validatedRef.current.delete(msg.id); + seededStores.current.clear(); + setValidationResults((prev) => { + const next = new Map(prev); + next.delete(msg.id); + return next; + }); + } + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + console.error('Fixer error:', err); + } + } finally { + setFixingMsgId(null); + fixAbortRef.current = null; + } + }, + [ + validationResults, + messages, + config, + isFixing, + updateMessage, + fixerModel, + customFixerModel, + promptKey, + ], + ); + + // Auto-fix with LLM when enabled and unfixed issues detected + const autoFixTriggeredRef = useRef>(new Set()); + const autoFixQueueRef = useRef(null); + + useEffect(() => { + if (!autoFixWithLlm || isFixing || isGenerating) return; + for (const [msgId, result] of validationResults) { + if (autoFixTriggeredRef.current.has(msgId)) continue; + const unfixed = result.issues.filter( + (i) => !i.fixed && (i.severity === 'error' || i.severity === 'warning'), + ); + if (unfixed.length > 0) { + autoFixTriggeredRef.current.add(msgId); + autoFixQueueRef.current = msgId; + break; + } + } + }, [validationResults, autoFixWithLlm, isFixing, isGenerating]); + + // Process the queued auto-fix in a separate effect to avoid stale closures + useEffect(() => { + if (autoFixQueueRef.current === null || isFixing || isGenerating) return; + const msgId = autoFixQueueRef.current; + autoFixQueueRef.current = null; + handleRequestFix(msgId); + }, [isFixing, isGenerating, handleRequestFix]); + + // Flow validation — run against all assistant messages when steps are defined + const flowSteps = FLOW_STEPS[promptKey]; + const flowResult = useMemo(() => { + if (!flowSteps) return null; + const assistantContents = messages + .filter((m) => m.role === 'assistant' && m.content) + .map((m) => m.content); + if (assistantContents.length === 0) return null; + return validateFlow(assistantContents, { steps: flowSteps }); + }, [flowSteps, messages]); + + // All flow steps completed — check by counting info "correct" issues + const flowComplete = + flowSteps != null && + flowResult != null && + flowResult.issues.filter((i) => i.severity === 'info' && i.message.includes('correct')) + .length >= flowSteps.length; + + flowCompleteRef.current = flowComplete; const lastMsgId = messages[messages.length - 1]?.id; return ( -
-
- Validator - - Chat with the AI — every response is automatically validated by @mobile-reality/mdma-validator. Issues and auto-fixes appear on the right. - -
+
+
+ -
-
- - -
- {messages.length === 0 && ( -
-

Validator Chat

-

- Ask the AI to generate MDMA content. Each response will be validated automatically. -

-
- )} +
+ {messages.length === 0 && ( +
+

{variant.label}

+

{variant.description}

+

+ Rules tested: {variant.rules.join(', ')} +

+
+ )} - {messages.map((msg) => ( + {messages.map((msg) => ( +
- ))} + {fixingMsgId === msg.id && ( +
+
+ Fixing with LLM... +
+ )} +
+ ))} - {error &&
{error}
} + {error &&
{error}
} + +
+
-
+ 0} + inputRef={inputRef} + disabled={flowComplete} + placeholder={ + flowComplete ? 'Flow completed — all steps validated successfully' : undefined + } + /> +
+ + setLogOpen((prev) => !prev)} + /> + +
+
+

Fixer Settings

+ +
+ Model +
+ {fixerModel === '__custom__' && ( +
+ Custom Model ID + { + setCustomFixerModel(e.target.value); + localStorage.setItem('mdma-fixer-custom-model', e.target.value); + }} + placeholder="e.g. openrouter/auto" + /> +
+ )} +
+ + {flowSteps && } + +
- 0} - inputRef={inputRef} - /> + {showFlowComplete && ( +
setShowFlowComplete(false)}> +
e.stopPropagation()}> +
+

Flow Completed!

+

All {flowSteps?.length} steps have been validated successfully.

+ +
+ )} +
+ ); +} + +export function ValidatorView() { + const [activeVariant, setActiveVariant] = useState('structure'); - + return ( +
+
+ Validator + + Chat with the AI — every response is automatically validated. Issues and auto-fixes appear + on the right. + +
+ +
+ {VALIDATOR_PROMPT_VARIANTS.map((v) => ( + + ))}
+ +
); } diff --git a/demo/src/chart-components.tsx b/demo/src/chart-components.tsx index 5be48f7..a08e8c9 100644 --- a/demo/src/chart-components.tsx +++ b/demo/src/chart-components.tsx @@ -3,11 +3,20 @@ import type { ChartComponent } from '@mobile-reality/mdma-spec'; import type { MdmaBlockRendererProps } from '@mobile-reality/mdma-renderer-react'; import { ResponsiveContainer, - LineChart, Line, - BarChart, Bar, - AreaChart, Area, - PieChart, Pie, Cell, - XAxis, YAxis, CartesianGrid, Tooltip, Legend, + LineChart, + Line, + BarChart, + Bar, + AreaChart, + Area, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, } from 'recharts'; // ─── CSV Data Parser ───────────────────────────────────────────────────────── @@ -18,12 +27,15 @@ interface ParsedChartData { } function parseCsvData(raw: string): ParsedChartData { - const lines = raw.trim().split('\n').filter(l => l.trim() !== ''); + const lines = raw + .trim() + .split('\n') + .filter((l) => l.trim() !== ''); if (lines.length === 0) return { headers: [], rows: [] }; - const headers = lines[0].split(',').map(h => h.trim()); - const rows = lines.slice(1).map(line => { - const values = line.split(',').map(v => v.trim()); + const headers = lines[0].split(',').map((h) => h.trim()); + const rows = lines.slice(1).map((line) => { + const values = line.split(',').map((v) => v.trim()); const row: Record = {}; headers.forEach((header, i) => { const val = values[i] ?? ''; @@ -40,7 +52,7 @@ function arrayToChartData(resolved: unknown): ParsedChartData { if (!Array.isArray(resolved) || resolved.length === 0) return { headers: [], rows: [] }; const first = resolved[0] as Record; const headers = Object.keys(first); - const rows = resolved.map(item => { + const rows = resolved.map((item) => { const row: Record = {}; for (const key of headers) { const val = (item as Record)[key]; @@ -54,8 +66,14 @@ function arrayToChartData(resolved: unknown): ParsedChartData { // ─── Color Palette ─────────────────────────────────────────────────────────── const DEFAULT_COLORS = [ - '#6c5ce7', '#00b894', '#fdcb6e', '#e74c3c', - '#a29bfe', '#74b9ff', '#fd79a8', '#55efc4', + '#6c5ce7', + '#00b894', + '#fdcb6e', + '#e74c3c', + '#a29bfe', + '#74b9ff', + '#fd79a8', + '#55efc4', ]; // ─── Shared helpers ────────────────────────────────────────────────────────── @@ -74,8 +92,10 @@ function resolveChartData( const xKey = chart.xAxis ?? parsed.headers[0] ?? ''; const yKeys = chart.yAxis - ? (Array.isArray(chart.yAxis) ? chart.yAxis : [chart.yAxis]) - : parsed.headers.filter(h => h !== xKey); + ? Array.isArray(chart.yAxis) + ? chart.yAxis + : [chart.yAxis] + : parsed.headers.filter((h) => h !== xKey); const colors = chart.colors ?? DEFAULT_COLORS; return { data: parsed, xKey, yKeys, colors }; @@ -97,15 +117,34 @@ const tooltipStyle = { // ─── Line ──────────────────────────────────────────────────────────────────── -function RechartLine({ data, xKey, yKeys, colors, chart }: { - data: ParsedChartData; xKey: string; yKeys: string[]; colors: string[]; chart: ChartComponent; +function RechartLine({ + data, + xKey, + yKeys, + colors, + chart, +}: { + data: ParsedChartData; + xKey: string; + yKeys: string[]; + colors: string[]; + chart: ChartComponent; }) { return ( {chart.showGrid && } - - + + {chart.showLegend && } {yKeys.map((key, i) => ( @@ -126,15 +165,34 @@ function RechartLine({ data, xKey, yKeys, colors, chart }: { // ─── Bar ───────────────────────────────────────────────────────────────────── -function RechartBar({ data, xKey, yKeys, colors, chart }: { - data: ParsedChartData; xKey: string; yKeys: string[]; colors: string[]; chart: ChartComponent; +function RechartBar({ + data, + xKey, + yKeys, + colors, + chart, +}: { + data: ParsedChartData; + xKey: string; + yKeys: string[]; + colors: string[]; + chart: ChartComponent; }) { return ( {chart.showGrid && } - - + + {chart.showLegend && } {yKeys.map((key, i) => ( @@ -153,15 +211,34 @@ function RechartBar({ data, xKey, yKeys, colors, chart }: { // ─── Area ──────────────────────────────────────────────────────────────────── -function RechartArea({ data, xKey, yKeys, colors, chart }: { - data: ParsedChartData; xKey: string; yKeys: string[]; colors: string[]; chart: ChartComponent; +function RechartArea({ + data, + xKey, + yKeys, + colors, + chart, +}: { + data: ParsedChartData; + xKey: string; + yKeys: string[]; + colors: string[]; + chart: ChartComponent; }) { return ( {chart.showGrid && } - - + + {chart.showLegend && } {yKeys.map((key, i) => ( @@ -187,10 +264,20 @@ function renderPieLabel(props: { name?: string; percent?: number }) { return `${props.name ?? ''} ${((props.percent ?? 0) * 100).toFixed(0)}%`; } -function RechartPie({ data, xKey, yKeys, colors, chart }: { - data: ParsedChartData; xKey: string; yKeys: string[]; colors: string[]; chart: ChartComponent; +function RechartPie({ + data, + xKey, + yKeys, + colors, + chart, +}: { + data: ParsedChartData; + xKey: string; + yKeys: string[]; + colors: string[]; + chart: ChartComponent; }) { - const valueKey = yKeys[0] ?? data.headers.find(h => h !== xKey) ?? ''; + const valueKey = yKeys[0] ?? data.headers.find((h) => h !== xKey) ?? ''; return ( diff --git a/demo/src/chat/ChatActionLog.tsx b/demo/src/chat/ChatActionLog.tsx index f0e8c94..f401817 100644 --- a/demo/src/chat/ChatActionLog.tsx +++ b/demo/src/chat/ChatActionLog.tsx @@ -18,9 +18,7 @@ function renderDetail(action: StoreAction) { } if ('actionId' in action) { return ( - - action: {(action as { actionId: string }).actionId} - + action: {(action as { actionId: string }).actionId} ); } return null; @@ -48,9 +46,7 @@ export const ChatActionLog = memo(function ChatActionLog({ title={isOpen ? 'Hide action log' : 'Show action log'} > {isOpen ? 'Hide Log' : 'Action Log'} - {events.length > 0 && ( - {events.length} - )} + {events.length > 0 && {events.length}} {isOpen && ( diff --git a/demo/src/chat/ChatInput.tsx b/demo/src/chat/ChatInput.tsx index 373191a..41e9881 100644 --- a/demo/src/chat/ChatInput.tsx +++ b/demo/src/chat/ChatInput.tsx @@ -9,6 +9,10 @@ export interface ChatInputProps { isGenerating: boolean; hasMessages: boolean; inputRef: RefObject; + /** When true, the input is disabled (e.g. flow completed). */ + disabled?: boolean; + /** Placeholder text override. */ + placeholder?: string; } export const ChatInput = memo(function ChatInput({ @@ -20,7 +24,11 @@ export const ChatInput = memo(function ChatInput({ isGenerating, hasMessages, inputRef, + disabled, + placeholder, }: ChatInputProps) { + const isDisabled = disabled && !isGenerating; + return (
{hasMessages && ( @@ -38,12 +46,13 @@ export const ChatInput = memo(function ChatInput({ className="chat-input" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="Describe the interactive document you need..." + placeholder={placeholder ?? 'Describe the interactive document you need...'} rows={2} + disabled={isDisabled} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - onSend(); + if (!isDisabled) onSend(); } }} /> @@ -56,7 +65,7 @@ export const ChatInput = memo(function ChatInput({ type="button" className="chat-send-btn" onClick={onSend} - disabled={!value.trim()} + disabled={isDisabled || !value.trim()} > Send diff --git a/demo/src/chat/ChatMessage.tsx b/demo/src/chat/ChatMessage.tsx index f491ee0..4feaded 100644 --- a/demo/src/chat/ChatMessage.tsx +++ b/demo/src/chat/ChatMessage.tsx @@ -97,9 +97,7 @@ export const ChatMessage = memo(function ChatMessage({ return (
- - {message.role === 'user' ? 'You' : 'MDMA AI'} - + {message.role === 'user' ? 'You' : 'MDMA AI'} {message.role === 'assistant' && message.content && ( {open && ( @@ -55,14 +100,45 @@ export const ChatSettings = memo(function ChatSettings({ placeholder="sk-..." /> -
)} diff --git a/demo/src/chat/EditableMessageContext.tsx b/demo/src/chat/EditableMessageContext.tsx index 5ba710d..b0ae6d7 100644 --- a/demo/src/chat/EditableMessageContext.tsx +++ b/demo/src/chat/EditableMessageContext.tsx @@ -2,11 +2,7 @@ import { createContext, useContext } from 'react'; export interface EditableFieldContextValue { /** Apply one or more property changes to a form field in the underlying YAML. */ - editField: ( - componentId: string, - fieldName: string, - changes: Record, - ) => void; + editField: (componentId: string, fieldName: string, changes: Record) => void; /** Available data source names (for select options switching). */ dataSourceNames: string[]; } @@ -56,75 +52,72 @@ function applySingleChange( property: string, newValue: string, ): string { - return markdown.replace( - /```mdma\n([\s\S]*?)```/g, - (fullMatch, yamlBlock: string) => { - // Only modify the block that contains our component - if (!new RegExp(`^\\s*id:\\s*${escapeRegex(componentId)}\\s*$`, 'm').test(yamlBlock)) { - return fullMatch; + return markdown.replace(/```mdma\n([\s\S]*?)```/g, (fullMatch, yamlBlock: string) => { + // Only modify the block that contains our component + if (!new RegExp(`^\\s*id:\\s*${escapeRegex(componentId)}\\s*$`, 'm').test(yamlBlock)) { + return fullMatch; + } + + const lines = yamlBlock.split('\n'); + + // 1. Find the field's `- name: ` line + let fieldStart = -1; + let fieldNameIndent = -1; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trimStart(); + if (trimmed === `- name: ${fieldName}`) { + fieldStart = i; + fieldNameIndent = lines[i].length - trimmed.length; + break; } - - const lines = yamlBlock.split('\n'); - - // 1. Find the field's `- name: ` line - let fieldStart = -1; - let fieldNameIndent = -1; - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trimStart(); - if (trimmed === `- name: ${fieldName}`) { - fieldStart = i; - fieldNameIndent = lines[i].length - trimmed.length; - break; - } - } - if (fieldStart === -1) return fullMatch; - - // 2. Find end of this field (next list item at same/lower indent, or end) - let fieldEnd = lines.length; - for (let i = fieldStart + 1; i < lines.length; i++) { - const trimmed = lines[i].trimStart(); - if (trimmed === '') continue; - const indent = lines[i].length - trimmed.length; - if (indent <= fieldNameIndent) { - fieldEnd = i; - break; - } + } + if (fieldStart === -1) return fullMatch; + + // 2. Find end of this field (next list item at same/lower indent, or end) + let fieldEnd = lines.length; + for (let i = fieldStart + 1; i < lines.length; i++) { + const trimmed = lines[i].trimStart(); + if (trimmed === '') continue; + const indent = lines[i].length - trimmed.length; + if (indent <= fieldNameIndent) { + fieldEnd = i; + break; } - - // 3. Within the field, find the property line - const propRegex = new RegExp(`^(\\s*)${escapeRegex(property)}:`); - for (let i = fieldStart + 1; i < fieldEnd; i++) { - const match = lines[i].match(propRegex); - if (match) { - const propIndent = match[1].length; - - // Find end of this property's value (includes deeper-indented continuation lines) - let propEnd = i + 1; - while (propEnd < fieldEnd) { - const nextTrimmed = lines[propEnd].trimStart(); - if (nextTrimmed === '') { - propEnd++; - continue; - } - const nextIndent = lines[propEnd].length - nextTrimmed.length; - if (nextIndent > propIndent) { - propEnd++; - } else { - break; - } + } + + // 3. Within the field, find the property line + const propRegex = new RegExp(`^(\\s*)${escapeRegex(property)}:`); + for (let i = fieldStart + 1; i < fieldEnd; i++) { + const match = lines[i].match(propRegex); + if (match) { + const propIndent = match[1].length; + + // Find end of this property's value (includes deeper-indented continuation lines) + let propEnd = i + 1; + while (propEnd < fieldEnd) { + const nextTrimmed = lines[propEnd].trimStart(); + if (nextTrimmed === '') { + propEnd++; + continue; + } + const nextIndent = lines[propEnd].length - nextTrimmed.length; + if (nextIndent > propIndent) { + propEnd++; + } else { + break; } - - // Replace the property (possibly multi-line) with a single line - const newLine = match[1] + `${property}: ${newValue}`; - lines.splice(i, propEnd - i, newLine); - return '```mdma\n' + lines.join('\n') + '```'; } + + // Replace the property (possibly multi-line) with a single line + const newLine = match[1] + `${property}: ${newValue}`; + lines.splice(i, propEnd - i, newLine); + return '```mdma\n' + lines.join('\n') + '```'; } + } - // 4. Property not found — add it after the `- name:` line - const propIndent = ' '.repeat(fieldNameIndent + 2); - lines.splice(fieldStart + 1, 0, propIndent + `${property}: ${newValue}`); - return '```mdma\n' + lines.join('\n') + '```'; - }, - ); + // 4. Property not found — add it after the `- name:` line + const propIndent = ' '.repeat(fieldNameIndent + 2); + lines.splice(fieldStart + 1, 0, propIndent + `${property}: ${newValue}`); + return '```mdma\n' + lines.join('\n') + '```'; + }); } diff --git a/demo/src/chat/use-chat.ts b/demo/src/chat/use-chat.ts index 13a2355..1fc302f 100644 --- a/demo/src/chat/use-chat.ts +++ b/demo/src/chat/use-chat.ts @@ -31,7 +31,9 @@ function loadSavedConfig(): LlmConfig { try { const saved = localStorage.getItem(CONFIG_KEY); if (saved) return JSON.parse(saved); - } catch { /* ignore */ } + } catch { + /* ignore */ + } return DEFAULT_CONFIG; } @@ -54,7 +56,9 @@ function loadSavedHistory(storageKey: string): StoredMsg[] { try { const saved = localStorage.getItem(historyKey(storageKey)); if (saved) return JSON.parse(saved); - } catch { /* ignore */ } + } catch { + /* ignore */ + } return []; } @@ -69,7 +73,8 @@ function clearSavedHistory(storageKey: string) { localStorage.removeItem(historyKey(storageKey)); } -const DEFAULT_USER_SUFFIX = '\n\nRespond with an MDMA Markdown document. Do not wrap it in code fences.'; +const DEFAULT_USER_SUFFIX = + '\n\nRespond with an MDMA Markdown document. Do not wrap it in code fences.'; // ---- Parse throttle interval ---- @@ -98,7 +103,7 @@ export function useChat(options?: UseChatOptions) { // Build parser — recreate when the customSchemas reference changes (e.g. after HMR or tab switch) const customSchemas = options?.parserOptions?.customSchemas; const parseMarkdownFn = useMemo( - () => customSchemas ? createParser({ customSchemas }) : defaultParseMarkdown, + () => (customSchemas ? createParser({ customSchemas }) : defaultParseMarkdown), [customSchemas], ); const parseMarkdownRef = useRef(parseMarkdownFn); @@ -140,11 +145,16 @@ export function useChat(options?: UseChatOptions) { // Re-parse all assistant messages to rebuild AST + store for (const m of hydrated) { if (m.role === 'assistant' && m.content) { - parseMarkdownRef.current(m.content).then(({ ast, store }) => { - setMessages((prev) => - prev.map((msg) => (msg.id === m.id ? { ...msg, ast, store } : msg)), - ); - }).catch(() => { /* ignore parse errors from old messages */ }); + parseMarkdownRef + .current(m.content) + .then(({ ast, store }) => { + setMessages((prev) => + prev.map((msg) => (msg.id === m.id ? { ...msg, ast, store } : msg)), + ); + }) + .catch(() => { + /* ignore parse errors from old messages */ + }); } } }, []); @@ -170,9 +180,7 @@ export function useChat(options?: UseChatOptions) { const existingStore = messagesRef.current.find((m) => m.id === msgId)?.store ?? undefined; const { ast, store } = await parseMarkdownRef.current(content, existingStore); if (gen >= parseGenRef.current) { - setMessages((prev) => - prev.map((m) => (m.id === msgId ? { ...m, ast, store } : m)), - ); + setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, ast, store } : m))); } } catch { // Parse errors during streaming are fine — will retry on next chunk @@ -194,14 +202,17 @@ export function useChat(options?: UseChatOptions) { }); }, []); - const applyPreset = useCallback((presetName: string) => { - const preset = PROVIDER_PRESETS[presetName]; - if (preset) { - const next = { ...preset, apiKey: config.apiKey }; - setConfig(next); - saveConfig(next); - } - }, [config.apiKey]); + const applyPreset = useCallback( + (presetName: string) => { + const preset = PROVIDER_PRESETS[presetName]; + if (preset) { + const next = { ...preset, apiKey: config.apiKey }; + setConfig(next); + saveConfig(next); + } + }, + [config.apiKey], + ); const send = useCallback(async () => { const text = input.trim(); @@ -230,9 +241,7 @@ export function useChat(options?: UseChatOptions) { setIsGenerating(true); // Build conversation history for the LLM - const history: LlmMessage[] = [ - { role: 'system', content: systemPromptRef.current }, - ]; + const history: LlmMessage[] = [{ role: 'system', content: systemPromptRef.current }]; for (const m of [...messages, userMsg]) { history.push({ role: m.role, content: m.content }); @@ -305,48 +314,44 @@ export function useChat(options?: UseChatOptions) { /** Update an assistant message's content and re-parse it. */ const updateMessage = useCallback( async (msgId: number, content: string) => { - setMessages((prev) => - prev.map((m) => (m.id === msgId ? { ...m, content } : m)), - ); + setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, content } : m))); await reparseLastAssistant(content, msgId); }, [reparseLastAssistant], ); // Active flow state for multi-step example flows - const flowRef = useRef<{ steps: { userMessage: string; markdown: string }[]; currentStep: number } | null>(null); + const flowRef = useRef<{ + steps: { userMessage: string; markdown: string }[]; + currentStep: number; + } | null>(null); /** Inject a single user+assistant message pair and parse the markdown. */ - const injectStep = useCallback( - async (userMessage: string, markdown: string) => { - const userMsg: ChatMsg = { - id: ++msgIdRef.current, - role: 'user', - content: userMessage, - ast: null, - store: null, - }; - const assistantMsg: ChatMsg = { - id: ++msgIdRef.current, - role: 'assistant', - content: markdown, - ast: null, - store: null, - }; - setMessages((prev) => [...prev, userMsg, assistantMsg]); + const injectStep = useCallback(async (userMessage: string, markdown: string) => { + const userMsg: ChatMsg = { + id: ++msgIdRef.current, + role: 'user', + content: userMessage, + ast: null, + store: null, + }; + const assistantMsg: ChatMsg = { + id: ++msgIdRef.current, + role: 'assistant', + content: markdown, + ast: null, + store: null, + }; + setMessages((prev) => [...prev, userMsg, assistantMsg]); - const asstId = assistantMsg.id; - try { - const { ast, store } = await parseMarkdownRef.current(markdown); - setMessages((prev) => - prev.map((m) => (m.id === asstId ? { ...m, ast, store } : m)), - ); - } catch { - // parse error — content is still shown as raw text - } - }, - [], - ); + const asstId = assistantMsg.id; + try { + const { ast, store } = await parseMarkdownRef.current(markdown); + setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ast, store } : m))); + } catch { + // parse error — content is still shown as raw text + } + }, []); /** Start a multi-step example flow. Loads the first step immediately. */ const startFlow = useCallback( diff --git a/demo/src/creator/CreatorPanel.tsx b/demo/src/creator/CreatorPanel.tsx index 67e0cd4..7f4c1d5 100644 --- a/demo/src/creator/CreatorPanel.tsx +++ b/demo/src/creator/CreatorPanel.tsx @@ -48,7 +48,14 @@ export const CreatorPanel = memo(function CreatorPanel({ onClick={() => setShowRaw((v) => !v)} title={showRaw ? 'Show rendered view' : 'Show MDMA source'} > - + @@ -72,15 +79,20 @@ export const CreatorPanel = memo(function CreatorPanel({ ) : (
- +
-

- Component preview will appear here -

+

Component preview will appear here

Describe a component in the chat and the AI will generate it for you to preview.

@@ -92,29 +104,42 @@ export const CreatorPanel = memo(function CreatorPanel({
{isApproved ? (
- + Component Approved
) : ( <> -
- {current > 0 && {current}/{maxStars}} + {current > 0 && ( + + {current}/{maxStars} + + )}
); }); @@ -106,9 +114,7 @@ const TREND_ARROWS: Record = { flat: '\u2192', }; -export const MetricRenderer = memo(function MetricRenderer({ - component, -}: MdmaBlockRendererProps) { +export const MetricRenderer = memo(function MetricRenderer({ component }: MdmaBlockRendererProps) { const { value, unit, trend } = component as unknown as z.infer; return ( @@ -160,7 +166,9 @@ function FieldTypeSelector({ title="Change field type" > {FIELD_TYPES.map((t) => ( - + ))} ); @@ -190,9 +198,13 @@ function DataSourceSelector({ }} title="Switch data source" > - + {edit.dataSourceNames.map((ds) => ( - + ))} ); @@ -200,27 +212,58 @@ function DataSourceSelector({ // ─── Custom Form Element Overrides (scoped) ───────────────────────────────── -function GlassInput({ id, type, value, onChange, required, name }: FormInputElementProps) { +function GlassInput({ + id, + type, + value, + onChange, + required, + name, + sensitive, +}: FormInputElementProps) { const edit = useEditableField(); const componentId = extractComponentId(id, name); + const [masked, setMasked] = useState(sensitive === true && value !== ''); + const displayType = masked ? 'password' : type; return ( -
+
onChange(e.target.value)} + onChange={(e) => { + onChange(e.target.value); + if (sensitive && masked) setMasked(false); + }} /> + {sensitive && value && ( + + )} {edit && }
); } -function GlassSelect({ id, value, onChange, required, options, name, type }: FormSelectElementProps) { +function GlassSelect({ + id, + value, + onChange, + required, + options, + name, + type, +}: FormSelectElementProps) { const edit = useEditableField(); const componentId = extractComponentId(id, name); @@ -235,7 +278,9 @@ function GlassSelect({ id, value, onChange, required, options, name, type }: For > {options.map((opt) => ( - + ))} {edit && ( @@ -255,29 +300,45 @@ function ToggleCheckbox({ id, checked, onChange, label, name }: FormCheckboxElem return (
- {edit && } + {edit && ( + + )}
); } -function GlassTextarea({ id, value, onChange, required, name }: FormTextareaElementProps) { +function GlassTextarea({ + id, + value, + onChange, + required, + name, + sensitive, +}: FormTextareaElementProps) { const edit = useEditableField(); const componentId = extractComponentId(id, name); return ( -
+