` or `Text as="h*"`:
+
+- `Title` — renders `` with `role="heading"` and `aria-level`
+- Automatically sets `size="md"` + `weight="semibold"` for level 1, `size="sm"` for others
+- Supports `tone`, `size`, `weight`, `font` overrides
+
+**Use `Paragraph` for paragraph text** instead of raw `
` or `Text as="p"`:
+
+- `` instead of ``
+
+**Use `Text` for inline text** (``, ``, ``, etc.):
+
+- `` instead of ``
+- Supports: `as`, `size`, `weight`, `tone`, `font` props
+
+## Buttons
+
+Use `Button` from `@/components/ui/button`
+
+## Icons
+
+Use `Icon` from `@/components/ui/icon`
+
+## Styling
+
+- Use shadcn/ui components from `@/components/ui/` for all UI primitives
+- Use TailwindCSS v4 for styling (not CSS modules or styled-components)
+- **Only use inline styling** (`style={...}`) for dynamic/variable CSS values (e.g., `style={{height: h}}`). Never use inline styles for static values — use Tailwind classes instead
+- Use `cn()` utility for conditional classes (from `@/lib/utils`)
+- Prefer composition over prop drilling for complex components
+
+## Suggest Abstractions for Repeated Patterns
+
+When you see similar Tailwind class combinations used multiple times, suggest creating reusable components or utility classes:
+
+- Multiple buttons with similar styling -> Create a Button variant or new component
+- Repeated container/card patterns -> Abstract into reusable Card component
+- Common spacing/layout patterns -> Suggest utility classes or component abstractions
+- Similar form field styling -> Create form field components
+
+## When Raw HTML is Acceptable
+
+- Semantic elements not supported by primitives (e.g., ``, ``, ``, ``)
+- Complex layouts where primitives don't fit
+- Performance-critical sections where abstraction overhead matters
diff --git a/.claude/skills/validate/SKILL.md b/.claude/skills/validate/SKILL.md
new file mode 100644
index 000000000..a5b0570fb
--- /dev/null
+++ b/.claude/skills/validate/SKILL.md
@@ -0,0 +1,29 @@
+---
+name: validate
+description: Run validation and testing commands for the project. Use when the user asks to validate, lint, typecheck, or run tests.
+disable-model-invocation: true
+allowed-tools: Bash(npm run *)
+argument-hint: [test|e2e]
+---
+
+# Validate & Test
+
+Run project validation and testing commands.
+
+## Available Commands
+
+- **`npm run validate`** - Runs format, lint:fix, typecheck, and knip (use before committing)
+- **`npm run validate:test`** - Runs validate + unit tests (full validation)
+- **`npm run test:e2e`** - Runs Playwright E2E tests
+
+## Usage
+
+- `/validate` — Run `npm run validate`
+- `/validate test` — Run `npm run validate:test`
+- `/validate e2e` — Run `npm run test:e2e`
+
+## Local Development Ports
+
+- **Frontend**: `localhost:3000` (default for `npm start`)
+- **Backend API**: `localhost:8000`
+- **API Docs**: `localhost:8000/docs`
diff --git a/.claude/skills/vitest-testing/SKILL.md b/.claude/skills/vitest-testing/SKILL.md
new file mode 100644
index 000000000..0b2f9959b
--- /dev/null
+++ b/.claude/skills/vitest-testing/SKILL.md
@@ -0,0 +1,239 @@
+---
+name: vitest-testing
+description: Vitest unit and component testing patterns. Use when writing unit tests, component tests, or hook tests.
+---
+
+# Vitest Testing Patterns
+
+**Focus tests on the component/hook under test.** Assume that dependencies (services, hooks, utilities) are independently tested in their own test files. Only mock what's necessary to isolate the unit under test — don't re-test dependency behavior or create elaborate mock setups for services that aren't the focus of the test.
+
+## Setup
+
+- **Framework**: Vitest with jsdom environment
+- **Globals**: `describe`, `it`, `expect` are globally available (no imports needed)
+- **DOM matchers**: `@testing-library/jest-dom` is configured in `vitest-setup.js`
+- **Component rendering**: `@testing-library/react` with `render`, `screen`, `fireEvent`, `waitFor`
+- **No MSW**: Mock functions directly with `vi.mock()` and `vi.fn()`, not HTTP interception
+
+## Test File Location
+
+Tests are **co-located** next to source files:
+
+```
+src/utils/searchUtils.ts
+src/utils/searchUtils.test.ts
+
+src/hooks/useIOSelectionPersistence.ts
+src/hooks/useIOSelectionPersistence.test.ts
+
+src/components/shared/SuspenseWrapper.tsx
+src/components/shared/SuspenseWrapper.test.tsx
+```
+
+## Utility / Pure Function Tests
+
+```typescript
+describe("formatDuration", () => {
+ it("formats seconds correctly", () => {
+ const start = "2024-01-01T10:00:00.000Z";
+ const end = "2024-01-01T10:00:30.000Z";
+ expect(formatDuration(start, end)).toBe("30s");
+ });
+});
+```
+
+## Component Tests
+
+Render with providers using a wrapper function:
+
+```typescript
+const renderWithProviders = (component: React.ReactElement) => {
+ return render(component, {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ });
+};
+
+it("renders the toolbar", async () => {
+ renderWithProviders();
+ await waitFor(() => {
+ expect(screen.getByTestId("inspect-pipeline-button")).toBeInTheDocument();
+ });
+});
+```
+
+## Hook Tests
+
+Use `renderHook` with a wrapper for hooks that need providers:
+
+```typescript
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+it("should hydrate component", async () => {
+ vi.mocked(hydrateComponentReference).mockResolvedValue(mockHydratedRef);
+
+ const { result } = renderHook(
+ () => useHydrateComponentReference(mockComponent),
+ { wrapper: createWrapper() },
+ );
+
+ await waitFor(() => {
+ expect(result.current).toEqual(mockHydratedRef);
+ });
+});
+```
+
+Use `act` for state updates in hooks:
+
+```typescript
+act(() => {
+ result.current.preserveIOSelectionOnSpecChange(initialSpec);
+});
+expect(mockSetNodes).toHaveBeenCalledWith(expect.any(Function));
+```
+
+## Mocking Patterns
+
+### Module mocks with `vi.mock()`
+
+```typescript
+vi.mock("@/utils/localforage", () => ({
+ componentExistsByUrl: vi.fn(),
+ getComponentByUrl: vi.fn(),
+ saveComponent: vi.fn(),
+}));
+
+// React component mock
+vi.mock("@monaco-editor/react", () => ({
+ default: ({ defaultValue }: { defaultValue: string }) => (
+ {defaultValue}
+ ),
+}));
+
+// Provider/context mock
+vi.mock("@/providers/ComponentSpecProvider", () => ({
+ useComponentSpec: () => ({
+ componentSpec: mockSpec,
+ setComponentSpec: mockSetComponentSpec,
+ }),
+}));
+```
+
+### Function spies
+
+```typescript
+const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+// ... test code
+expect(consoleSpy).toHaveBeenCalledWith("Error:", expect.any(Error));
+```
+
+### Global mocks
+
+```typescript
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(yamlContent),
+} as Response);
+```
+
+### Environment variables
+
+```typescript
+vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id");
+```
+
+## Mock Factories
+
+Create inline helper functions for test data — don't over-abstract:
+
+```typescript
+const createMockNode = (
+ id: string,
+ type: "input" | "output" | "task",
+ label: string,
+ selected = false,
+) => ({
+ id,
+ type,
+ position: { x: 0, y: 0 },
+ data: { label },
+ selected,
+});
+```
+
+## Setup / Teardown
+
+```typescript
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ cleanup(); // Testing Library cleanup
+ queryClient.clear(); // Clear query cache if using QueryClient
+ vi.restoreAllMocks();
+});
+```
+
+## Assertion Patterns
+
+```typescript
+// DOM assertions
+expect(screen.getByTestId("submit")).toBeInTheDocument();
+expect(screen.queryByTestId("hidden")).not.toBeInTheDocument();
+
+// Mock assertions
+expect(mockFn).toHaveBeenCalledWith(url);
+expect(mockFn).toHaveBeenCalledTimes(1);
+expect(mockFn).not.toHaveBeenCalled();
+
+// Partial matching
+expect(mockSave).toHaveBeenCalledWith({
+ id: expect.stringMatching(/^component-\w+$/),
+ createdAt: expect.any(Number),
+});
+```
+
+## User Interactions
+
+Prefer `fireEvent` for simple interactions in this project:
+
+```typescript
+const input = screen.getByLabelText("Name") as HTMLInputElement;
+fireEvent.change(input, { target: { value: "NewName" } });
+fireEvent.blur(input);
+expect(mockCallback).toHaveBeenCalled();
+```
+
+## Async Testing
+
+```typescript
+// Wait for state updates
+await waitFor(() => {
+ expect(screen.getByTestId("content")).toBeInTheDocument();
+});
+
+// Assert on promises
+await expect(promise).resolves.toBe(expectedValue);
+```
+
+## npm Scripts
+
+- `npm test` — Run all unit tests once
+- `npm run test:coverage` — Run with coverage report
+- `npm run validate:test` — Full validate + unit tests