docs: update Phase 2 spec after review loop — fix field names, permissions, search coordination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-13 01:26:24 -04:00
parent e875d73c0b
commit 9a37caf596

View File

@@ -9,17 +9,21 @@
## Goal
Build the Script Library frontend: a three-panel 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.
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 (C). Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.
**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.
**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 (`{{variable_name}}` replacement only). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters (`as_secure_string`, `as_array`, `as_bool`), conditionals, and PowerShell-safe sanitization.
**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.
---
@@ -36,16 +40,18 @@ interface ScriptGeneratorState {
templates: ScriptTemplateListItem[];
selectedTemplate: ScriptTemplateDetail | null;
searchQuery: string;
activeCategoryId: string | null;
isLoadingTemplates: boolean;
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 variable_name
formErrors: Record<string, string>; // keyed by variable_name
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;
@@ -55,7 +61,8 @@ interface ScriptGeneratorState {
selectTemplate: (id: string) => Promise<void>;
setCategory: (id: string | null) => void;
setSearch: (query: string) => void;
setParamValue: (variableName: string, value: string) => void;
setParamValue: (key: string, value: string) => void;
validate: () => boolean;
generate: (sessionId?: string) => Promise<void>;
clearOutput: () => void;
reset: () => void;
@@ -64,10 +71,18 @@ interface ScriptGeneratorState {
### Behaviour notes
- `setCategory` and `setSearch` both call `loadTemplates()` after updating state — they compose (category + search both applied to the API call)
- `selectTemplate` fetches the full `ScriptTemplateDetail` (including `parameters_schema` and `script_template`), then calls `reset()` to clear previous form/output state
- `reset()` clears `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generateError` — does not clear template selection or browsing state
- `generate()` validates required params client-side first (populates `formErrors`), then calls `POST /scripts/generate`
- `setCategory` updates `activeCategoryId`, then calls `loadTemplates()`
- `setSearch` updates `searchQuery`, then calls `loadTemplates()`. The component (not the store) debounces its call to `setSearch``setSearch` 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`
---
@@ -81,29 +96,53 @@ export interface ScriptCategoryResponse {
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;
script_complexity: 'simple' | 'moderate' | 'complex';
usage_count: number;
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 {
variable_name: string;
key: string;
label: string;
field_type: 'text' | 'password' | 'select' | 'multiselect' | 'checkbox' | 'number';
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea';
required: boolean;
default_value?: string;
options?: string[];
is_sensitive: boolean;
display_order: number;
help_text?: string;
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 {
@@ -111,20 +150,45 @@ export interface ScriptParametersSchema {
}
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;
script_template: string;
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, string>;
parameters: Record<string, unknown>;
session_id?: string;
}
export interface ScriptGenerateResponse {
generation_id: string;
generated_script: string;
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;
}
```
@@ -132,25 +196,34 @@ export interface ScriptGenerateResponse {
## API Client
**File:** `frontend/src/api/scripts.ts`
**File:** `frontend/src/api/scripts.ts` — use a named export object (matching the `copilotApi`/`assistantChatApi` pattern in `api/index.ts`):
```typescript
getCategories(): Promise<ScriptCategoryResponse[]>
getTemplates(params?: { category_id?: string; search?: string }): Promise<ScriptTemplateListItem[]>
getTemplateDetail(id: string): Promise<ScriptTemplateDetail>
generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse>
getGenerations(): Promise<ScriptGenerationRecord[]>
// 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:
```typescript
export { scriptsApi } from './scripts'
```
All methods use the existing `apiClient` (base URL `/api/v1`, auth interceptor handles token refresh).
Exported from `frontend/src/api/index.ts` as `scriptsApi`.
> `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
```
```text
ScriptLibraryPage pages/ScriptLibraryPage.tsx
├── ScriptFilterBar components/scripts/ScriptFilterBar.tsx
├── ScriptTemplateList components/scripts/ScriptTemplateList.tsx
@@ -165,75 +238,116 @@ ScriptLibraryPage pages/ScriptLibraryPage.tsx
### Component responsibilities
**`ScriptLibraryPage`**
- Bootstraps store on mount: calls `loadCategories()` then `loadTemplates()`
- Renders two-column layout (template list left, generator panel right)
- Renders `ScriptFilterBar` above the two columns
- No business logic — pure layout + bootstrap
- 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`**
- Category tabs (pill style, `bg-primary/10` + cyan accent on active)
- Search input (debounced 300ms) — searches names, descriptions, and tags (backend handles it)
- Reads `categories`, `activeCategoryId`, `searchQuery` from store
- Calls `setCategory` and `setSearch`
- 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
- Shows skeleton loaders while loading
- Shows empty state when no templates match
- 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, description (truncated to 2 lines), complexity badge, usage count, tags
- Active state: `bg-primary/10` background + 3px left cyan accent bar (matches existing nav active pattern)
- 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: simple → emerald-400, moderate → amber-400, complex → rose-500
- 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 ("Select a template to get started") when `selectedTemplate` is null
- When template selected: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, and action bar
- Action bar: Generate button (`bg-gradient-brand`) + Download `.ps1` button
- Download triggers `new Blob([generatedScript], { type: 'text/plain' })` → anchor click
- Shows `generateError` inline below Generate button
- Viewers see Generate button disabled with tooltip "Engineer access required" (checked via `usePermissions()`)
- 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`**
- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `display_order`
- Renders a `ScriptParameterField` per parameter
- Client-side required validation on Generate (marks `formErrors` in store)
- Disabled entirely for viewers
- 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`**
- Renders input by `field_type`: text → `<Input>`, password → `<Input type="password">`, select → `<select>`, checkbox → `<input type="checkbox">`, number → `<Input type="number">`
- Shows `help_text` as a small muted line below the field
- Shows `formErrors[variable_name]` as an inline error
- Password fields show a show/hide toggle
- Calls `setParamValue(variable_name, value)` on change
- 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:
- **Draft mode** (before Generate): client-side substitution of `{{variable_name}}` in `selectedTemplate.script_template` using current `paramValues`. Unfilled params render as `{{variable_name}}` placeholders (visually dimmed).
- **Generated mode** (after Generate): shows `generatedScript` from store
- Copy icon (Lucide `Copy`) in top-right corner of code block — calls `navigator.clipboard.writeText()`; shows a brief "Copied!" tooltip on success
- Passes script string to `PowerShellHighlighter`
- 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`
- Regex-based syntax highlighting (no external library):
- Comments (`#...`) → `text-[#8b949e]`
- Cmdlets (`Verb-Noun` pattern) → `text-[#22d3ee]` (cyan)
- String literals (`"..."`, `'...'`) → `text-[#a5d6ff]`
- Variables (`$VarName`) → `text-[#79c0ff]`
- Parameters (`-ParamName`) → `text-[#d2a8ff]` (purple)
- Keywords (`if`, `foreach`, `function`, etc.) → `text-[#ff7b72]`
- Unfilled placeholders (`{{variable_name}}`) → `text-amber-400` with dashed underline
- Renders as `<pre><code>` with `font-label` (JetBrains Mono), `bg-card`, rounded corners
- 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:
```text
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), grouped under the main nav
- 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
---
@@ -241,40 +355,49 @@ ScriptLibraryPage pages/ScriptLibraryPage.tsx
## Permissions
| Action | Minimum role |
|--------|-------------|
| ------ | ------------ |
| View Script Library page | Any authenticated user |
| Browse templates, see preview | Any authenticated user |
| Fill form, generate, copy, download | Engineer or above |
| Browse templates, see draft preview | Any authenticated user |
| Fill form, generate, copy, download | Engineer, owner, or super_admin (i.e. `!isViewer`) |
Implemented via `usePermissions()` hook. Viewer-blocking applied to: `ScriptParameterForm` (disabled), Generate button (disabled + tooltip), Download button (disabled + tooltip).
`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=` param to `GET /scripts/templates`
- Backend searches name, description, and tags (already implemented in Phase 1)
- Debounced 300ms in `ScriptFilterBar` before calling `setSearch`
- Category filter and search compose: both params sent simultaneously
- 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 | Skeleton cards (3 placeholder TemplateCards) |
| No templates in category | Illustration + "No templates found" message |
| No search results | "No templates match your search" with clear button |
| No template selected | Right panel: centered placeholder with Terminal icon + "Select a template to get started" |
| Generating | Generate button shows spinner, disabled |
| Generate error | Inline error text below Generate button in rose-500 |
| -------- | --------- |
| 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