Files
resolutionflow/docs/superpowers/plans/2026-03-13-script-library-pane-takeover.md
chihlasm d4dbf44781 feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector
Complete Script Generator feature including:

Backend:
- ScriptCategory, ScriptTemplate, ScriptGeneration models
- ScriptTemplateEngine with substitution, filters, sanitization
- CRUD + share API endpoints with permission checks
- Integration tests for permissions and sharing
- Migration 057 with AD User Management seed templates

Frontend — Script Library:
- Browse templates with category tabs and search
- Configure pane with parameter form and script generation
- Script preview with live substitution and copy/download
- scriptGeneratorStore Zustand store

Frontend — Template Editor:
- Full CRUD form with metadata, script body (Monaco Editor), parameters
- ParameterSchemaBuilder with visual builder + JSON toggle
- ScriptManagePage with routing and nav link

Frontend — Parameter Detector:
- Client-side PowerShell parameter detection engine
- Detects script-level param() blocks and variable assignments
- Type inference from PS type annotations and value patterns
- ParameterDetectorStepper one-by-one review UI with accept/skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:18:59 -04:00

689 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Script Library Pane Takeover — Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign the Script Library left pane to have Browse mode (template list + filter bar) and Configure mode (full-height parameter form + action buttons), with the right pane becoming a read-only `ScriptPreview`.
**Architecture:** `ScriptLibraryPage` owns `paneMode` local state (`'browse' | 'configure'`). Clicking "Configure →" on a `TemplateCard` calls `store.selectTemplate(id)` then flips the pane. `ScriptConfigurePane` (new component) owns the configure-mode layout — back button, template header, param form, action bar. `ScriptGeneratorPanel` is deleted; the right pane becomes `ScriptPreview` in isolation.
**Tech Stack:** React 19, TypeScript, Zustand (`useScriptGeneratorStore`), Tailwind CSS v3, Lucide React. Verification: `npx tsc -b --noEmit` (NOT `npm run build` — pre-existing Node 18 incompatibility with Vite).
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `frontend/src/components/scripts/TemplateCard.tsx` | Modify | Non-interactive card with "Configure →" button; no store subscription |
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Modify | Thread `onConfigure` prop to each `TemplateCard` |
| `frontend/src/components/scripts/ScriptConfigurePane.tsx` | Create | Configure mode layout: back button, template header, form, action bar |
| `frontend/src/pages/ScriptLibraryPage.tsx` | Modify | `paneMode` state, filter-bar moved into left pane, right pane simplified |
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | Delete | Superseded by `ScriptConfigurePane` + right-pane simplification |
---
## Chunk 1: All Tasks
### Task 1: Modify `TemplateCard` — remove store subscription, add "Configure →" button
**Files:**
- Modify: `frontend/src/components/scripts/TemplateCard.tsx`
Current state: `TemplateCard` is a `<button>` that calls `store.selectTemplate()` on click and applies active-border styling when `selectedTemplate?.id === template.id`. It imports `useScriptGeneratorStore`.
- [ ] **Step 1: Replace the entire file with the updated implementation**
```tsx
import { ShieldAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ScriptTemplateListItem } from '@/types'
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
beginner: 'text-emerald-400 bg-emerald-400/10',
intermediate: 'text-amber-400 bg-amber-400/10',
advanced: 'text-rose-500 bg-rose-500/10',
}
interface Props {
template: ScriptTemplateListItem
onConfigure: (id: string) => void
}
export function TemplateCard({ template, onConfigure }: Props) {
return (
<div
className={cn(
'w-full text-left px-4 py-3 rounded-xl border transition-all',
'border-border bg-transparent'
)}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm font-medium text-foreground line-clamp-1">
{template.name}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{template.requires_elevation && (
<span title="Requires administrator elevation">
<ShieldAlert size={13} className="text-amber-400" />
</span>
)}
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
{template.complexity}
</span>
</div>
</div>
{template.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{template.description}
</p>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-[0.625rem] text-muted-foreground font-label">
<span>{template.usage_count}× used</span>
{template.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{template.tags.slice(0, 3).map(tag => (
<span key={tag} className="bg-white/5 border border-border rounded px-1.5 py-0.5">
{tag}
</span>
))}
{template.tags.length > 3 && (
<span className="text-muted-foreground">+{template.tags.length - 3}</span>
)}
</div>
)}
</div>
<button
type="button"
onClick={() => onConfigure(template.id)}
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
>
Configure
</button>
</div>
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: errors only from `ScriptTemplateList` (it still passes no `onConfigure` prop) — that's fine, it's fixed in Task 2.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/TemplateCard.tsx
git commit -m "refactor: TemplateCard — remove store subscription, add Configure button"
```
---
### Task 2: Modify `ScriptTemplateList` — thread `onConfigure` prop
**Files:**
- Modify: `frontend/src/components/scripts/ScriptTemplateList.tsx`
Current state: `ScriptTemplateList` accepts `{ inputValue, onClearSearch }` and renders `<TemplateCard template={template} />` with no extra props.
- [ ] **Step 1: Update the file**
```tsx
import { FileCode, Search } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { TemplateCard } from './TemplateCard'
interface Props {
inputValue: string
onClearSearch: () => void
onConfigure: (id: string) => void
}
function TemplateSkeleton() {
return (
<div className="px-4 py-3 rounded-xl border border-border animate-pulse">
<div className="flex justify-between mb-2">
<div className="h-4 w-2/3 bg-white/10 rounded" />
<div className="h-4 w-14 bg-white/10 rounded" />
</div>
<div className="h-3 w-full bg-white/5 rounded mb-1" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
)
}
export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: Props) {
const templates = useScriptGeneratorStore(s => s.templates)
const isLoadingTemplates = useScriptGeneratorStore(s => s.isLoadingTemplates)
if (isLoadingTemplates) {
return (
<div className="flex flex-col gap-2 p-2">
<TemplateSkeleton />
<TemplateSkeleton />
<TemplateSkeleton />
</div>
)
}
if (templates.length === 0) {
if (inputValue !== '') {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<Search size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates match your search</p>
<button
type="button"
onClick={onClearSearch}
className="text-xs text-primary hover:underline"
>
Clear search
</button>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<FileCode size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates found</p>
</div>
)
}
return (
<div className="flex flex-col gap-2 p-2">
{templates.map(template => (
<TemplateCard key={template.id} template={template} onConfigure={onConfigure} />
))}
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: errors now only from `ScriptLibraryPage` (it passes no `onConfigure` to `ScriptTemplateList` yet) — that's fine.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptTemplateList.tsx
git commit -m "refactor: ScriptTemplateList — add onConfigure prop threading"
```
---
### Task 3: Create `ScriptConfigurePane` — configure mode layout
**Files:**
- Create: `frontend/src/components/scripts/ScriptConfigurePane.tsx`
This new component renders the full configure-mode left pane: back button, loading spinner (when `isLoadingDetail`), first-selection error state, template header with tags, `ScriptParameterForm`, warnings callout, and the Generate/Download/Copy action bar.
Data comes from `useScriptGeneratorStore` directly. `canGenerate` and `onBack` come from props.
The action-bar Copy button copies `store.generatedScript` and is disabled when `generatedScript === null`. The Download button uses `selectedTemplate.slug` for the filename. Both Generate and Download are disabled when `isGenerating || !canGenerate`. Copy is disabled when `!generatedScript || !canGenerate`.
- [ ] **Step 1: Create the file**
```tsx
import { useState } from 'react'
import { ArrowLeft, Terminal, Download, Loader2, AlertTriangle, Copy, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptParameterForm } from './ScriptParameterForm'
const COMPLEXITY_CLASSES = {
beginner: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20',
intermediate: 'text-amber-400 bg-amber-400/10 border-amber-400/20',
advanced: 'text-rose-500 bg-rose-500/10 border-rose-500/20',
} as const
interface Props {
canGenerate: boolean
onBack: () => void
}
export function ScriptConfigurePane({ canGenerate, onBack }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
const categories = useScriptGeneratorStore(s => s.categories)
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
const generationWarnings = useScriptGeneratorStore(s => s.generationWarnings)
const isGenerating = useScriptGeneratorStore(s => s.isGenerating)
const generateError = useScriptGeneratorStore(s => s.generateError)
const generate = useScriptGeneratorStore(s => s.generate)
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!generatedScript) return
try {
await navigator.clipboard.writeText(generatedScript)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silently fail
}
}
const handleDownload = () => {
if (!generatedScript || !selectedTemplate) return
const blob = new Blob([generatedScript], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedTemplate.slug}.ps1`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Loading state
if (isLoadingDetail) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex items-center justify-center">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
</div>
)
}
// First-selection failure state
if (!selectedTemplate) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
<Terminal size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Failed to load template.</p>
</div>
</div>
)
}
const categoryName = categories.find(c => c.id === selectedTemplate.category_id)?.name
const displayTags = selectedTemplate.tags.slice(0, 3)
const extraTagCount = selectedTemplate.tags.length - 3
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
{/* Template header */}
<div className="mb-3">
<h2 className="text-base font-semibold font-heading text-foreground">
{selectedTemplate.name}
</h2>
{selectedTemplate.description && (
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
)}
<div className="flex items-center gap-1.5 flex-wrap mt-2">
{selectedTemplate.requires_elevation && (
<span
title="Requires administrator elevation"
className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border text-amber-400 bg-amber-400/10 border-amber-400/20"
>
Elevated
</span>
)}
<span className={cn(
'font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
COMPLEXITY_CLASSES[selectedTemplate.complexity]
)}>
{selectedTemplate.complexity}
</span>
{categoryName && (
<span className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{categoryName}
</span>
)}
{displayTags.map(tag => (
<span key={tag} className="font-label text-[0.625rem] px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{tag}
</span>
))}
{extraTagCount > 0 && (
<span className="font-label text-[0.625rem] text-muted-foreground">+{extraTagCount}</span>
)}
</div>
</div>
<div className="border-t border-border mb-4" />
{/* Parameter form */}
<ScriptParameterForm canGenerate={canGenerate} />
{/* Warnings */}
{generationWarnings.length > 0 && (
<div className="flex flex-col gap-1 rounded-lg border border-amber-400/20 bg-amber-400/5 p-3 mt-4">
<div className="flex items-center gap-1.5 text-amber-400 text-xs font-medium mb-1">
<AlertTriangle size={13} />
Warnings
</div>
{generationWarnings.map((w, i) => (
<p key={i} className="text-xs text-amber-400/80">{w}</p>
))}
</div>
)}
{/* Action bar */}
<div className="flex flex-col gap-2 mt-4 pt-1">
<span title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={() => generate()}
disabled={isGenerating || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-2 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
>
{isGenerating && <Loader2 size={14} className="animate-spin" />}
Generate Script
</button>
</span>
<div className="flex gap-2">
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleDownload}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={14} />
Download .ps1
</button>
</span>
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleCopy}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</span>
</div>
</div>
{/* Generate error */}
{generateError && (
<p className="text-xs text-rose-500 mt-2">{generateError}</p>
)}
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: No new errors from this file. Errors may still exist from `ScriptLibraryPage` not yet updated — that's fine.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptConfigurePane.tsx
git commit -m "feat: add ScriptConfigurePane — configure mode layout"
```
---
### Task 4: Rewrite `ScriptLibraryPage` — pane mode, filter bar in column, right pane simplified
**Files:**
- Modify: `frontend/src/pages/ScriptLibraryPage.tsx`
Changes:
1. Add `paneMode` state (`'browse' | 'configure'`)
2. Add `usePermissions` for `canGenerate`
3. Add `selectedTemplate` subscription for right-pane conditional
4. Move `ScriptFilterBar` into the left pane column (only rendered in browse mode)
5. Add `onConfigure` / `onBack` callbacks
6. Left pane conditionally renders browse content or `ScriptConfigurePane`
7. Right pane: empty state when `selectedTemplate === null`, otherwise `ScriptPreview` in `overflow-hidden` wrapper
- [ ] **Step 1: Replace the entire file**
```tsx
import { useState, useEffect } from 'react'
import { Terminal } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { usePermissions } from '@/hooks/usePermissions'
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
export default function ScriptLibraryPage() {
const [inputValue, setInputValue] = useState('')
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const { isEngineer } = usePermissions()
const canGenerate = isEngineer
useEffect(() => {
loadCategories().then(() => loadTemplates())
}, [loadCategories, loadTemplates])
const onClearSearch = () => {
setInputValue('')
setSearch('')
}
const onConfigure = (id: string) => {
selectTemplate(id)
setPaneMode('configure')
}
const onBack = () => {
clearOutput()
setPaneMode('browse')
}
return (
<div className="flex flex-col gap-4 p-6 h-full">
{/* Page header */}
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
</p>
</div>
{/* Two-column layout */}
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
{/* Left pane — Browse or Configure */}
{paneMode === 'browse' ? (
<div className="glass-card-static flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
</div>
<div className="flex-1 overflow-y-auto">
<ScriptTemplateList
inputValue={inputValue}
onClearSearch={onClearSearch}
onConfigure={onConfigure}
/>
</div>
</div>
) : (
<ScriptConfigurePane canGenerate={canGenerate} onBack={onBack} />
)}
{/* Right pane — always ScriptPreview */}
{selectedTemplate === null ? (
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<Terminal size={40} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Select a template to get started</p>
</div>
) : (
<div className="glass-card-static h-full overflow-hidden p-4">
<ScriptPreview />
</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript — expect clean**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: Zero errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/ScriptLibraryPage.tsx
git commit -m "feat: ScriptLibraryPage — pane takeover with Browse/Configure modes"
```
---
### Task 5: Delete `ScriptGeneratorPanel`
**Files:**
- Delete: `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`
`ScriptGeneratorPanel` is no longer imported or used anywhere — `ScriptLibraryPage` now uses `ScriptConfigurePane` for the left pane and `ScriptPreview` directly for the right pane.
- [ ] **Step 1: Verify nothing imports `ScriptGeneratorPanel`**
```bash
grep -r "ScriptGeneratorPanel" /home/michaelchihlas/dev/patherly/frontend/src
```
Expected: No output (zero matches).
- [ ] **Step 2: Delete the file**
```bash
rm /home/michaelchihlas/dev/patherly/frontend/src/components/scripts/ScriptGeneratorPanel.tsx
```
- [ ] **Step 3: Verify TypeScript still clean**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: Zero errors.
- [ ] **Step 4: Commit**
```bash
git add -u frontend/src/components/scripts/ScriptGeneratorPanel.tsx
git commit -m "chore: delete ScriptGeneratorPanel — superseded by ScriptConfigurePane"
```
---
### Task 6: Smoke test
- [ ] **Step 1: Start dev server**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run dev
```
- [ ] **Step 2: Verify browse mode**
Open `http://localhost:5173/scripts`.
Expected:
- Template list shows with filter bar above it (inside the left pane, no filter bar outside the pane)
- Each template card has a "Configure →" button at bottom-right
- Clicking anywhere on the card body (not the button) does nothing
- Right pane shows Terminal icon + "Select a template to get started"
- [ ] **Step 3: Verify configure mode**
Click "Configure →" on any template.
Expected:
- Left pane transitions to configure view (filter bar and list hidden)
- Loading spinner visible briefly, then full configure view appears
- Template name, description, complexity badge, category name, tags visible at top
- Parameter form below (all fields interactive if engineer role)
- "Generate Script" button full-width, cyan gradient
- "Download .ps1" and "Copy" buttons disabled (no generated script yet)
- Right pane still shows empty state (first click) or previous preview
- [ ] **Step 4: Verify generate flow**
Fill required parameters, click "Generate Script".
Expected:
- Spinner appears on Generate button during generation
- Right pane updates to show generated PowerShell (with syntax highlighting)
- "Download .ps1" and "Copy" buttons become enabled
- Copy button copies text; shows "Copied!" for 2 seconds
- Download button triggers `.ps1` file download
- [ ] **Step 5: Verify Back**
Click "← Back to library".
Expected:
- Left pane returns to browse mode (filter bar + template list visible)
- Search input and category pills restore to previous state
- Right pane continues showing the previously generated output
- [ ] **Step 6: Stop dev server and push**
```bash
git push origin feat/script-generator
```