MAESTRO: Implement PromptEditor component with Jinja2 syntax highlighting, variable sidebar, and preview

Built standalone PromptEditor with transparent-textarea overlay for syntax
highlighting of Jinja2 expressions, statements, and comments. Includes
clickable variable sidebar for insertion and preview panel with sample data
substitution. Integrated into ExperimentPage PipelineStageCard. 27 tests added.
This commit is contained in:
John Lightner 2026-04-07 02:56:48 -05:00
parent 405bbf8206
commit f2e6baa56f
5 changed files with 562 additions and 56 deletions

View file

@ -14,9 +14,11 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments. - [x] Implement the Projects page (frontend/src/pages/Projects.tsx). Card grid showing all projects with name, description, experiment count, last activity timestamp, and a progress indicator showing best score across all experiments. Include a "New Project" button that opens a creation modal. Click a card to navigate to its experiments.
<!-- Implemented in ProjectsPage.tsx. Shows loading/error/empty states. Card grid with name, description, last activity timestamp, experiment & best score indicators. "New Project" button opens creation modal with name + description fields, validation, and error handling. Cards navigate to /experiments/:id on click. 12 tests added. --> <!-- Implemented in ProjectsPage.tsx. Shows loading/error/empty states. Card grid with name, description, last activity timestamp, experiment & best score indicators. "New Project" button opens creation modal with name + description fields, validation, and error handling. Cards navigate to /experiments/:id on click. 12 tests added. -->
- [ ] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep. - [x] Implement the Experiment Builder (frontend/src/pages/Experiment.tsx). This is the most complex page. It has several sections: (1) Basic info (name, description), (2) Sample data input (paste text, upload file, or enter JSON), (3) Pipeline stage builder (add/remove stages, each with a prompt template editor with syntax highlighting, model selector dropdown populated from configured endpoints, and parameter controls), (4) Scoring config (checkboxes for which scorers to enable, weight sliders for each), (5) Parameter space definition (for each parameter, set type: fixed/range/options and values), (6) Action buttons: Save Draft, Run Single, Start Sweep.
<!-- Implemented in ExperimentPage.tsx (existing file convention). All six sections: BasicInfoSection (name + description), SampleDataSection (text/JSON/file upload modes with JSON validation), PipelineSection (add/remove stages, prompt template editor with variable hints and preview, model selector from endpoints), ScoringSection (5 scorers with enable checkboxes and weight sliders), ParameterSpaceSection (add/remove params, fixed/range/options types), ActionButtons (Save Draft, Run Single, Start Sweep). Supports new + edit modes via route param. Loads endpoints for model selectors. 20 tests added. App.test.tsx updated for new page behavior. -->
- [ ] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data. - [x] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data.
<!-- Implemented PromptEditor with transparent-textarea-over-highlighted-backdrop approach for Jinja2 syntax highlighting ({{ expressions }}, {% statements %}, {# comments #}). Variable sidebar with clickable insert buttons. Preview panel renders template with sample data substitution. Integrated into ExperimentPage's PipelineStageCard, replacing the old inline textarea. 27 tests added, existing ExperimentPage tests updated. -->
- [ ] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error). - [ ] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error).

View file

