diff --git a/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md b/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
new file mode 100644
index 00000000..df04162b
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
@@ -0,0 +1,2650 @@
+# Empty States, Onboarding & Professional Exports — 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:** Add illustrative empty states, onboarding checklist, team branding, PDF exports, and supporting data capture to make ResolutionFlow feel polished and professional.
+
+**Architecture:** Bottom-up — backend foundation (migrations, endpoints, PDF generation) first, then frontend empty states + guides, then onboarding checklist, then PDF/supporting data UI. Four PRs, each independently shippable.
+
+**Tech Stack:** FastAPI, SQLAlchemy, Alembic, WeasyPrint, Jinja2, React 19, TypeScript, Tailwind CSS, Vitest, Playwright
+
+**Spec:** `docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md`
+
+---
+
+## Chunk 1: PR 1 — Backend Foundation
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Modify | `backend/app/models/user.py` | Add `onboarding_dismissed`, branding columns |
+| Modify | `backend/app/models/team.py` | Add branding columns |
+| Create | `backend/app/models/supporting_data.py` | SessionSupportingData model |
+| Modify | `backend/app/models/__init__.py` | Import new model |
+| Create | `backend/alembic/versions/*_add_onboarding_and_branding.py` | Migration: user + team columns |
+| Create | `backend/alembic/versions/*_add_supporting_data_table.py` | Migration: supporting data table |
+| Create | `backend/app/schemas/onboarding.py` | Onboarding status response schema |
+| Create | `backend/app/schemas/branding.py` | Branding request/response schemas |
+| Create | `backend/app/schemas/supporting_data.py` | Supporting data CRUD schemas |
+| Modify | `backend/app/schemas/session.py:109` | Add `pdf` to format pattern |
+| Create | `backend/app/api/endpoints/onboarding.py` | Onboarding status + dismiss endpoints |
+| Create | `backend/app/api/endpoints/branding.py` | Team branding CRUD endpoints |
+| Create | `backend/app/api/endpoints/supporting_data.py` | Supporting data CRUD endpoints |
+| Modify | `backend/app/api/router.py` | Register new endpoint routers |
+| Modify | `backend/app/services/export_service.py` | Add `generate_pdf_export()` function + supporting data in all formats |
+| Modify | `backend/app/api/endpoints/sessions.py:371-440` | Add PDF format branch |
+| Create | `backend/app/templates/export_pdf.html` | Jinja2 PDF template |
+| Modify | `backend/requirements.txt` | Add `weasyprint`, `jinja2` |
+| Modify | `backend/Dockerfile` | Add WeasyPrint system deps |
+| Create | `backend/tests/test_onboarding.py` | Onboarding endpoint tests |
+| Create | `backend/tests/test_branding.py` | Branding endpoint tests |
+| Create | `backend/tests/test_supporting_data.py` | Supporting data endpoint tests |
+| Create | `backend/tests/test_pdf_export.py` | PDF export tests |
+
+---
+
+### Task 0: Create Feature Branch
+
+- [ ] **Step 1: Create feature branch before any commits**
+
+```bash
+git checkout -b feat/backend-foundation-empty-states-exports
+```
+
+Per CLAUDE.md: "Always create feature branch BEFORE committing."
+
+---
+
+### Task 1: Database Migrations — User & Team Branding Columns
+
+**Files:**
+- Modify: `backend/app/models/user.py:75` (after `avatar_url`)
+- Modify: `backend/app/models/team.py:25` (after `name`)
+- Create: migration file
+
+**Note:** All model code uses `Mapped[]`/`mapped_column()` syntax to match the existing codebase pattern. Do NOT use legacy `Column()` style.
+
+- [ ] **Step 1: Add columns to User model**
+
+In `backend/app/models/user.py`, after `avatar_url` (line 75), add:
+
+```python
+ # Onboarding
+ onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
+
+ # Branding (solo pros without a team)
+ logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
+ company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+```
+
+Ensure `Optional` is imported from `typing` and `mapped_column`, `Mapped` from `sqlalchemy.orm` (should already be imported in the file).
+
+- [ ] **Step 2: Add columns to Team model**
+
+In `backend/app/models/team.py`, after `name` (line 25), add:
+
+```python
+ # Branding
+ logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
+ company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+```
+
+- [ ] **Step 3: Generate migration**
+
+Run: `cd backend && alembic revision --autogenerate -m "add onboarding and branding columns"`
+
+- [ ] **Step 4: Review and apply migration**
+
+Review the generated migration file, then run:
+`cd backend && alembic upgrade head`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/models/user.py backend/app/models/team.py backend/alembic/versions/
+git commit -m "feat: add onboarding_dismissed and branding columns to user and team models"
+```
+
+---
+
+### Task 2: SessionSupportingData Model & Migration
+
+**Files:**
+- Create: `backend/app/models/supporting_data.py`
+- Modify: `backend/app/models/__init__.py`
+
+- [ ] **Step 1: Create the model**
+
+Create `backend/app/models/supporting_data.py` using `Mapped[]`/`mapped_column()` to match existing model patterns:
+
+```python
+import uuid
+from datetime import datetime, timezone
+from typing import Optional
+
+from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class SessionSupportingData(Base):
+ __tablename__ = "session_supporting_data"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+ session_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("sessions.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ label: Mapped[str] = mapped_column(String(255), nullable=False)
+ data_type: Mapped[str] = mapped_column(
+ Enum("text_snippet", "screenshot", name="supporting_data_type"),
+ nullable=False,
+ )
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ nullable=False,
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ nullable=False,
+ )
+
+ session = relationship("Session", back_populates="supporting_data")
+```
+
+- [ ] **Step 2: Add relationship to Session model**
+
+In `backend/app/models/session.py`, add to the relationships section:
+
+```python
+ supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order")
+```
+
+- [ ] **Step 3: Register in models __init__.py**
+
+In `backend/app/models/__init__.py`, add the import:
+
+```python
+from app.models.supporting_data import SessionSupportingData
+```
+
+- [ ] **Step 4: Generate and apply migration**
+
+```bash
+cd backend && alembic revision --autogenerate -m "add session_supporting_data table"
+cd backend && alembic upgrade head
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/models/supporting_data.py backend/app/models/__init__.py backend/app/models/session.py backend/alembic/versions/
+git commit -m "feat: add session_supporting_data model and migration"
+```
+
+---
+
+### Task 3: Pydantic Schemas
+
+**Files:**
+- Create: `backend/app/schemas/onboarding.py`
+- Create: `backend/app/schemas/branding.py`
+- Create: `backend/app/schemas/supporting_data.py`
+- Modify: `backend/app/schemas/session.py:109`
+
+- [ ] **Step 1: Create onboarding schema**
+
+Create `backend/app/schemas/onboarding.py`:
+
+```python
+from pydantic import BaseModel
+
+
+class OnboardingStatus(BaseModel):
+ created_flow: bool
+ ran_session: bool
+ exported_session: bool
+ tried_ai_assistant: bool
+ invited_teammate: bool
+ connected_psa: bool
+ is_team_user: bool
+ dismissed: bool
+```
+
+- [ ] **Step 2: Create branding schemas**
+
+Create `backend/app/schemas/branding.py`:
+
+```python
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+
+class BrandingResponse(BaseModel):
+ company_display_name: Optional[str] = None
+ logo_content_type: Optional[str] = None
+ has_logo: bool = False
+
+
+class BrandingLogoResponse(BaseModel):
+ company_display_name: Optional[str] = None
+ logo_data: Optional[str] = None
+ logo_content_type: Optional[str] = None
+```
+
+- [ ] **Step 3: Create supporting data schemas**
+
+Create `backend/app/schemas/supporting_data.py`:
+
+```python
+from datetime import datetime
+from typing import Literal, Optional
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+
+class SupportingDataCreate(BaseModel):
+ label: str = Field(..., min_length=1, max_length=255)
+ data_type: Literal["text_snippet", "screenshot"]
+ content: str = Field(..., min_length=1, max_length=5_000_000) # ~2MB base64 for screenshots, 50K chars for text validated in endpoint
+ content_type: Optional[str] = Field(None, max_length=50)
+
+
+class SupportingDataUpdate(BaseModel):
+ label: Optional[str] = Field(None, min_length=1, max_length=255)
+ content: Optional[str] = Field(None, min_length=1)
+
+
+class SupportingDataResponse(BaseModel):
+ id: UUID
+ session_id: UUID
+ label: str
+ data_type: str
+ content: str
+ content_type: Optional[str]
+ sort_order: int
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
+```
+
+- [ ] **Step 4: Update SessionExport format pattern**
+
+In `backend/app/schemas/session.py`, line 109, change:
+
+```python
+ format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
+```
+
+to:
+
+```python
+ format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$")
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/schemas/onboarding.py backend/app/schemas/branding.py backend/app/schemas/supporting_data.py backend/app/schemas/session.py
+git commit -m "feat: add onboarding, branding, and supporting data schemas"
+```
+
+---
+
+### Task 4: Onboarding Endpoints
+
+**Files:**
+- Create: `backend/app/api/endpoints/onboarding.py`
+- Modify: `backend/app/api/router.py`
+
+**Important:** The existing `conftest.py` only provides `client`, `test_user`, `auth_headers`, and `test_tree` fixtures. Tests that need team admins, engineers, teams, or sessions must create them inline. Follow the pattern in existing test files — register a user via the API, create a team, etc. within each test or via a local fixture in the test file.
+
+- [ ] **Step 1: Write the onboarding status test**
+
+Create `backend/tests/test_onboarding.py`:
+
+```python
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_onboarding_status_fresh_user(client: AsyncClient, auth_headers: dict):
+ """Fresh user should have all items false."""
+ response = await client.get("/api/v1/users/onboarding-status", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["created_flow"] is False
+ assert data["ran_session"] is False
+ assert data["exported_session"] is False
+ assert data["tried_ai_assistant"] is False
+ assert data["dismissed"] is False
+
+
+@pytest.mark.asyncio
+async def test_onboarding_dismiss(client: AsyncClient, auth_headers: dict):
+ """Dismiss endpoint should set dismissed to true."""
+ response = await client.post("/api/v1/users/onboarding-status/dismiss", headers=auth_headers)
+ assert response.status_code == 200
+
+ response = await client.get("/api/v1/users/onboarding-status", headers=auth_headers)
+ assert response.json()["dismissed"] is True
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && pytest tests/test_onboarding.py -v --override-ini="addopts="`
+Expected: FAIL — endpoint does not exist yet.
+
+- [ ] **Step 3: Create the onboarding endpoint**
+
+Create `backend/app/api/endpoints/onboarding.py`:
+
+```python
+from typing import Annotated
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user
+from app.core.database import get_db
+from app.models.session import Session
+from app.models.tree import Tree
+from app.models.user import User
+from app.schemas.onboarding import OnboardingStatus
+
+router = APIRouter(prefix="/users", tags=["onboarding"])
+
+
+@router.get("/onboarding-status", response_model=OnboardingStatus)
+async def get_onboarding_status(
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> OnboardingStatus:
+ user_id = current_user.id
+ team_id = current_user.team_id
+
+ # Check created_flow
+ flow_count = await db.scalar(
+ select(func.count()).select_from(Tree).where(Tree.created_by == user_id)
+ )
+
+ # Check ran_session
+ session_count = await db.scalar(
+ select(func.count()).select_from(Session).where(Session.user_id == user_id)
+ )
+
+ # Check exported_session
+ exported_count = await db.scalar(
+ select(func.count())
+ .select_from(Session)
+ .where(Session.user_id == user_id, Session.exported == True)
+ )
+
+ # Check tried_ai_assistant
+ from app.models.assistant_chat import AssistantChat
+ ai_count = await db.scalar(
+ select(func.count()).select_from(AssistantChat).where(AssistantChat.user_id == user_id)
+ )
+
+ # Check team-specific items
+ is_team_user = team_id is not None
+ invited_teammate = False
+ connected_psa = False
+
+ if is_team_user:
+ team_member_count = await db.scalar(
+ select(func.count()).select_from(User).where(User.team_id == team_id)
+ )
+ invited_teammate = (team_member_count or 0) > 1
+
+ from app.models.psa_connection import PsaConnection
+ psa_count = await db.scalar(
+ select(func.count())
+ .select_from(PsaConnection)
+ .where(PsaConnection.team_id == team_id)
+ )
+ connected_psa = (psa_count or 0) > 0
+
+ return OnboardingStatus(
+ created_flow=(flow_count or 0) > 0,
+ ran_session=(session_count or 0) > 0,
+ exported_session=(exported_count or 0) > 0,
+ tried_ai_assistant=(ai_count or 0) > 0,
+ invited_teammate=invited_teammate,
+ connected_psa=connected_psa,
+ is_team_user=is_team_user,
+ dismissed=current_user.onboarding_dismissed,
+ )
+
+
+@router.post("/onboarding-status/dismiss")
+async def dismiss_onboarding(
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+):
+ current_user.onboarding_dismissed = True
+ db.add(current_user)
+ await db.commit()
+ return {"status": "dismissed"}
+```
+
+- [ ] **Step 4: Register in router**
+
+In `backend/app/api/router.py`, add import and include:
+
+```python
+from app.api.endpoints import onboarding
+# ...
+api_router.include_router(onboarding.router)
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cd backend && pytest tests/test_onboarding.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/onboarding.py backend/app/api/router.py backend/tests/test_onboarding.py
+git commit -m "feat: add onboarding status and dismiss endpoints with tests"
+```
+
+---
+
+### Task 5: Branding Endpoints
+
+**Files:**
+- Create: `backend/app/api/endpoints/branding.py`
+- Create: `backend/tests/test_branding.py`
+- Modify: `backend/app/api/router.py`
+
+- [ ] **Step 1: Write branding tests**
+
+Create `backend/tests/test_branding.py`. **Note:** `team_admin_headers`, `engineer_headers`, and `test_team_id` don't exist in conftest.py. Each test must create a team and users inline via the API (register user, create team, log in to get headers). Follow existing test patterns in the codebase — read other test files first to see how they set up users/teams.
+
+The tests must cover:
+- Get branding with no logo returns defaults (has_logo=False)
+- Upload a valid 1x1 PNG logo with company name — verify has_logo=True
+- Upload oversized file (>2MB) — returns 400
+- Upload invalid content type (application/pdf) — returns 400
+- Delete logo — clears it
+- Non-admin cannot update branding — returns 403
+- Non-team-member cannot read branding — returns 403
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_branding.py -v --override-ini="addopts="`
+Expected: FAIL — endpoints don't exist.
+
+- [ ] **Step 3: Create branding endpoint**
+
+Create `backend/app/api/endpoints/branding.py`:
+
+```python
+import base64
+from typing import Annotated, Optional
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user
+from app.core.database import get_db
+from app.models.team import Team
+from app.models.user import User
+from app.schemas.branding import BrandingResponse
+
+router = APIRouter(prefix="/teams", tags=["branding"])
+
+ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/svg+xml"}
+MAX_LOGO_SIZE = 2 * 1024 * 1024 # 2MB
+
+
+async def _get_team_or_404(db: AsyncSession, team_id: UUID) -> Team:
+ team = await db.get(Team, team_id)
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found")
+ return team
+
+
+def _require_team_admin(user: User, team_id: UUID) -> None:
+ if user.is_super_admin:
+ return
+ if not user.is_team_admin or user.team_id != team_id:
+ raise HTTPException(status_code=403, detail="Team admin required")
+
+
+def _require_team_member(user: User, team_id: UUID) -> None:
+ if user.is_super_admin:
+ return
+ if user.team_id != team_id:
+ raise HTTPException(status_code=403, detail="Not a member of this team")
+
+
+@router.get("/{team_id}/branding", response_model=BrandingResponse)
+async def get_branding(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> BrandingResponse:
+ _require_team_member(current_user, team_id)
+ team = await _get_team_or_404(db, team_id)
+ return BrandingResponse(
+ company_display_name=team.company_display_name,
+ logo_content_type=team.logo_content_type,
+ has_logo=team.logo_data is not None,
+ )
+
+
+@router.patch("/{team_id}/branding", response_model=BrandingResponse)
+async def update_branding(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+ logo: Annotated[Optional[UploadFile], File()] = None,
+ company_display_name: Annotated[Optional[str], Form()] = None,
+) -> BrandingResponse:
+ _require_team_admin(current_user, team_id)
+ team = await _get_team_or_404(db, team_id)
+
+ if logo is not None:
+ if logo.content_type not in ALLOWED_CONTENT_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid content type. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}",
+ )
+ logo_bytes = await logo.read()
+ if len(logo_bytes) > MAX_LOGO_SIZE:
+ raise HTTPException(status_code=400, detail="Logo must be under 2MB")
+ team.logo_data = base64.b64encode(logo_bytes).decode("utf-8")
+ team.logo_content_type = logo.content_type
+
+ if company_display_name is not None:
+ team.company_display_name = company_display_name
+
+ db.add(team)
+ await db.commit()
+ await db.refresh(team)
+
+ return BrandingResponse(
+ company_display_name=team.company_display_name,
+ logo_content_type=team.logo_content_type,
+ has_logo=team.logo_data is not None,
+ )
+
+
+@router.delete("/{team_id}/branding/logo")
+async def delete_logo(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+):
+ _require_team_admin(current_user, team_id)
+ team = await _get_team_or_404(db, team_id)
+ team.logo_data = None
+ team.logo_content_type = None
+ db.add(team)
+ await db.commit()
+ return {"status": "logo_deleted"}
+```
+
+- [ ] **Step 4: Register in router**
+
+In `backend/app/api/router.py`, add:
+
+```python
+from app.api.endpoints import branding
+api_router.include_router(branding.router)
+```
+
+- [ ] **Step 5: Run tests**
+
+Run: `cd backend && pytest tests/test_branding.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/branding.py backend/tests/test_branding.py backend/app/api/router.py
+git commit -m "feat: add team branding CRUD endpoints with tests"
+```
+
+---
+
+### Task 6: Supporting Data Endpoints
+
+**Files:**
+- Create: `backend/app/api/endpoints/supporting_data.py`
+- Create: `backend/tests/test_supporting_data.py`
+- Modify: `backend/app/api/router.py`
+
+- [ ] **Step 1: Write supporting data tests**
+
+Create `backend/tests/test_supporting_data.py`:
+
+```python
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Create a text snippet supporting data item."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={
+ "label": "Ping Output",
+ "data_type": "text_snippet",
+ "content": "PING 8.8.8.8: 64 bytes from 8.8.8.8: icmp_seq=1 ttl=117",
+ },
+ )
+ assert response.status_code == 201
+ data = response.json()
+ assert data["label"] == "Ping Output"
+ assert data["data_type"] == "text_snippet"
+
+
+@pytest.mark.asyncio
+async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Create a screenshot supporting data item."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={
+ "label": "Error Dialog",
+ "data_type": "screenshot",
+ "content": "iVBORw0KGgoAAAANSUhEUg==",
+ "content_type": "image/png",
+ },
+ )
+ assert response.status_code == 201
+ assert response.json()["data_type"] == "screenshot"
+
+
+@pytest.mark.asyncio
+async def test_list_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """List returns items in sort order."""
+ response = await client.get(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert isinstance(response.json(), list)
+
+
+@pytest.mark.asyncio
+async def test_delete_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Delete removes item."""
+ # Create an item first
+ create_resp = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={"label": "To Delete", "data_type": "text_snippet", "content": "temp"},
+ )
+ item_id = create_resp.json()["id"]
+
+ response = await client.delete(
+ f"/api/v1/sessions/{test_session_id}/supporting-data/{item_id}",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_exceed_max_items(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Reject when exceeding 20 items per session."""
+ for i in range(20):
+ await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={"label": f"Item {i}", "data_type": "text_snippet", "content": "data"},
+ )
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={"label": "Item 21", "data_type": "text_snippet", "content": "data"},
+ )
+ assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_exceed_screenshot_size(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Reject screenshots over 2MB."""
+ import base64
+ big_content = base64.b64encode(b"x" * (2 * 1024 * 1024 + 1)).decode()
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={
+ "label": "Big Screenshot",
+ "data_type": "screenshot",
+ "content": big_content,
+ "content_type": "image/png",
+ },
+ )
+ assert response.status_code == 400
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_supporting_data.py -v --override-ini="addopts="`
+Expected: FAIL
+
+- [ ] **Step 3: Create supporting data endpoint**
+
+Create `backend/app/api/endpoints/supporting_data.py`:
+
+```python
+import base64
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user
+from app.core.database import get_db
+from app.models.session import Session
+from app.models.supporting_data import SessionSupportingData
+from app.models.user import User
+from app.schemas.supporting_data import (
+ SupportingDataCreate,
+ SupportingDataResponse,
+ SupportingDataUpdate,
+)
+
+router = APIRouter(prefix="/sessions", tags=["supporting-data"])
+
+MAX_ITEMS_PER_SESSION = 20
+MAX_SCREENSHOT_SIZE = 2 * 1024 * 1024 # 2MB raw (before base64)
+
+
+async def _get_session_or_404(db: AsyncSession, session_id: UUID) -> Session:
+ session = await db.get(Session, session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+ return session
+
+
+async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None:
+ if user.is_super_admin:
+ return
+ if session.user_id == user.id:
+ return
+ # Team admins can only access sessions from their own team members
+ if user.is_team_admin and user.team_id is not None:
+ session_owner = await db.get(User, session.user_id)
+ if session_owner and session_owner.team_id == user.team_id:
+ return
+ raise HTTPException(status_code=403, detail="Access denied")
+
+
+@router.post(
+ "/{session_id}/supporting-data",
+ response_model=SupportingDataResponse,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_supporting_data(
+ session_id: UUID,
+ data: SupportingDataCreate,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> SupportingDataResponse:
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ # Check item limit
+ count = await db.scalar(
+ select(func.count())
+ .select_from(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session_id)
+ )
+ if (count or 0) >= MAX_ITEMS_PER_SESSION:
+ raise HTTPException(status_code=400, detail=f"Maximum {MAX_ITEMS_PER_SESSION} items per session")
+
+ # Check text snippet length
+ if data.data_type == "text_snippet" and len(data.content) > 50_000:
+ raise HTTPException(status_code=400, detail="Text snippet must be under 50,000 characters")
+
+ # Check screenshot size (base64 decode to get raw size)
+ if data.data_type == "screenshot":
+ try:
+ raw_bytes = base64.b64decode(data.content)
+ if len(raw_bytes) > MAX_SCREENSHOT_SIZE:
+ raise HTTPException(status_code=400, detail="Screenshot must be under 2MB")
+ except Exception as e:
+ if isinstance(e, HTTPException):
+ raise
+ raise HTTPException(status_code=400, detail="Invalid base64 content")
+
+ # Get next sort_order
+ max_order = await db.scalar(
+ select(func.max(SessionSupportingData.sort_order))
+ .where(SessionSupportingData.session_id == session_id)
+ )
+ next_order = (max_order or 0) + 1
+
+ item = SessionSupportingData(
+ session_id=session_id,
+ label=data.label,
+ data_type=data.data_type,
+ content=data.content,
+ content_type=data.content_type,
+ sort_order=next_order,
+ )
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+ return SupportingDataResponse.model_validate(item)
+
+
+@router.get("/{session_id}/supporting-data", response_model=list[SupportingDataResponse])
+async def list_supporting_data(
+ session_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> list[SupportingDataResponse]:
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ result = await db.execute(
+ select(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session_id)
+ .order_by(SessionSupportingData.sort_order)
+ )
+ items = result.scalars().all()
+ return [SupportingDataResponse.model_validate(item) for item in items]
+
+
+@router.patch("/{session_id}/supporting-data/{item_id}", response_model=SupportingDataResponse)
+async def update_supporting_data(
+ session_id: UUID,
+ item_id: UUID,
+ data: SupportingDataUpdate,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> SupportingDataResponse:
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ item = await db.get(SessionSupportingData, item_id)
+ if not item or item.session_id != session_id:
+ raise HTTPException(status_code=404, detail="Supporting data item not found")
+
+ if data.label is not None:
+ item.label = data.label
+ if data.content is not None:
+ item.content = data.content
+
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+ return SupportingDataResponse.model_validate(item)
+
+
+@router.delete("/{session_id}/supporting-data/{item_id}")
+async def delete_supporting_data(
+ session_id: UUID,
+ item_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+):
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ item = await db.get(SessionSupportingData, item_id)
+ if not item or item.session_id != session_id:
+ raise HTTPException(status_code=404, detail="Supporting data item not found")
+
+ await db.delete(item)
+ await db.commit()
+ return {"status": "deleted"}
+```
+
+- [ ] **Step 4: Register in router**
+
+In `backend/app/api/router.py`, add:
+
+```python
+from app.api.endpoints import supporting_data
+api_router.include_router(supporting_data.router)
+```
+
+- [ ] **Step 5: Run tests**
+
+Run: `cd backend && pytest tests/test_supporting_data.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/supporting_data.py backend/tests/test_supporting_data.py backend/app/api/router.py
+git commit -m "feat: add supporting data CRUD endpoints with tests"
+```
+
+---
+
+### Task 7: PDF Export — WeasyPrint Setup & Template
+
+**Files:**
+- Modify: `backend/requirements.txt`
+- Modify: `backend/Dockerfile`
+- Create: `backend/app/templates/export_pdf.html`
+
+- [ ] **Step 1: Add WeasyPrint to requirements**
+
+Add to `backend/requirements.txt`:
+
+```
+weasyprint>=62.0
+jinja2>=3.1.0
+```
+
+- [ ] **Step 2: Install locally**
+
+```bash
+cd backend && pip install weasyprint jinja2
+```
+
+Note: WeasyPrint requires system libraries. On Ubuntu/Debian:
+```bash
+sudo apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev
+```
+
+- [ ] **Step 3: Update Dockerfile**
+
+In `backend/Dockerfile`, update the apt-get line to include WeasyPrint deps:
+
+Change:
+```dockerfile
+RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
+```
+
+To:
+```dockerfile
+RUN apt-get update && apt-get install -y gcc libpq-dev libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev && rm -rf /var/lib/apt/lists/*
+```
+
+- [ ] **Step 4: Create PDF HTML template**
+
+Create `backend/app/templates/export_pdf.html`:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% if summary %}
+Summary
+{{ summary }}
+{% endif %}
+
+
+{% if steps %}
+Troubleshooting Path
+
+ {% for step in steps %}
+
+
+
{{ loop.index }}. {{ step.title }}
+ {% if step.decision %}
+
Decision: {{ step.decision }}
+ {% endif %}
+
+ {% endfor %}
+
+{% endif %}
+
+
+{% if supporting_data %}
+
+
Supporting Data
+ {% for item in supporting_data %}
+
+
{{ item.label }}
+ {% if item.data_type == "text_snippet" %}
+
{{ item.content }}
+ {% elif item.data_type == "screenshot" %}
+
+ {% endif %}
+
+ {% endfor %}
+
+{% endif %}
+
+
+
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/requirements.txt backend/Dockerfile backend/app/templates/
+git commit -m "feat: add WeasyPrint dependency, Dockerfile system deps, and PDF template"
+```
+
+---
+
+### Task 8: PDF Generation in Export Service + Endpoint
+
+**Files:**
+- Modify: `backend/app/services/export_service.py`
+- Modify: `backend/app/api/endpoints/sessions.py:371-440`
+- Create: `backend/tests/test_pdf_export.py`
+
+- [ ] **Step 1: Write PDF export tests**
+
+Create `backend/tests/test_pdf_export.py`:
+
+```python
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_export_pdf_format(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Export as PDF returns application/pdf content type."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/export",
+ headers=auth_headers,
+ json={"format": "pdf"},
+ )
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/pdf"
+ assert response.content[:4] == b"%PDF"
+
+
+@pytest.mark.asyncio
+async def test_export_pdf_no_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """PDF export works when session has no supporting data."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/export",
+ headers=auth_headers,
+ json={"format": "pdf"},
+ )
+ assert response.status_code == 200
+ assert response.content[:4] == b"%PDF"
+
+
+@pytest.mark.asyncio
+async def test_export_markdown_still_works(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Existing markdown export still works after PDF addition."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/export",
+ headers=auth_headers,
+ json={"format": "markdown"},
+ )
+ assert response.status_code == 200
+ assert "text/plain" in response.headers["content-type"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="`
+Expected: FAIL
+
+- [ ] **Step 3: Add generate_pdf_export function to export_service.py**
+
+**Important:** The export service uses standalone module-level functions (NOT a class). Add this as a standalone `async def` matching the pattern of `generate_markdown_export()`, `generate_text_export()`, etc.
+
+Add the following function to `backend/app/services/export_service.py`:
+
+```python
+async def generate_pdf_export(
+ session: "Session",
+ options: "SessionExport",
+ db: "AsyncSession",
+ ) -> bytes:
+ """Generate a branded PDF export using WeasyPrint."""
+ import weasyprint
+ from jinja2 import Environment, FileSystemLoader
+ from pathlib import Path
+ from datetime import datetime, timezone
+
+ from app.models.supporting_data import SessionSupportingData
+ from sqlalchemy import select
+
+ # Load template
+ template_dir = Path(__file__).parent.parent / "templates"
+ env = Environment(loader=FileSystemLoader(str(template_dir)))
+ template = env.get_template("export_pdf.html")
+
+ # Get tree snapshot data
+ tree_snapshot = session.tree_snapshot or {}
+ flow_title = tree_snapshot.get("title", "Untitled Flow")
+ tree_type = tree_snapshot.get("tree_type", "troubleshooting")
+
+ report_type_map = {
+ "troubleshooting": "Troubleshooting Report",
+ "procedural": "Project Report",
+ "maintenance": "Maintenance Report",
+ }
+ report_type = report_type_map.get(tree_type, "Session Report")
+
+ # Get branding
+ logo_data = None
+ logo_content_type = None
+ company_name = None
+ has_custom_logo = False
+
+ user = session.user
+ if user and user.team_id:
+ from app.models.team import Team
+ team = await db.get(Team, user.team_id)
+ if team:
+ if team.logo_data:
+ logo_data = team.logo_data
+ logo_content_type = team.logo_content_type
+ has_custom_logo = True
+ company_name = team.company_display_name or team.name
+ elif user:
+ if user.logo_data:
+ logo_data = user.logo_data
+ logo_content_type = user.logo_content_type
+ has_custom_logo = True
+ company_name = user.company_display_name
+
+ # Build steps from decisions
+ steps = []
+ for decision in (session.decisions or []):
+ steps.append({
+ "title": decision.get("title") or decision.get("question") or decision.get("description", "Step"),
+ "decision": decision.get("selected_option") or decision.get("answer", ""),
+ })
+
+ # Get supporting data
+ result = await db.execute(
+ select(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session.id)
+ .order_by(SessionSupportingData.sort_order)
+ )
+ supporting_data_items = result.scalars().all()
+
+ # Calculate duration
+ duration = "—"
+ if session.started_at and session.completed_at:
+ delta = session.completed_at - session.started_at
+ minutes = int(delta.total_seconds() / 60)
+ if minutes < 60:
+ duration = f"{minutes} min"
+ else:
+ hours = minutes // 60
+ remaining = minutes % 60
+ duration = f"{hours}h {remaining}m"
+
+ # Outcome display
+ outcome = session.outcome or "In Progress"
+ outcome_class = "resolved" if outcome == "resolved" else "unresolved" if outcome == "unresolved" else "escalated"
+ outcome_display = f"✓ {outcome.title()}" if outcome == "resolved" else outcome.title()
+
+ # Session date
+ session_date = ""
+ if session.started_at:
+ session_date = session.started_at.strftime("%B %d, %Y")
+
+ # Summary
+ summary = session.outcome_notes or ""
+
+ # Engineer name
+ engineer_name = user.name if user else "Unknown"
+
+ # Generated timestamp
+ generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC")
+
+ # Render HTML
+ html_content = template.render(
+ report_type=report_type,
+ flow_title=flow_title,
+ logo_data=logo_data,
+ logo_content_type=logo_content_type,
+ has_custom_logo=has_custom_logo,
+ company_name=company_name,
+ engineer_name=engineer_name,
+ client_name=session.client_name,
+ ticket_number=session.ticket_number,
+ session_date=session_date,
+ duration=duration,
+ outcome_class=outcome_class,
+ outcome_display=outcome_display,
+ summary=summary,
+ steps=steps,
+ supporting_data=supporting_data_items,
+ generated_at=generated_at,
+ )
+
+ # Generate PDF
+ pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
+ return pdf_bytes
+```
+
+- [ ] **Step 4: Update export endpoint for PDF format**
+
+In `backend/app/api/endpoints/sessions.py`, in the `export_session` function, add the PDF branch. After the format dispatch block (around line 395-406), add:
+
+```python
+ if export_options.format == "pdf":
+ from app.services.export_service import generate_pdf_export
+
+ pdf_bytes = await generate_pdf_export(session, export_options, db)
+
+ # Mark as exported if completed (same logic as other formats)
+ if session.completed_at and not session.exported:
+ session.exported = True
+ db.add(session)
+ await db.commit()
+
+ from fastapi.responses import Response
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'
+ },
+ )
+```
+
+Make sure this block runs before the existing format dispatch so it returns early for PDF.
+
+**Note:** Variable resolution and redaction are handled inside `generate_pdf_export()` before rendering — the function must call `resolve_variables()` on text content and `apply_redaction_to_text()` if `redaction_mode != "none"`, matching the pattern used by other export formats in the existing code.
+
+- [ ] **Step 5: Run tests**
+
+Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Run full test suite to verify no regressions**
+
+Run: `cd backend && pytest --override-ini="addopts="`
+Expected: All existing tests still pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add backend/app/services/export_service.py backend/app/api/endpoints/sessions.py backend/tests/test_pdf_export.py
+git commit -m "feat: add PDF export generation via WeasyPrint with branded template"
+```
+
+---
+
+### Task 9: Supporting Data in Non-PDF Export Formats
+
+**Files:**
+- Modify: `backend/app/services/export_service.py`
+
+The spec requires supporting data to be included in ALL export formats, not just PDF.
+
+- [ ] **Step 1: Add supporting data to generate_markdown_export**
+
+After the existing decisions/steps section, add a "## Supporting Data" section that renders each item:
+- Text snippets: labeled fenced code blocks
+- Screenshots: `[Screenshot: {label}]` placeholder (base64 images don't work in plain markdown)
+
+- [ ] **Step 2: Add supporting data to generate_text_export**
+
+After the steps section, add a "SUPPORTING DATA" section:
+- Text snippets: labeled indented blocks
+- Screenshots: `[Screenshot: {label}]` placeholder
+
+- [ ] **Step 3: Add supporting data to generate_html_export**
+
+After the steps section, add a "Supporting Data" section:
+- Text snippets: `` blocks with labels
+- Screenshots: ` ` tags with base64 src
+
+- [ ] **Step 4: Add supporting data to generate_psa_export**
+
+After the steps section, add a "Supporting Data" section:
+- Text snippets: labeled code blocks (markdown format for CW notes)
+- Screenshots: `[Screenshot: {label}]` placeholder
+
+**Note:** All four functions need to accept `db: AsyncSession` as a parameter (or the supporting data items as a pre-fetched list) to load the session's supporting data. Read the existing function signatures and follow the established pattern.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/export_service.py
+git commit -m "feat: include supporting data in all export formats"
+```
+
+---
+
+### Task 10: PR 1 Final — Integration Test Run & PR
+
+- [ ] **Step 1: Run full backend test suite**
+
+```bash
+cd backend && pytest --override-ini="addopts=" -v
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 2: Push feature branch and create PR**
+
+```bash
+git push -u origin feat/backend-foundation-empty-states-exports
+```
+
+Create PR with title: "feat: backend foundation for empty states, onboarding, and exports"
+
+---
+
+## Chunk 2: PR 2 — Empty States + Guides
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Modify | `frontend/src/components/common/EmptyState.tsx` | Add illustration, learnMoreLink props |
+| Create | `frontend/src/components/common/EmptyStateIllustrations.tsx` | SVG illustrations for each page |
+| Modify | `frontend/src/pages/TreeLibraryPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/MyAnalyticsPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/TeamAnalyticsPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/SessionHistoryPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/StepLibraryPage.tsx` | Add empty state |
+| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` | Add empty state |
+| Modify | `frontend/src/pages/MySharesPage.tsx` | Upgraded empty state |
+| Modify | relevant integrations page | Add empty state |
+| Create | `frontend/src/pages/guides/GuidePage.tsx` | Guide route wrapper |
+| Create | `frontend/src/pages/guides/CreatingFlowsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/UnderstandingAnalyticsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/RunningSessionsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/PsaSetupGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/StepLibraryGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/ScriptTemplatesGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/SharingSessionsGuide.tsx` | Guide content |
+| Modify | `frontend/src/router.tsx` | Add `/guides/:slug` route |
+
+---
+
+### Task 11: Upgrade EmptyState Component
+
+**Files:**
+- Modify: `frontend/src/components/common/EmptyState.tsx`
+- Create: `frontend/src/components/common/EmptyStateIllustrations.tsx`
+
+- [ ] **Step 1: Update EmptyState component**
+
+Rewrite `frontend/src/components/common/EmptyState.tsx` to support the new illustrative style:
+
+```tsx
+import { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { cn } from '@/lib/utils'
+
+interface EmptyStateProps {
+ icon?: ReactNode
+ illustration?: ReactNode
+ title: string
+ description?: string
+ action?: ReactNode
+ learnMoreLink?: string
+ learnMoreText?: string
+ className?: string
+}
+
+export function EmptyState({
+ icon,
+ illustration,
+ title,
+ description,
+ action,
+ learnMoreLink,
+ learnMoreText = 'Learn more',
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {illustration && (
+
+ {illustration}
+
+ )}
+ {!illustration && icon && (
+
{icon}
+ )}
+
{title}
+ {description && (
+
{description}
+ )}
+ {action &&
{action}
}
+ {learnMoreLink && (
+
+ {learnMoreText} →
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 2: Create illustrations file**
+
+Create `frontend/src/components/common/EmptyStateIllustrations.tsx` with SVG illustrations for each page. Each illustration is a simple 80x60 SVG using brand colors:
+
+```tsx
+export function FlowIllustration() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function AnalyticsIllustration() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export function SessionIllustration() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function IntegrationIllustration() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function StepLibraryIllustration() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ScriptIllustration() {
+ return (
+
+
+
+
+
+
+ >
+
+ )
+}
+
+export function ShareIllustration() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/common/EmptyState.tsx frontend/src/components/common/EmptyStateIllustrations.tsx
+git commit -m "feat: upgrade EmptyState component with illustration and learn more support"
+```
+
+---
+
+### Task 12: Roll Out Empty States Across Pages
+
+**Files:** 8 page files (see file structure above)
+
+- [ ] **Step 1: Update each page's empty state**
+
+For each page, update the empty state usage to include the new props (illustration, description, CTA, learnMoreLink). The specific edits depend on how each page currently renders its empty state — read each file and update the `` usage.
+
+**Pattern for each page:**
+
+```tsx
+import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
+
+// In the render:
+ }
+ title="Build your first troubleshooting flow"
+ description="Flows guide your team through proven resolution paths, capturing every decision along the way."
+ action={Create a Flow }
+ learnMoreLink="/guides/creating-flows"
+/>
+```
+
+Apply the correct illustration, title, description, CTA, and guide link from the spec table for each of the 8 pages.
+
+- [ ] **Step 2: Add empty states to pages that don't have them**
+
+For StepLibraryPage, ScriptLibraryPage, and the integrations page — add the `EmptyState` component where no data exists. Read each file first to understand the current rendering logic.
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds with no TypeScript errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/pages/
+git commit -m "feat: roll out illustrative empty states across 8 pages"
+```
+
+---
+
+### Task 13: Create Guide Pages & Route
+
+**Files:**
+- Create: 7 guide component files in `frontend/src/pages/guides/`
+- Create: `frontend/src/pages/guides/GuidePage.tsx`
+- Modify: `frontend/src/router.tsx`
+
+- [ ] **Step 1: Create guide page wrapper**
+
+Create `frontend/src/pages/guides/GuidePage.tsx`:
+
+```tsx
+import { useParams, Link } from 'react-router-dom'
+import { ChevronRight } from 'lucide-react'
+import { EmptyState } from '@/components/common/EmptyState'
+import CreatingFlowsGuide from './CreatingFlowsGuide'
+import UnderstandingAnalyticsGuide from './UnderstandingAnalyticsGuide'
+import RunningSessionsGuide from './RunningSessionsGuide'
+import PsaSetupGuide from './PsaSetupGuide'
+import StepLibraryGuide from './StepLibraryGuide'
+import ScriptTemplatesGuide from './ScriptTemplatesGuide'
+import SharingSessionsGuide from './SharingSessionsGuide'
+
+const guides: Record = {
+ 'creating-flows': { title: 'Creating Flows', component: CreatingFlowsGuide },
+ 'understanding-analytics': { title: 'Understanding Analytics', component: UnderstandingAnalyticsGuide },
+ 'running-sessions': { title: 'Running Sessions', component: RunningSessionsGuide },
+ 'psa-setup': { title: 'Connecting Your PSA', component: PsaSetupGuide },
+ 'step-library': { title: 'Using the Step Library', component: StepLibraryGuide },
+ 'script-templates': { title: 'Script Templates', component: ScriptTemplatesGuide },
+ 'sharing-sessions': { title: 'Sharing Sessions', component: SharingSessionsGuide },
+}
+
+export default function GuidePage() {
+ const { slug } = useParams<{ slug: string }>()
+ const guide = slug ? guides[slug] : undefined
+
+ if (!guide) {
+ return (
+
+ Back to Dashboard
+
+ }
+ />
+ )
+ }
+
+ const GuideContent = guide.component
+
+ return (
+
+
+ Home
+
+ {guide.title}
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Create each guide component**
+
+Create 7 guide files in `frontend/src/pages/guides/`. Each follows the same pattern — a functional component with heading, paragraphs, and a CTA link. Example for `CreatingFlowsGuide.tsx`:
+
+```tsx
+import { Link } from 'react-router-dom'
+
+export default function CreatingFlowsGuide() {
+ return (
+
+ Creating Flows
+
+ Flows are the core of ResolutionFlow — structured troubleshooting paths that guide your team
+ through proven resolution steps.
+
+
+ Flow Types
+
+ Troubleshooting — Decision trees that branch based on what the engineer finds at each step.
+ Projects — Step-by-step procedural guides for installations, migrations, and setups.
+ Maintenance — Recurring check sequences you can schedule and run in batches.
+
+
+ Creating a Flow Manually
+
+ Click "Create a Flow" from the Flow Library, choose your flow type, and start building
+ in the visual editor. Add decision nodes, connect paths, and define outcomes.
+
+
+ Using AI to Generate Flows
+
+ Describe your troubleshooting scenario in plain language and the AI assistant will generate
+ a complete flow structure. You can then refine it in the editor.
+
+
+
+
+ Go to Flow Library →
+
+
+
+ )
+}
+```
+
+Create the remaining 6 guides following the same pattern, tailored to their topic per the spec table. Keep each 300-600 words.
+
+- [ ] **Step 3: Add route to router.tsx**
+
+In `frontend/src/router.tsx`, add the guide route inside the protected children:
+
+```tsx
+{ path: 'guides/:slug', element: page(GuidePage) }
+```
+
+Add the lazy import at the top:
+```tsx
+const GuidePage = lazy(() => import('./pages/guides/GuidePage'))
+```
+
+- [ ] **Step 4: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/pages/guides/ frontend/src/router.tsx
+git commit -m "feat: add 7 in-app user guides with /guides/:slug route"
+```
+
+---
+
+### Task 14: EmptyState Vitest Tests
+
+**Files:**
+- Create: `frontend/src/components/common/__tests__/EmptyState.test.tsx`
+
+- [ ] **Step 1: Write tests**
+
+Create `frontend/src/components/common/__tests__/EmptyState.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { EmptyState } from '../EmptyState'
+import { FlowIllustration } from '../EmptyStateIllustrations'
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('EmptyState', () => {
+ it('renders title and description', () => {
+ render(
+ ,
+ { wrapper }
+ )
+ expect(screen.getByText('No data')).toBeInTheDocument()
+ expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
+ })
+
+ it('renders illustration when provided', () => {
+ render(
+ } />,
+ { wrapper }
+ )
+ expect(document.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('renders action button', () => {
+ render(
+ Do Thing} />,
+ { wrapper }
+ )
+ expect(screen.getByText('Do Thing')).toBeInTheDocument()
+ })
+
+ it('renders learn more link', () => {
+ render(
+ ,
+ { wrapper }
+ )
+ const link = screen.getByText('Learn more →')
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/guides/test')
+ })
+
+ it('renders without optional props', () => {
+ render( , { wrapper })
+ expect(screen.getByText('Just a title')).toBeInTheDocument()
+ expect(screen.queryByText('Learn more →')).not.toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 2: Run tests**
+
+Run: `cd frontend && npx vitest run src/components/common/__tests__/EmptyState.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/src/components/common/__tests__/EmptyState.test.tsx
+git commit -m "test: add EmptyState component Vitest tests"
+```
+
+---
+
+### Task 15: PR 2 — Build Verification & PR
+
+- [ ] **Step 1: Full frontend build**
+
+```bash
+cd frontend && npm run build
+```
+
+Expected: Clean build, no errors.
+
+- [ ] **Step 2: Create feature branch and PR**
+
+```bash
+git checkout -b feat/empty-states-and-guides
+git push -u origin feat/empty-states-and-guides
+```
+
+Create PR: "feat: illustrative empty states across 8 pages with in-app guides"
+
+---
+
+## Chunk 3: PR 3 — Onboarding Checklist
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Create | `frontend/src/components/dashboard/OnboardingChecklist.tsx` | Checklist widget component |
+| Create | `frontend/src/api/onboarding.ts` | API client for onboarding endpoints |
+| Modify | `frontend/src/pages/QuickStartPage.tsx` | Insert checklist widget |
+
+---
+
+### Task 16: Onboarding API Client
+
+**Files:**
+- Create: `frontend/src/api/onboarding.ts`
+
+- [ ] **Step 1: Create API client**
+
+Create `frontend/src/api/onboarding.ts`:
+
+```typescript
+import { apiClient } from './client'
+
+export interface OnboardingStatus {
+ created_flow: boolean
+ ran_session: boolean
+ exported_session: boolean
+ tried_ai_assistant: boolean
+ invited_teammate: boolean
+ connected_psa: boolean
+ is_team_user: boolean
+ dismissed: boolean
+}
+
+export async function getOnboardingStatus(): Promise {
+ const response = await apiClient.get('/users/onboarding-status')
+ return response.data
+}
+
+export async function dismissOnboarding(): Promise {
+ await apiClient.post('/users/onboarding-status/dismiss')
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add frontend/src/api/onboarding.ts
+git commit -m "feat: add onboarding status API client"
+```
+
+---
+
+### Task 17: OnboardingChecklist Component
+
+**Files:**
+- Create: `frontend/src/components/dashboard/OnboardingChecklist.tsx`
+
+- [ ] **Step 1: Create the component**
+
+Create `frontend/src/components/dashboard/OnboardingChecklist.tsx`:
+
+```tsx
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Check, X, ChevronRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { getOnboardingStatus, dismissOnboarding, OnboardingStatus } from '@/api/onboarding'
+
+interface ChecklistItem {
+ key: keyof OnboardingStatus
+ label: string
+ path: string
+}
+
+const SOLO_ITEMS: ChecklistItem[] = [
+ { key: 'created_flow', label: 'Create your first flow', path: '/trees' },
+ { key: 'ran_session', label: 'Run your first session', path: '/trees' },
+ { key: 'exported_session', label: 'Export a session', path: '/sessions' },
+ { key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' },
+]
+
+const TEAM_ITEMS: ChecklistItem[] = [
+ { key: 'created_flow', label: 'Create your first flow', path: '/trees' },
+ { key: 'invited_teammate', label: 'Invite a team member', path: '/account' },
+ { key: 'ran_session', label: 'Run your first session', path: '/trees' },
+ { key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' },
+ { key: 'exported_session', label: 'Export a session', path: '/sessions' },
+]
+
+export function OnboardingChecklist() {
+ const [status, setStatus] = useState(null)
+ const [dismissed, setDismissed] = useState(false)
+ const [allComplete, setAllComplete] = useState(false)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ getOnboardingStatus()
+ .then(setStatus)
+ .catch(() => {})
+ }, [])
+
+ if (!status || status.dismissed || dismissed) return null
+
+ const items = status.is_team_user ? TEAM_ITEMS : SOLO_ITEMS
+ const completedCount = items.filter((item) => status[item.key]).length
+ const totalCount = items.length
+ const isAllDone = completedCount === totalCount
+
+ // Show "all set" briefly then auto-hide after 2 seconds
+ useEffect(() => {
+ if (isAllDone) {
+ const timer = setTimeout(() => setAllComplete(true), 2000)
+ return () => clearTimeout(timer)
+ }
+ }, [isAllDone])
+
+ if (allComplete) return null
+
+ const handleDismiss = async () => {
+ setDismissed(true)
+ await dismissOnboarding().catch(() => {})
+ }
+
+ return (
+
+ {/* Progress bar */}
+
+
+
+
+
+ Getting Started
+
+
+ {isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`}
+
+
+
+
+
+
+
+
+ {items.map((item) => {
+ const isComplete = status[item.key]
+ return (
+
!isComplete && navigate(item.path)}
+ className={cn(
+ 'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors',
+ isComplete
+ ? 'text-muted-foreground cursor-default'
+ : 'text-foreground hover:bg-[rgba(255,255,255,0.04)]'
+ )}
+ >
+
+ {isComplete && }
+
+ {item.label}
+ {!isComplete && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Add to QuickStartPage**
+
+In `frontend/src/pages/QuickStartPage.tsx`, import and insert the checklist after the greeting section (after line ~279, before the calendar/stats section):
+
+```tsx
+import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'
+
+// In the render, after the greeting div:
+
+```
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/dashboard/OnboardingChecklist.tsx frontend/src/pages/QuickStartPage.tsx
+git commit -m "feat: add onboarding checklist widget to dashboard"
+```
+
+---
+
+### Task 18: PR 3 — Build & PR
+
+- [ ] **Step 1: Full frontend build**
+
+```bash
+cd frontend && npm run build
+```
+
+- [ ] **Step 2: Create branch and PR**
+
+```bash
+git checkout -b feat/onboarding-checklist
+git push -u origin feat/onboarding-checklist
+```
+
+Create PR: "feat: onboarding starter checklist on QuickStartPage"
+
+---
+
+## Chunk 4: PR 4 — PDF Export UI + Supporting Data UI + Branding Settings
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Create | `frontend/src/api/supportingData.ts` | Supporting data API client |
+| Create | `frontend/src/api/branding.ts` | Branding API client |
+| Create | `frontend/src/components/session/SupportingDataPanel.tsx` | Supporting data list + add modal |
+| Create | `frontend/src/components/session/AddSupportingDataModal.tsx` | Add text snippet / screenshot modal |
+| Modify | `frontend/src/components/session/ExportPreviewModal.tsx` | PDF format + download-only mode |
+| Modify | `frontend/src/pages/SessionDetailPage.tsx` | Add PDF format option |
+| Create | `frontend/src/components/settings/BrandingSettings.tsx` | Logo upload + company name form |
+| Modify | relevant team settings page | Add branding section |
+| Modify | session runner pages | Add supporting data button |
+
+---
+
+### Task 19: Supporting Data & Branding API Clients
+
+**Files:**
+- Create: `frontend/src/api/supportingData.ts`
+- Create: `frontend/src/api/branding.ts`
+
+- [ ] **Step 1: Create supporting data API client**
+
+Create `frontend/src/api/supportingData.ts`:
+
+```typescript
+import { apiClient } from './client'
+
+export interface SupportingDataItem {
+ id: string
+ session_id: string
+ label: string
+ data_type: 'text_snippet' | 'screenshot'
+ content: string
+ content_type: string | null
+ sort_order: number
+ created_at: string
+ updated_at: string
+}
+
+export async function getSupportingData(sessionId: string): Promise {
+ const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`)
+ return response.data
+}
+
+export async function createSupportingData(
+ sessionId: string,
+ data: { label: string; data_type: string; content: string; content_type?: string }
+): Promise {
+ const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data)
+ return response.data
+}
+
+export async function updateSupportingData(
+ sessionId: string,
+ itemId: string,
+ data: { label?: string; content?: string }
+): Promise {
+ const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
+ return response.data
+}
+
+export async function deleteSupportingData(sessionId: string, itemId: string): Promise {
+ await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`)
+}
+```
+
+- [ ] **Step 2: Create branding API client**
+
+Create `frontend/src/api/branding.ts`:
+
+```typescript
+import { apiClient } from './client'
+
+export interface BrandingInfo {
+ company_display_name: string | null
+ logo_content_type: string | null
+ has_logo: boolean
+}
+
+export async function getBranding(teamId: string): Promise {
+ const response = await apiClient.get(`/teams/${teamId}/branding`)
+ return response.data
+}
+
+export async function updateBranding(
+ teamId: string,
+ formData: FormData
+): Promise {
+ const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+ return response.data
+}
+
+export async function deleteLogo(teamId: string): Promise {
+ await apiClient.delete(`/teams/${teamId}/branding/logo`)
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/src/api/supportingData.ts frontend/src/api/branding.ts
+git commit -m "feat: add supporting data and branding API clients"
+```
+
+---
+
+### Task 20: Supporting Data UI Components
+
+**Files:**
+- Create: `frontend/src/components/session/AddSupportingDataModal.tsx`
+- Create: `frontend/src/components/session/SupportingDataPanel.tsx`
+
+- [ ] **Step 1: Create the add modal**
+
+Create `frontend/src/components/session/AddSupportingDataModal.tsx` with:
+- Two tabs: "Text Snippet" and "Screenshot"
+- Text tab: label input + multiline textarea
+- Screenshot tab: label input + drag-and-drop zone + file picker + paste support
+- Submit button that calls `createSupportingData`
+- 2MB file size validation for screenshots
+- Uses glass-card modal styling per design system
+
+- [ ] **Step 2: Create the panel**
+
+Create `frontend/src/components/session/SupportingDataPanel.tsx` with:
+- Collapsible section showing supporting data items
+- Each item: type icon (Code2 for text, Image for screenshot) + label + preview + delete button
+- "Add Supporting Data" button that opens the modal
+- Fetches items via `getSupportingData` on mount
+- Uses the design system's glass card and muted foreground styles
+
+- [ ] **Step 3: Integrate into session runner pages**
+
+Read the troubleshooting session runner (`TreeNavigationPage`) and procedural session runner (`ProceduralNavigationPage`) to find the notes/scratchpad area. Add the `SupportingDataPanel` component near the existing notes input in both pages.
+
+- [ ] **Step 4: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/components/session/AddSupportingDataModal.tsx frontend/src/components/session/SupportingDataPanel.tsx frontend/src/pages/
+git commit -m "feat: add supporting data capture UI to session runners"
+```
+
+---
+
+### Task 21: PDF Export in ExportPreviewModal
+
+**Files:**
+- Modify: `frontend/src/components/session/ExportPreviewModal.tsx`
+- Modify: `frontend/src/pages/SessionDetailPage.tsx`
+
+- [ ] **Step 1: Update ExportPreviewModal**
+
+Read `frontend/src/components/session/ExportPreviewModal.tsx` and modify:
+
+1. Update format type to include `'pdf'`:
+```typescript
+format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
+```
+
+2. Add a `onDownloadPdf` callback prop for triggering the PDF download.
+
+3. When `format === 'pdf'`, hide the textarea and show a download-only UI:
+```tsx
+{format === 'pdf' ? (
+
+
PDF exports are generated server-side with your team's branding.
+
+ {loading ? 'Generating PDF...' : 'Download PDF'}
+
+
+) : (
+ // existing textarea + copy/download buttons
+)}
+```
+
+- [ ] **Step 2: Update SessionDetailPage**
+
+In `frontend/src/pages/SessionDetailPage.tsx`, add 'pdf' to the format options and implement the PDF download handler:
+
+```typescript
+const handleDownloadPdf = async () => {
+ setLoading(true)
+ try {
+ const response = await apiClient.post(
+ `/sessions/${sessionId}/export`,
+ { format: 'pdf' },
+ { responseType: 'blob' }
+ )
+ const url = URL.createObjectURL(response.data)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `session-export-${sessionId}.pdf`
+ a.click()
+ URL.revokeObjectURL(url)
+ } catch (error) {
+ console.error('PDF export failed:', error)
+ } finally {
+ setLoading(false)
+ }
+}
+```
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/session/ExportPreviewModal.tsx frontend/src/pages/SessionDetailPage.tsx
+git commit -m "feat: add PDF export option with download-only mode in export modal"
+```
+
+---
+
+### Task 22: Branding Settings UI
+
+**Files:**
+- Create: `frontend/src/components/settings/BrandingSettings.tsx`
+- Modify: relevant team settings page
+
+- [ ] **Step 1: Create BrandingSettings component**
+
+Create `frontend/src/components/settings/BrandingSettings.tsx` with:
+- Company display name text input
+- Logo upload area (drag-and-drop + file picker)
+- Logo preview showing current logo
+- Delete logo button
+- Save button that calls `updateBranding` with FormData
+- 2MB file size validation
+- Accepts PNG, JPG, SVG only
+- Preview of how logo appears on export (small mockup showing header layout)
+
+- [ ] **Step 2: Add to team settings page**
+
+Read the team settings page and add a "Branding" section with the `BrandingSettings` component. Only visible to team admins.
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/settings/BrandingSettings.tsx frontend/src/pages/
+git commit -m "feat: add branding settings UI for team logo and company name"
+```
+
+---
+
+### Task 23: Playwright E2E Tests
+
+**Files:**
+- Create: `frontend/e2e/empty-states.spec.ts`
+- Create: `frontend/e2e/guides.spec.ts`
+
+- [ ] **Step 1: Write empty state Playwright test**
+
+Create `frontend/e2e/empty-states.spec.ts`:
+
+```typescript
+import { test, expect } from '@playwright/test'
+
+test.describe('Empty States', () => {
+ test('Flow Library shows empty state with CTA and Learn more', async ({ page }) => {
+ // Login as a fresh user (or user with no flows)
+ // Navigate to /trees
+ // Verify empty state illustration is visible
+ // Verify "Build your first troubleshooting flow" title
+ // Verify "Create a Flow" CTA button
+ // Verify "Learn more →" link pointing to /guides/creating-flows
+ await page.goto('/trees')
+ await expect(page.getByText('Build your first troubleshooting flow')).toBeVisible()
+ await expect(page.getByText('Learn more →')).toBeVisible()
+ })
+})
+```
+
+- [ ] **Step 2: Write guide page Playwright test**
+
+Create `frontend/e2e/guides.spec.ts`:
+
+```typescript
+import { test, expect } from '@playwright/test'
+
+test.describe('Guide Pages', () => {
+ test('Guide page loads from Learn more link', async ({ page }) => {
+ await page.goto('/guides/creating-flows')
+ await expect(page.getByText('Creating Flows')).toBeVisible()
+ })
+
+ test('Unknown guide slug shows not-found state', async ({ page }) => {
+ await page.goto('/guides/nonexistent')
+ await expect(page.getByText('Guide not found')).toBeVisible()
+ })
+})
+```
+
+**Note:** These tests need authentication setup. Use the existing Playwright auth fixtures from `frontend/e2e/fixtures/auth.ts`. Adapt the test user and login pattern to the project's existing Playwright setup. Read the existing e2e tests first.
+
+- [ ] **Step 3: Run Playwright tests**
+
+Run: `cd frontend && npx playwright test e2e/empty-states.spec.ts e2e/guides.spec.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/e2e/empty-states.spec.ts frontend/e2e/guides.spec.ts
+git commit -m "test: add Playwright e2e tests for empty states and guides"
+```
+
+---
+
+### Task 24: PR 4 — Final Build & PR
+
+- [ ] **Step 1: Full frontend build**
+
+```bash
+cd frontend && npm run build
+```
+
+- [ ] **Step 2: Full backend test suite**
+
+```bash
+cd backend && pytest --override-ini="addopts=" -v
+```
+
+- [ ] **Step 3: Push branch and create PR**
+
+```bash
+git push -u origin feat/pdf-export-supporting-data-branding-ui
+```
+
+Create PR: "feat: PDF export, supporting data capture, and branding settings UI"
+
+---
+
+## Post-Implementation
+
+- [ ] Update `CURRENT-STATE.md` — move empty states, onboarding, and exports to completed
+- [ ] Update `03-DEVELOPMENT-ROADMAP.md` — check off these items
+- [ ] Close related GitHub issues
+- [ ] Update `CLAUDE.md` if new patterns emerged (e.g., guide page pattern, WeasyPrint usage)