Files
resolutionflow/docs/superpowers/specs/2026-03-13-script-generator-phase2-design.md

22 KiB

Script Generator Phase 2 — Frontend Design

Date: 2026-03-13 Status: Approved Phase: 2 of Script Generator feature Builds on: Phase 1 backend (feat/script-generator PR #105)


Goal

Build the Script Library frontend: a page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script.


Architecture

Layout: Top filter bar + two-column layout. Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.

State: Zustand store (scriptGeneratorStore) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation — stale state is intentional and supports Phase 3 embeddability. reset() is a public action exposed for Phase 3 callers (e.g. session execution context that needs to clear output between runs). Within Phase 2, it is never called automatically; selectTemplate() clears form/output state inline via its own atomic set() call without calling reset().

Preview: Client-side lightweight substitution for live preview as the user types ({{key}} replacement only, matching the backend template variable syntax). The real POST /scripts/generate endpoint is called on Generate — it applies filters, conditionals, and PowerShell-safe sanitization on the backend.

Security note: The viewer restriction on Generate/Download is enforced frontend-only in Phase 2. The backend POST /scripts/generate endpoint uses only get_current_active_user with no role check. Adding a backend engineer role guard is deferred — this is a known limitation.

Search note: The backend ?search= parameter matches against name, description, and slug. Tags are filtered separately via ?tags= (comma-separated). Phase 2 does not expose a tag filter UI — search covers name/description/slug only.


Zustand Store — scriptGeneratorStore

File: frontend/src/store/scriptGeneratorStore.ts

State shape

interface ScriptGeneratorState {
  // Template browsing
  categories: ScriptCategoryResponse[];
  templates: ScriptTemplateListItem[];
  selectedTemplate: ScriptTemplateDetail | null;
  searchQuery: string;
  activeCategoryId: string | null;    // null = "All"; used to derive slug for API calls
  isLoadingTemplates: boolean;        // drives skeleton in ScriptTemplateList only
  isLoadingDetail: boolean;           // drives spinner in ScriptGeneratorPanel only

  // Form
  paramValues: Record<string, string>;   // keyed by ScriptParameter.key; booleans stored as 'true'/'false'
  formErrors: Record<string, string>;    // keyed by ScriptParameter.key

  // Output
  generatedScript: string | null;
  generationId: string | null;
  generationWarnings: string[];
  isGenerating: boolean;
  generateError: string | null;

  // Actions
  loadCategories: () => Promise<void>;
  loadTemplates: () => Promise<void>;
  selectTemplate: (id: string) => Promise<void>;
  setCategory: (id: string | null) => void;
  setSearch: (query: string) => void;
  setParamValue: (key: string, value: string) => void;
  validate: () => boolean;
  generate: (sessionId?: string) => Promise<void>;
  clearOutput: () => void;
  reset: () => void;
}

Behaviour notes

  • setCategory updates activeCategoryId, then calls loadTemplates()
  • setSearch updates searchQuery, then calls loadTemplates(). The component (not the store) debounces its call to setSearchsetSearch always calls loadTemplates() immediately on invocation
  • loadTemplates() resolves the slug from the categories array (categories.find(c => c.id === activeCategoryId)?.slug) before sending { category_slug, search } to the API. When activeCategoryId is null ("All"), category_slug is omitted from the request
  • selectTemplate(id) workflow:
    1. Sets isLoadingDetail: true (does NOT touch isLoadingTemplates)
    2. Fetches ScriptTemplateDetail from the API
    3. In a single set() call: sets selectedTemplate, populates paramValues by converting each parameter.default to string (null'', true'true', false'false', numbers → String(n)), clears formErrors, generatedScript, generationId, generationWarnings, generateError, sets isLoadingDetail: false
    4. The template list remains fully visible and interactive throughout
  • reset() clears exactly: paramValues, formErrors, generatedScript, generationId, generationWarnings, generateError. Does NOT clear selectedTemplate, categories, templates, or any browsing state. Not called by selectTemplate() — that action handles its own inline clear. Exposed as a public store action for Phase 3 callers
  • validate(): if selectedTemplate is null, returns true immediately (nothing to validate). Otherwise iterates selectedTemplate.parameters_schema.parameters, checks required && !paramValues[key] for each, writes errors to formErrors via set(), returns false if any required param is missing, true otherwise
  • generate(sessionId?): if selectedTemplate is null, returns immediately (no-op). Otherwise calls validate() first — if it returns false, stops (errors are already in store). If valid, sets isGenerating: true, clears generateError, calls POST /scripts/generate. On success: sets generatedScript/generationId/generationWarnings, sets isGenerating: false. On error: extracts error.response?.data?.detail (FastAPI detail string) or falls back to 'Failed to generate script', sets generateError, sets isGenerating: false
  • On generate success: generatedScript = response.script, generationId = response.id, generationWarnings = response.warnings

Types

Added to frontend/src/types/index.ts:

export interface ScriptCategoryResponse {
  id: string;
  name: string;
  slug: string;
  description: string | null;
  icon: string | null;
  sort_order: number;
  template_count: number;
}

export interface ScriptTemplateListItem {
  id: string;
  category_id: string;
  team_id: string | null;
  name: string;
  slug: string;
  description: string | null;
  tags: string[];
  complexity: 'beginner' | 'intermediate' | 'advanced'; // must match backend ScriptComplexity enum exactly
  estimated_runtime: string | null;
  requires_elevation: boolean;
  requires_modules: string[];
  is_verified: boolean;
  usage_count: number;
}

export interface ScriptParameterOption {
  value: string;
  label: string;
}

export interface ScriptParameterValidation {
  min_value?: number;   // matches backend field name (not 'min')
  max_value?: number;   // matches backend field name (not 'max')
  pattern?: string;
  min_length?: number;
  max_length?: number;
}

export interface ScriptParameter {
  key: string;
  label: string;
  type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea';
  required: boolean;
  placeholder: string | null;
  group: string | null;
  order: number;
  help_text: string | null;
  options: ScriptParameterOption[] | null; // for select type: [{value, label}]
  default: string | boolean | number | null;
  validation: ScriptParameterValidation | null;
  sensitive: boolean;
}

export interface ScriptParametersSchema {
  parameters: ScriptParameter[];
}

export interface ScriptTemplateDetail extends ScriptTemplateListItem {
  use_case: string | null;
  script_body: string;
  // NOTE: backend types this as `dict` — FastAPI serializes it as plain JSON.
  // At runtime this arrives as `unknown`. Access via a helper:
  //   function getParameters(detail: ScriptTemplateDetail): ScriptParameter[] {
  //     return ((detail.parameters_schema as ScriptParametersSchema)?.parameters ?? [])
  //   }
  parameters_schema: ScriptParametersSchema;
  default_values: Record<string, unknown>;
  validation_rules: Record<string, unknown>;
  version: number;
  created_at: string;
  updated_at: string;
}

export interface ScriptGenerateRequest {
  template_id: string;
  parameters: Record<string, unknown>;
  session_id?: string;
}

export interface ScriptGenerateResponse {
  id: string;        // generation UUID
  script: string;    // rendered PowerShell
  warnings: string[];
  metadata: {
    template_name: string;
    template_version: number;
    requires_elevation: boolean;
    [key: string]: unknown;
  };
}

export interface ScriptGenerationRecord {
  id: string;
  template_id: string;
  template_name: string;
  parameters_used: Record<string, unknown>; // sensitive values already redacted by backend
  created_at: string;
}

API Client

File: frontend/src/api/scripts.ts — use a named export object (matching the copilotApi/assistantChatApi pattern in api/index.ts):

// scripts.ts
export const scriptsApi = {
  getCategories(): Promise<ScriptCategoryResponse[]> { ... },
  getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise<ScriptTemplateListItem[]> { ... },
  getTemplateDetail(id: string): Promise<ScriptTemplateDetail> { ... },
  generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> { ... },
  getGenerations(): Promise<ScriptGenerationRecord[]> { ... },
}

Re-exported from frontend/src/api/index.ts as:

export { scriptsApi } from './scripts'

All methods use the existing apiClient (base URL /api/v1, auth interceptor handles token refresh).

getGenerations() and the tags param on getTemplates are Phase 3 scaffolding — included because the backend endpoints already exist and adding them later would require touching the same file. Neither is called by the Phase 2 UI. Mark both with a // Phase 3 comment in the implementation.


Component Tree

ScriptLibraryPage                    pages/ScriptLibraryPage.tsx
├── ScriptFilterBar                  components/scripts/ScriptFilterBar.tsx
├── ScriptTemplateList               components/scripts/ScriptTemplateList.tsx
│   └── TemplateCard                 components/scripts/TemplateCard.tsx
└── ScriptGeneratorPanel             components/scripts/ScriptGeneratorPanel.tsx
    ├── ScriptParameterForm          components/scripts/ScriptParameterForm.tsx
    │   └── ScriptParameterField     components/scripts/ScriptParameterField.tsx
    └── ScriptPreview                components/scripts/ScriptPreview.tsx
        └── PowerShellHighlighter    components/scripts/PowerShellHighlighter.tsx

Component responsibilities

ScriptLibraryPage

  • Bootstraps store on mount: calls loadCategories() then loadTemplates()
  • Owns inputValue state (search input text) and onClearSearch callback — lifted here so ScriptFilterBar and ScriptTemplateList can coordinate clear-search without direct coupling
  • Renders ScriptFilterBar (passing inputValue, setInputValue, onClearSearch) above the two columns
  • Renders two-column layout: ScriptTemplateList (left, passing inputValue + onClearSearch) and ScriptGeneratorPanel (right)
  • Minimal logic — only the search state lift, everything else in the store or child components

ScriptFilterBar

  • Renders an "All" tab first (calls setCategory(null), active when activeCategoryId === null), followed by one tab per category
  • Category tabs: pill style, bg-primary/10 + left 3px cyan accent bar on active tab
  • Search: local inputValue state controls the <Input> value (NOT searchQuery from store — avoids input lag during debounce). useEffect watches inputValue, schedules setTimeout(() => setSearch(inputValue), 300), clears timeout on cleanup
  • Reads categories, activeCategoryId from store; does NOT use searchQuery from store to control the input
  • Exposes a onClearSearch callback (or reads a clearSearch prop) — see ScriptTemplateList below for why. Simplest implementation: ScriptLibraryPage passes a clearSearch callback to both components; ScriptFilterBar exposes it as a function ref or the page wires setInputValue via a useRef

ScriptTemplateList

  • Scrollable list of TemplateCard components
  • Reads templates, isLoadingTemplates, selectedTemplate from store
  • Accepts inputValue: string and onClearSearch: () => void props from ScriptLibraryPage
  • Shows 3 skeleton placeholder cards while isLoadingTemplates is true
  • Shows "No templates found" empty state when templates.length === 0 and !isLoadingTemplates and inputValue === ''
  • Shows "No templates match your search" + "Clear search" button when templates.length === 0 and !isLoadingTemplates and inputValue !== ''. "Clear search" calls onClearSearch() which resets both inputValue in ScriptFilterBar and searchQuery in the store. Using inputValue (not store.searchQuery) avoids the 300ms debounce lag in empty-state detection

TemplateCard

  • Displays: name, complexity badge, usage_count, description (2-line clamp via line-clamp-2), tags
  • Shows a requires_elevation warning icon (Lucide ShieldAlert, amber-400) if true
  • Active state when template.id === selectedTemplate?.id: bg-primary/10 background + 3px left cyan gradient accent bar
  • Calls selectTemplate(template.id) on click
  • Complexity badge colors: beginner → emerald-400, intermediate → amber-400, advanced → rose-500
  • Slug is already URL/filesystem-safe by backend convention — no sanitization needed

ScriptGeneratorPanel

  • Shows placeholder (Terminal icon + "Select a template to get started") when selectedTemplate is null
  • Shows spinner overlay when isLoadingDetail is true (template list remains visible behind it)
  • When template selected and !isLoadingDetail: renders template name/description header, ScriptParameterForm, ScriptPreview, action bar
  • Action bar (visible when template selected):
    • Generate button (bg-gradient-brand) — calls generate(); shows spinner + disabled while isGenerating
    • Download .ps1 button — disabled when generatedScript is null; on click triggers new Blob([generatedScript], { type: 'text/plain' }) → programmatic anchor click with download="${selectedTemplate.slug}.ps1"
  • generateError shown as rose-500 text inline below action bar
  • generationWarnings shown as amber-400 callout above the preview when generationWarnings.length > 0
  • Permission check: call usePermissions() directly in this component; derive canGenerate = !isViewer. Pass canGenerate as a prop down to ScriptParameterForm. Generate and Download buttons disabled with tooltip "Engineer access required" when !canGenerate

ScriptParameterForm

  • Accepts canGenerate: boolean prop from ScriptGeneratorPanel
  • Iterates selectedTemplate.parameters_schema.parameters sorted by order
  • Groups by group field: renders a font-label uppercase text-muted-foreground section label before each group boundary (parameters with group: null rendered ungrouped at the top)
  • Renders a ScriptParameterField per parameter, passing canGenerate down
  • Does NOT own the Generate button and does NOT call generate() or validate() directly — validation is triggered from ScriptGeneratorPanel via the store's generate() action (which calls validate() internally)

ScriptParameterField

  • Accepts param: ScriptParameter, value: string, error: string | undefined, disabled: boolean
  • Renders input by type. Pass error prop to shared components (they render their own error message — do NOT add a separate error <p> below):
    • text<Input error={error} />
    • password<Input type="password" error={error} /> with Lucide Eye/EyeOff toggle
    • textarea<Textarea error={error} />
    • number<Input type="number" error={error} />
    • multi_text<Input placeholder="Comma-separated values" error={error} />; stored as single string, backend splits on comma
    • select → native <select className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]">; options from parameter.options; render error manually as <p className="mt-1.5 text-xs text-red-400">{error}</p> (no shared component for select)
    • boolean<input type="checkbox" className="rounded border-border" checked={value === 'true'} onChange={e => setParamValue(key, e.target.checked ? 'true' : 'false')} />; render error manually as <p className="mt-1.5 text-xs text-red-400">{error}</p>
  • Shows help_text as <p className="text-xs text-muted-foreground mt-1"> below the input (before the error)
  • Calls setParamValue(key, value) on every change event; boolean uses e.target.checked ? 'true' : 'false'