@ -0,0 +1,270 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import PromptEditor, { tokenize, renderPreview } from "./PromptEditor";
// ---------------------------------------------------------------------------
// Unit: tokenize()
// ---------------------------------------------------------------------------
describe("tokenize", () => {
it("returns single text token for plain text", () => {
const tokens = tokenize("Hello world");
expect(tokens).toEqual([{ type: "text", value: "Hello world" }]);
});
it("tokenizes {{ expression }} blocks", () => {
const tokens = tokenize("Hello {{ name }}!");
expect(tokens).toEqual([
{ type: "text", value: "Hello " },
{ type: "expression", value: "{{ name }}" },
{ type: "text", value: "!" },
]);
});
it("tokenizes {% statement %} blocks", () => {
const tokens = tokenize("{% if x %}yes{% endif %}");
expect(tokens).toEqual([
{ type: "statement", value: "{% if x %}" },
{ type: "text", value: "yes" },
{ type: "statement", value: "{% endif %}" },
]);
});
it("tokenizes {# comment #} blocks", () => {
const tokens = tokenize("{# this is a comment #}");
expect(tokens).toEqual([
{ type: "comment", value: "{# this is a comment #}" },
]);
});
it("handles mixed delimiters", () => {
const tokens = tokenize("{{ a }}{# b #}{% c %}");
expect(tokens).toHaveLength(3);
expect(tokens[0].type).toBe("expression");
expect(tokens[1].type).toBe("comment");
expect(tokens[2].type).toBe("statement");
});
it("treats unclosed delimiter as text", () => {
const tokens = tokenize("Hello {{ name");
expect(tokens).toEqual([
{ type: "text", value: "Hello " },
{ type: "text", value: "{{ name" },
]);
});
it("handles empty string", () => {
expect(tokenize("")).toEqual([]);
});
it("handles adjacent delimiters", () => {
const tokens = tokenize("{{ a }}{{ b }}");
expect(tokens).toEqual([
{ type: "expression", value: "{{ a }}" },
{ type: "expression", value: "{{ b }}" },
]);
});
});
// ---------------------------------------------------------------------------
// Unit: renderPreview()
// ---------------------------------------------------------------------------
describe("renderPreview", () => {
it("replaces known variables with sample data", () => {
expect(
renderPreview("Hello {{ name }}, welcome!", { name: "Alice" }),
).toBe("Hello Alice, welcome!");
});
it("replaces unknown variables with [varName] placeholder", () => {
expect(renderPreview("Hello {{ name }}", {})).toBe("Hello [name]");
});
it("handles multiple variables", () => {
expect(
renderPreview("{{ greeting }} {{ name }}", {
greeting: "Hi",
name: "Bob",
}),
).toBe("Hi Bob");
});
it("handles templates with no variables", () => {
expect(renderPreview("No variables here", {})).toBe("No variables here");
});
it("handles whitespace variations in delimiters", () => {
expect(renderPreview("{{name}}", { name: "X" })).toBe("X");
expect(renderPreview("{{ name }}", { name: "Y" })).toBe("Y");
});
});
// ---------------------------------------------------------------------------
// Component tests
// ---------------------------------------------------------------------------
describe("PromptEditor", () => {
const defaultProps = {
value: "",
onChange: vi.fn(),
variables: ["input_data", "previous_stage_output", "stage_index"],
sampleData: { input_data: "Hello world", previous_stage_output: "Summary" },
"data-testid": "editor",
};
it("renders the textarea", () => {
render(<PromptEditor {...defaultProps} />);
expect(screen.getByTestId("editor-textarea")).toBeInTheDocument();
});
it("renders with provided value", () => {
render(<PromptEditor {...defaultProps} value="Hello {{ input_data }}" />);
expect(screen.getByTestId("editor-textarea")).toHaveValue(
"Hello {{ input_data }}",
);
});
it("calls onChange when typing", async () => {
const onChange = vi.fn();
render(<PromptEditor {...defaultProps} onChange={onChange} />);
const textarea = screen.getByTestId("editor-textarea");
await userEvent.type(textarea, "H");
expect(onChange).toHaveBeenCalled();
});
it("renders variable sidebar when variables are provided", () => {
render(<PromptEditor {...defaultProps} />);
const sidebar = screen.getByTestId("editor-variables");
expect(sidebar).toBeInTheDocument();
expect(within(sidebar).getByText("{{ input_data }}")).toBeInTheDocument();
expect(
within(sidebar).getByText("{{ previous_stage_output }}"),
).toBeInTheDocument();
expect(within(sidebar).getByText("{{ stage_index }}")).toBeInTheDocument();
});
it("does not render sidebar when no variables provided", () => {
render(<PromptEditor {...defaultProps} variables={[]} />);
expect(screen.queryByTestId("editor-variables")).not.toBeInTheDocument();
});
it("inserts variable on sidebar button click", async () => {
const onChange = vi.fn();
render(
<PromptEditor {...defaultProps} value="" onChange={onChange} />,
);
const btn = screen.getByText("{{ input_data }}");
await userEvent.click(btn);
expect(onChange).toHaveBeenCalledWith("{{ input_data }}");
});
it("shows preview button", () => {
render(<PromptEditor {...defaultProps} />);
expect(screen.getByTestId("editor-preview-btn")).toBeInTheDocument();
expect(screen.getByTestId("editor-preview-btn")).toHaveTextContent(
"Preview",
);
});
it("toggles preview panel on button click", async () => {
render(
<PromptEditor
{...defaultProps}
value="Test {{ input_data }}"
/>,
);
// Preview panel hidden initially
expect(screen.queryByTestId("editor-preview")).not.toBeInTheDocument();
// Click to show
await userEvent.click(screen.getByTestId("editor-preview-btn"));
expect(screen.getByTestId("editor-preview")).toBeInTheDocument();
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
"Test Hello world",
);
// Button text changes
expect(screen.getByTestId("editor-preview-btn")).toHaveTextContent(
"Hide Preview",
);
// Click to hide
await userEvent.click(screen.getByTestId("editor-preview-btn"));
expect(screen.queryByTestId("editor-preview")).not.toBeInTheDocument();
});
it("renders preview with sample data substitution", async () => {
render(
<PromptEditor
{...defaultProps}
value="Input: {{ input_data }}, Prev: {{ previous_stage_output }}"
/>,
);
await userEvent.click(screen.getByTestId("editor-preview-btn"));
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
"Input: Hello world, Prev: Summary",
);
});
it("shows placeholder for unknown variables in preview", async () => {
render(
<PromptEditor
{...defaultProps}
value="Hello {{ unknown_var }}"
/>,
);
await userEvent.click(screen.getByTestId("editor-preview-btn"));
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
"Hello [unknown_var]",
);
});
it("shows (empty template) when value is empty in preview", async () => {
render(<PromptEditor {...defaultProps} value="" />);
await userEvent.click(screen.getByTestId("editor-preview-btn"));
expect(screen.getByTestId("editor-preview")).toHaveTextContent(
"(empty template)",
);
});
it("renders syntax highlighting spans in backdrop", () => {
render(
<PromptEditor
{...defaultProps}
value="Hello {{ input_data }}!"
/>,
);
const highlight = screen.getByTestId("editor-highlight");
const spans = highlight.querySelectorAll("span");
// At least 3 spans: "Hello ", "{{ input_data }}", "!"
expect(spans.length).toBeGreaterThanOrEqual(3);
});
it("applies expression color class to {{ }} tokens", () => {
render(
<PromptEditor
{...defaultProps}
value="{{ input_data }}"
/>,
);
const highlight = screen.getByTestId("editor-highlight");
const exprSpan = highlight.querySelector("span");
expect(exprSpan?.className).toContain("text-amber");
});
it("uses custom placeholder", () => {
render(
<PromptEditor
{...defaultProps}
placeholder="Custom placeholder text"
/>,
);
expect(screen.getByTestId("editor-textarea")).toHaveAttribute(
"placeholder",
"Custom placeholder text",
);
});
});