ScriptPreview

  • Two modes determined by generatedScript in store:
    • Draft mode (generatedScript === null): client-side {{key}} substitution in selectedTemplate.script_body. Sensitive params (parameter.sensitive === true) rendered as **** regardless of paramValues value. Unfilled non-sensitive params left as {{key}} for PowerShellHighlighter to color amber
    • Generated mode (generatedScript !== null): shows generatedScript
  • Copy icon (Lucide Copy, 14px) in top-right corner of code block, visible in both modes:
    • On click: copies the currently displayed script string (preview in draft mode, generated script in generated mode) via navigator.clipboard.writeText(). On success: shows "Copied!" for 2s then resets. On failure: silently fails — no error displayed, icon does not change state
  • Passes current script string to PowerShellHighlighter

PowerShellHighlighter

  • Pure component: ({ script: string }) => JSX.Element

  • Single-pass tokenizer using a combined alternation regex (not sequential replace). The regex alternation matches tokens in priority order; each match is replaced with a <span> and the remaining string continues from after the match. This prevents a variable inside a string literal from being re-colored by the variable rule:

    Priority order in alternation:
    1. Comments: /#[^\r\n]*/
    2. String literals: /"[^"]*"|'[^']*'/
    3. Unfilled placeholders: /\{\{[^}]+\}\}/
    4. Variables: /\$\w+/
    5. Cmdlets: /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
    6. Parameters: /-[A-Za-z]+/
    7. Keywords: /\b(if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/
    

    Token colors:

    • Comments → text-[#8b949e]
    • String literals → text-[#a5d6ff]
    • Unfilled placeholders → text-amber-400 underline decoration-dashed
    • Variables → text-[#79c0ff]
    • Cmdlets → text-[#22d3ee]
    • Parameters → text-[#d2a8ff]
    • Keywords → text-[#ff7b72]
    • Unmatched text → unstyled
  • Renders as <pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"><code>


Routing & Navigation

  • Route: /scripts added to frontend/src/router.tsx inside the ProtectedRoute/AppLayout children
  • Sidebar nav entry: "Scripts" with a Terminal icon (Lucide), added to AppLayout.tsx in the main nav group
  • No sub-routes needed for Phase 2

Permissions

Action Minimum role
View Script Library page Any authenticated user
Browse templates, see draft preview Any authenticated user
Fill form, generate, copy, download Engineer, owner, or super_admin (i.e. !isViewer)

usePermissions() is called once in ScriptGeneratorPanel. Derive canGenerate = isEngineer (which returns true for engineer, owner, and super_admin via the role hierarchy — matching "Engineer or above" intent more explicitly than !isViewer). Pass canGenerate as a prop down to ScriptParameterForm. Leaf components (ScriptParameterField) receive disabled as a prop — they do not call usePermissions() directly.

Viewer-blocking is frontend-only in Phase 2 (backend role guard deferred — known limitation, documented in Architecture section).

Tooltip implementation: Use the HTML title attribute on the wrapper <span> around disabled buttons — consistent with the existing codebase pattern (e.g. TopBar.tsx, NavItem.tsx, CheckoutButton.tsx). Example: <span title="Engineer access required"><button disabled ...>Generate</button></span>.


Search Behaviour

  • Search query sent as ?search= to GET /scripts/templates; matches name, description, slug (not tags)
  • 300ms debounce in ScriptFilterBar: local inputValue state, useEffect + setTimeout/clearTimeout
  • setSearch is called once after the debounce delay; it immediately calls loadTemplates()
  • Category filter and search compose: loadTemplates() sends both category_slug and search simultaneously
  • inputValue is lifted to ScriptLibraryPage (as state) and passed as a prop to both ScriptFilterBar (controls the input) and ScriptTemplateList (drives empty-state variant). onClearSearch callback (also from ScriptLibraryPage) resets inputValue to '' and calls setSearch('')

Empty & Loading States

Scenario Treatment
Templates loading 3 skeleton TemplateCard placeholders (isLoadingTemplates)
No templates in category Empty state icon + "No templates found"
No search results "No templates match your search" + "Clear search" button
Detail fetch in progress Spinner in ScriptGeneratorPanel (isLoadingDetail); template list stays visible
No template selected Right panel: Terminal icon + "Select a template to get started"
Generating Generate button spinner + disabled (isGenerating)
Generate error Rose-500 inline text below action bar
Warnings after generate Amber-400 callout above preview, one line per warning

Out of Scope (Phase 2)

  • Session-embedded script generation (Script Output Node) — Phase 3
  • Tag filter UI — deferred (backend capability exists via ?tags=)
  • Backend engineer role guard on generate endpoint — deferred (known gap)
  • Template creation/editing UI — admin-only, deferred
  • Generation history page — deferred
  • Admin template management — deferred
  • Script execution / RMM integration — long-term roadmap