View file

@ -0,0 +1,272 @@
import { useState, useRef, useCallback, useMemo } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PromptEditorProps {
/** Current template value */
value: string;
/** Called when the template text changes */
onChange: (value: string) => void;
/** Available template variables to show in sidebar */
variables?: string[];
/** Sample data for preview rendering */
sampleData?: Record<string, string>;
/** Placeholder text */
placeholder?: string;
/** Number of visible rows */
rows?: number;
/** Test id */
"data-testid"?: string;
}
// ---------------------------------------------------------------------------
// Jinja2 syntax highlighting
// ---------------------------------------------------------------------------
/** Segment types produced by the tokenizer. */
type TokenType = "text" | "expression" | "statement" | "comment";
interface Token {
type: TokenType;
value: string;
}
/**
* Tokenize a Jinja2 template into segments for syntax highlighting.
* Handles {{ expr }}, {% stmt %}, and {# comment #} blocks.
*/
export function tokenize(template: string): Token[] {
const tokens: Token[] = [];
let pos = 0;
const patterns: { open: string; close: string; type: TokenType }[] = [
{ open: "{{", close: "}}", type: "expression" },
{ open: "{%", close: "%}", type: "statement" },
{ open: "{#", close: "#}", type: "comment" },
];
while (pos < template.length) {
// Find the nearest opening delimiter
let nearestIdx = template.length;
let matched: (typeof patterns)[0] | null = null;
for (const pat of patterns) {
const idx = template.indexOf(pat.open, pos);
if (idx !== -1 && idx < nearestIdx) {
nearestIdx = idx;
matched = pat;
}
}
// Push any leading plain text
if (nearestIdx > pos) {
tokens.push({ type: "text", value: template.slice(pos, nearestIdx) });
}
if (!matched) {
// No more delimiters — done
break;
}
// Find closing delimiter
const closeIdx = template.indexOf(
matched.close,
nearestIdx + matched.open.length,
);
if (closeIdx === -1) {
// Unclosed delimiter — treat rest as text
tokens.push({ type: "text", value: template.slice(nearestIdx) });
pos = template.length;
break;
}
tokens.push({
type: matched.type,
value: template.slice(nearestIdx, closeIdx + matched.close.length),
});
pos = closeIdx + matched.close.length;
}
return tokens;
}
/** Map token type to Tailwind colour classes. */
function tokenClass(type: TokenType): string {
switch (type) {
case "expression":
return "text-amber-600 dark:text-amber-400";
case "statement":
return "text-violet-600 dark:text-violet-400";
case "comment":
return "text-slate-400 dark:text-slate-500 italic";
default:
return "text-slate-800 dark:text-slate-200";
}
}
// ---------------------------------------------------------------------------
// Preview renderer
// ---------------------------------------------------------------------------
/**
* Render a Jinja2-style template by replacing {{ var }} with sample values.
* Only handles simple variable substitution not full Jinja2.
*/
export function renderPreview(
template: string,
sampleData: Record<string, string>,
): string {
return template.replace(
/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g,
(_match, varName: string) => {
return varName in sampleData ? sampleData[varName] : `[${varName}]`;
},
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function PromptEditor({
value,
onChange,
variables = [],
sampleData = {},
placeholder = "Enter your prompt template using {{ variable }} syntax...",
rows = 6,
"data-testid": testId,
}: PromptEditorProps) {
const [showPreview, setShowPreview] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
// Tokenize the current value for the highlight overlay
const tokens = useMemo(() => tokenize(value), [value]);
// Sync scroll between textarea and highlight layer
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
// Insert a variable at cursor position
function insertVariable(varName: string) {
const ta = textareaRef.current;
if (!ta) return;
const insertion = `{{ ${varName} }}`;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const newValue =
value.slice(0, start) + insertion + value.slice(end);
onChange(newValue);
// Restore cursor after insertion
requestAnimationFrame(() => {
ta.focus();
const newCursor = start + insertion.length;
ta.setSelectionRange(newCursor, newCursor);
});
}
const preview = useMemo(
() => renderPreview(value, sampleData),
[value, sampleData],
);
return (
<div data-testid={testId} className="space-y-2">
<div className="flex gap-3">
{/* Editor area */}
<div className="flex-1 min-w-0">
<div className="relative rounded-lg border border-slate-300 dark:border-slate-600 overflow-hidden">
{/* Highlighted backdrop */}
<div
ref={highlightRef}
aria-hidden="true"
className="absolute inset-0 overflow-hidden whitespace-pre-wrap break-words px-3 py-2 text-sm font-mono leading-relaxed pointer-events-none"
data-testid={testId ? `${testId}-highlight` : undefined}
>
{tokens.map((tok, i) => (
<span key={i} className={tokenClass(tok.type)}>
{tok.value}
</span>
))}
{/* Ensure the highlight div is at least as tall as rows */}
{value === "" && (
<span className="text-transparent">{placeholder}</span>
)}
</div>
{/* Transparent textarea on top */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
rows={rows}
placeholder={placeholder}
spellCheck={false}
className="relative w-full bg-transparent px-3 py-2 text-sm font-mono leading-relaxed text-transparent caret-slate-900 dark:caret-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 resize-none z-10"
style={{ caretColor: "inherit" }}
data-testid={testId ? `${testId}-textarea` : undefined}
/>
</div>
</div>
{/* Variable sidebar */}
{variables.length > 0 && (
<div
className="w-44 shrink-0 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 p-3"
data-testid={testId ? `${testId}-variables` : undefined}
>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Variables
</h4>
<ul className="space-y-1">
{variables.map((v) => (
<li key={v}>
<button
type="button"
onClick={() => insertVariable(v)}
className="w-full rounded px-2 py-1 text-left text-xs font-mono text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition truncate"
title={`Insert {{ ${v} }}`}
>
{`{{ ${v} }}`}
</button>
</li>
))}
</ul>
</div>
)}
</div>
{/* Preview toggle and panel */}
<div>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 transition"
data-testid={testId ? `${testId}-preview-btn` : undefined}
>
{showPreview ? "Hide Preview" : "Preview"}
</button>
{showPreview && (
<pre
className="mt-2 rounded-lg bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 p-3 text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap break-words"
data-testid={testId ? `${testId}-preview` : undefined}
>
{preview || "(empty template)"}
</pre>
)}
</div>
</div>
);
}

View file

@ -275,14 +275,12 @@ describe("ExperimentPage", () => {
await user.click(screen.getByRole("button", { name: "Add Stage" })); await user.click(screen.getByRole("button", { name: "Add Stage" }));
const stage = screen.getByTestId("pipeline-stage-0"); const stage = screen.getByTestId("pipeline-stage-0");
const templateInput = within(stage).getByPlaceholderText( const templateInput = within(stage).getByTestId("stage-editor-0-textarea");
/Enter your prompt template/,
);
await user.type(templateInput, "Summarize: {{{{ input_data }}}}"); await user.type(templateInput, "Summarize: {{{{ input_data }}}}");
await user.click(within(stage).getByText("Preview")); await user.click(within(stage).getByTestId("stage-editor-0-preview-btn"));
expect(screen.getByTestId("stage-preview-0")).toHaveTextContent( expect(screen.getByTestId("stage-editor-0-preview")).toHaveTextContent(
"Summarize: [input_data]", "Summarize: (sample input)",
); );
}); });

View file

@ -10,6 +10,7 @@ import type {
ExperimentUpdate, ExperimentUpdate,
EndpointResponse, EndpointResponse,
} from "../api/client"; } from "../api/client";
import PromptEditor from "../components/PromptEditor";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@ -301,19 +302,6 @@ function PipelineStageCard({
onUpdate: (s: PipelineStage) => void; onUpdate: (s: PipelineStage) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const [showPreview, setShowPreview] = useState(false);
function renderPreview(): string {
let result = stage.prompt_template;
TEMPLATE_VARIABLES.forEach((v) => {
result = result.replace(
new RegExp(`\\{\\{\\s*${v}\\s*\\}\\}`, "g"),
`[${v}]`,
);
});
return result;
}
return ( return (
<div <div
data-testid={`pipeline-stage-${index}`} data-testid={`pipeline-stage-${index}`}
@ -340,26 +328,19 @@ function PipelineStageCard({
<label className="mb-1 block text-xs font-medium text-slate-600 dark:text-slate-400"> <label className="mb-1 block text-xs font-medium text-slate-600 dark:text-slate-400">
Prompt Template Prompt Template
</label> </label>
<textarea <PromptEditor
value={stage.prompt_template} value={stage.prompt_template}
onChange={(e) => onChange={(v) => onUpdate({ ...stage, prompt_template: v })}
onUpdate({ ...stage, prompt_template: e.target.value }) variables={TEMPLATE_VARIABLES}
} sampleData={{
input_data: "(sample input)",
previous_stage_output: "(previous output)",
stage_index: String(index),
experiment_name: "(experiment)",
}}
rows={4} rows={4}
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-900 px-3 py-2 text-sm font-mono text-slate-900 dark:text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition resize-none" data-testid={`stage-editor-${index}`}
placeholder="Enter your prompt template using {{ variable }} syntax..."
/> />
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-slate-400">Variables:</span>
{TEMPLATE_VARIABLES.map((v) => (
<span
key={v}
className="rounded bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 text-xs font-mono text-slate-600 dark:text-slate-300"
>
{`{{ ${v} }}`}
</span>
))}
</div>
</div> </div>
{/* Model selector */} {/* Model selector */}
@ -388,24 +369,7 @@ function PipelineStageCard({
</select> </select>
</div> </div>
{/* Preview button */} {/* Preview is now integrated in PromptEditor above */}
<div>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 transition"
>
{showPreview ? "Hide Preview" : "Preview"}
</button>
{showPreview && (
<pre
data-testid={`stage-preview-${index}`}
className="mt-2 rounded-lg bg-slate-50 dark:bg-slate-900 p-3 text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap"
>
{renderPreview() || "(empty template)"}
</pre>
)}
</div>
</div> </div>
); );
} }