Files
resolutionflow/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
chihlasm ae6b7b3055 docs: add implementation plan for empty states, onboarding, and exports
24 tasks across 4 PRs. Addresses all spec review findings:
model code uses Mapped[] syntax, standalone export functions,
team-scoped access checks, variable resolution in PDF, Jinja2/CSS fix,
Vitest + Playwright test tasks included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 23:23:22 -04:00

2651 lines
85 KiB
Markdown

# 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
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page {
size: A4;
margin: 2cm 2.5cm;
}
/* Jinja2 conditionally generates the appropriate @page footer rules */
{% if has_custom_logo %}
@page {
@bottom-right {
content: "Powered by ResolutionFlow";
font-size: 8pt;
color: #999;
}
}
{% endif %}
@page {
@bottom-left {
content: "Generated {{ generated_at }}";
font-size: 8pt;
color: #999;
}
}
body {
font-family: -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
color: #1a1a1a;
line-height: 1.6;
font-size: 11pt;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid #06b6d4;
padding-bottom: 12pt;
margin-bottom: 18pt;
}
.header-left .report-type {
font-size: 9pt;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4pt;
}
.header-left .flow-title {
font-size: 16pt;
font-weight: 700;
color: #1a1a1a;
}
.header-right {
text-align: right;
}
.header-right img {
max-width: 120px;
max-height: 40px;
}
.header-right .company-name {
font-size: 9pt;
color: #999;
margin-top: 4pt;
}
.metadata-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10pt;
padding: 12pt;
background: #f8f9fa;
border-radius: 6pt;
border: 1px solid #e9ecef;
margin-bottom: 18pt;
}
.metadata-grid .meta-label {
font-size: 8pt;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metadata-grid .meta-value {
font-size: 10pt;
font-weight: 600;
}
.outcome-resolved { color: #16a34a; }
.outcome-unresolved { color: #dc2626; }
.outcome-escalated { color: #f59e0b; }
.section-heading {
font-size: 11pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
border-left: 3px solid #06b6d4;
padding-left: 8pt;
margin-bottom: 10pt;
margin-top: 18pt;
}
.summary-text {
font-size: 10pt;
color: #333;
}
.timeline {
margin-left: 10pt;
border-left: 2px solid #e2e8f0;
padding-left: 14pt;
}
.timeline-step {
margin-bottom: 12pt;
position: relative;
page-break-inside: avoid;
}
.timeline-step .dot {
position: absolute;
left: -21pt;
top: 2pt;
width: 10pt;
height: 10pt;
background: #06b6d4;
border-radius: 50%;
}
.timeline-step.final .dot {
background: #16a34a;
}
.step-title {
font-size: 10pt;
font-weight: 600;
}
.step-decision {
font-size: 9pt;
color: #666;
margin-top: 2pt;
}
.supporting-data-section {
page-break-before: auto;
}
.supporting-data-item {
margin-bottom: 10pt;
padding: 10pt;
background: #f8f9fa;
border-radius: 5pt;
border: 1px solid #e9ecef;
page-break-inside: avoid;
}
.supporting-data-item .item-label {
font-size: 9pt;
font-weight: 600;
color: #666;
text-transform: uppercase;
margin-bottom: 5pt;
}
.supporting-data-item pre {
font-size: 9pt;
font-family: 'JetBrains Mono', 'Courier New', monospace;
background: #fff;
padding: 6pt;
border-radius: 3pt;
border: 1px solid #e2e8f0;
white-space: pre-wrap;
margin: 0;
}
.supporting-data-item img {
max-width: 100%;
border-radius: 3pt;
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="report-type">{{ report_type }}</div>
<div class="flow-title">{{ flow_title }}</div>
</div>
<div class="header-right">
{% if logo_data %}
<img src="data:{{ logo_content_type }};base64,{{ logo_data }}" alt="{{ company_name }}">
{% endif %}
{% if company_name %}
<div class="company-name">{{ company_name }}</div>
{% endif %}
</div>
</div>
<!-- Metadata Grid -->
<div class="metadata-grid">
<div>
<div class="meta-label">Engineer</div>
<div class="meta-value">{{ engineer_name }}</div>
</div>
<div>
<div class="meta-label">Client</div>
<div class="meta-value">{{ client_name or "—" }}</div>
</div>
<div>
<div class="meta-label">Ticket #</div>
<div class="meta-value">{{ ticket_number or "—" }}</div>
</div>
<div>
<div class="meta-label">Date</div>
<div class="meta-value">{{ session_date }}</div>
</div>
<div>
<div class="meta-label">Duration</div>
<div class="meta-value">{{ duration }}</div>
</div>
<div>
<div class="meta-label">Outcome</div>
<div class="meta-value outcome-{{ outcome_class }}">{{ outcome_display }}</div>
</div>
</div>
<!-- Summary -->
{% if summary %}
<div class="section-heading">Summary</div>
<p class="summary-text">{{ summary }}</p>
{% endif %}
<!-- Troubleshooting Path -->
{% if steps %}
<div class="section-heading">Troubleshooting Path</div>
<div class="timeline">
{% for step in steps %}
<div class="timeline-step {% if loop.last %}final{% endif %}">
<div class="dot"></div>
<div class="step-title">{{ loop.index }}. {{ step.title }}</div>
{% if step.decision %}
<div class="step-decision">Decision: {{ step.decision }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Supporting Data -->
{% if supporting_data %}
<div class="supporting-data-section">
<div class="section-heading">Supporting Data</div>
{% for item in supporting_data %}
<div class="supporting-data-item">
<div class="item-label">{{ item.label }}</div>
{% if item.data_type == "text_snippet" %}
<pre>{{ item.content }}</pre>
{% elif item.data_type == "screenshot" %}
<img src="data:{{ item.content_type }};base64,{{ item.content }}" alt="{{ item.label }}">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</body>
</html>
```
- [ ] **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: `<pre>` blocks with labels
- Screenshots: `<img>` 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 (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{illustration && (
<div className="mb-6 opacity-60">
{illustration}
</div>
)}
{!illustration && icon && (
<div className="mb-4 text-muted-foreground">{icon}</div>
)}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && (
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
{learnMoreLink && (
<Link
to={learnMoreLink}
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{learnMoreText}
</Link>
)}
</div>
)
}
```
- [ ] **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 (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="8" r="6" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
<line x1="40" y1="14" x2="25" y2="28" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="40" y1="14" x2="55" y2="28" stroke="#06b6d4" strokeWidth="1.5" />
<circle cx="25" cy="32" r="5" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
<circle cx="55" cy="32" r="5" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
<line x1="25" y1="37" x2="25" y2="48" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="55" y1="37" x2="55" y2="48" stroke="#22d3ee" strokeWidth="1.5" />
<circle cx="25" cy="52" r="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.2)" />
<circle cx="55" cy="52" r="4" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.2)" />
</svg>
)
}
export function AnalyticsIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="30" width="14" height="25" rx="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1" />
<rect x="23" y="20" width="14" height="35" rx="3" fill="rgba(6,182,212,0.2)" stroke="#06b6d4" strokeWidth="1" />
<rect x="41" y="10" width="14" height="45" rx="3" fill="rgba(6,182,212,0.25)" stroke="#06b6d4" strokeWidth="1" />
<rect x="59" y="5" width="14" height="50" rx="3" fill="rgba(34,211,238,0.3)" stroke="#22d3ee" strokeWidth="1" />
<line x1="2" y1="57" x2="78" y2="57" stroke="rgba(255,255,255,0.1)" strokeWidth="1" />
</svg>
)
}
export function SessionIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="5" width="60" height="12" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
<rect x="10" y="22" width="60" height="12" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
<rect x="10" y="39" width="60" height="12" rx="4" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
<circle cx="22" cy="11" r="3" fill="rgba(6,182,212,0.4)" />
<circle cx="22" cy="28" r="3" fill="rgba(6,182,212,0.4)" />
<circle cx="22" cy="45" r="3" fill="rgba(34,211,238,0.4)" />
</svg>
)
}
export function IntegrationIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="15" width="25" height="30" rx="5" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
<rect x="50" y="15" width="25" height="30" rx="5" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
<line x1="30" y1="26" x2="50" y2="26" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="30" y1="34" x2="50" y2="34" stroke="#22d3ee" strokeWidth="1.5" strokeDasharray="4 3" />
<polygon points="47,23 50,26 47,29" fill="#06b6d4" />
<polygon points="33,31 30,34 33,37" fill="#22d3ee" />
</svg>
)
}
export function StepLibraryIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="5" width="55" height="14" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.1)" />
<rect x="15" y="23" width="55" height="14" rx="4" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
<rect x="10" y="41" width="55" height="14" rx="4" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
<circle cx="20" cy="12" r="2.5" fill="rgba(6,182,212,0.5)" />
<circle cx="25" cy="30" r="2.5" fill="rgba(6,182,212,0.5)" />
<circle cx="20" cy="48" r="2.5" fill="rgba(34,211,238,0.5)" />
</svg>
)
}
export function ScriptIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="5" width="60" height="50" rx="5" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.05)" />
<line x1="18" y1="18" x2="35" y2="18" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="22" y1="26" x2="50" y2="26" stroke="rgba(6,182,212,0.6)" strokeWidth="1.5" />
<line x1="22" y1="34" x2="45" y2="34" stroke="rgba(34,211,238,0.6)" strokeWidth="1.5" />
<line x1="18" y1="42" x2="38" y2="42" stroke="#22d3ee" strokeWidth="1.5" />
<text x="14" y="19" fill="rgba(6,182,212,0.4)" fontSize="8" fontFamily="monospace">&gt;</text>
</svg>
)
}
export function ShareIllustration() {
return (
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="30" r="8" stroke="#06b6d4" strokeWidth="1.5" fill="rgba(6,182,212,0.15)" />
<circle cx="60" cy="15" r="6" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
<circle cx="60" cy="45" r="6" stroke="#22d3ee" strokeWidth="1.5" fill="rgba(34,211,238,0.1)" />
<line x1="27" y1="26" x2="54" y2="17" stroke="#06b6d4" strokeWidth="1.5" />
<line x1="27" y1="34" x2="54" y2="43" stroke="#06b6d4" strokeWidth="1.5" />
</svg>
)
}
```
- [ ] **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 `<EmptyState>` usage.
**Pattern for each page:**
```tsx
import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
// In the render:
<EmptyState
illustration={<FlowIllustration />}
title="Build your first troubleshooting flow"
description="Flows guide your team through proven resolution paths, capturing every decision along the way."
action={<button className="bg-gradient-brand ...">Create a Flow</button>}
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<string, { title: string; component: React.ComponentType }> = {
'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 (
<EmptyState
title="Guide not found"
description="The guide you're looking for doesn't exist."
action={
<Link to="/" className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all">
Back to Dashboard
</Link>
}
/>
)
}
const GuideContent = guide.component
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<nav className="mb-6 flex items-center gap-1 text-sm text-muted-foreground">
<Link to="/" className="hover:text-foreground transition-colors">Home</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-foreground">{guide.title}</span>
</nav>
<div className="glass-card-static p-8">
<GuideContent />
</div>
</div>
)
}
```
- [ ] **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 (
<article>
<h1 className="text-2xl font-heading font-bold text-foreground mb-4">Creating Flows</h1>
<p className="text-muted-foreground mb-6">
Flows are the core of ResolutionFlow structured troubleshooting paths that guide your team
through proven resolution steps.
</p>
<h2 className="text-lg font-semibold text-foreground mt-8 mb-3">Flow Types</h2>
<ul className="list-disc pl-6 text-muted-foreground space-y-2 mb-6">
<li><strong className="text-foreground">Troubleshooting</strong> Decision trees that branch based on what the engineer finds at each step.</li>
<li><strong className="text-foreground">Projects</strong> Step-by-step procedural guides for installations, migrations, and setups.</li>
<li><strong className="text-foreground">Maintenance</strong> Recurring check sequences you can schedule and run in batches.</li>
</ul>
<h2 className="text-lg font-semibold text-foreground mt-8 mb-3">Creating a Flow Manually</h2>
<p className="text-muted-foreground mb-4">
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.
</p>
<h2 className="text-lg font-semibold text-foreground mt-8 mb-3">Using AI to Generate Flows</h2>
<p className="text-muted-foreground mb-4">
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.
</p>
<div className="mt-8 pt-6 border-t border-border">
<Link
to="/trees"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all"
>
Go to Flow Library
</Link>
</div>
</article>
)
}
```
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 }) => (
<BrowserRouter>{children}</BrowserRouter>
)
describe('EmptyState', () => {
it('renders title and description', () => {
render(
<EmptyState title="No data" description="Nothing here yet" />,
{ wrapper }
)
expect(screen.getByText('No data')).toBeInTheDocument()
expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
})
it('renders illustration when provided', () => {
render(
<EmptyState title="Test" illustration={<FlowIllustration />} />,
{ wrapper }
)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('renders action button', () => {
render(
<EmptyState title="Test" action={<button>Do Thing</button>} />,
{ wrapper }
)
expect(screen.getByText('Do Thing')).toBeInTheDocument()
})
it('renders learn more link', () => {
render(
<EmptyState title="Test" learnMoreLink="/guides/test" />,
{ wrapper }
)
const link = screen.getByText('Learn more →')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/guides/test')
})
it('renders without optional props', () => {
render(<EmptyState title="Just a title" />, { 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<OnboardingStatus> {
const response = await apiClient.get('/users/onboarding-status')
return response.data
}
export async function dismissOnboarding(): Promise<void> {
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<OnboardingStatus | null>(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 (
<div className="glass-card p-5 mb-6">
{/* Progress bar */}
<div className="h-1 rounded-full bg-[rgba(255,255,255,0.06)] mb-4">
<div
className="h-full rounded-full bg-gradient-brand transition-all duration-500"
style={{ width: `${(completedCount / totalCount) * 100}%` }}
/>
</div>
<div className="flex items-center justify-between mb-4">
<div>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Getting Started
</span>
<span className="ml-2 text-xs text-muted-foreground">
{isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`}
</span>
</div>
<button
onClick={handleDismiss}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dismiss checklist"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-1">
{items.map((item) => {
const isComplete = status[item.key]
return (
<button
key={item.key}
onClick={() => !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)]'
)}
>
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border transition-colors',
isComplete
? 'border-primary/30 bg-primary/10'
: 'border-border'
)}
>
{isComplete && <Check className="h-3 w-3 text-primary" />}
</div>
<span className={cn(isComplete && 'line-through')}>{item.label}</span>
{!isComplete && (
<ChevronRight className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</button>
)
})}
</div>
</div>
)
}
```
- [ ] **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:
<OnboardingChecklist />
```
- [ ] **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<SupportingDataItem[]> {
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<SupportingDataItem> {
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<SupportingDataItem> {
const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
return response.data
}
export async function deleteSupportingData(sessionId: string, itemId: string): Promise<void> {
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<BrandingInfo> {
const response = await apiClient.get(`/teams/${teamId}/branding`)
return response.data
}
export async function updateBranding(
teamId: string,
formData: FormData
): Promise<BrandingInfo> {
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<void> {
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' ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4">PDF exports are generated server-side with your team's branding.</p>
<button
onClick={onDownloadPdf}
disabled={loading}
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-6 py-3 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all"
>
{loading ? 'Generating PDF...' : 'Download PDF'}
</button>
</div>
) : (
// 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)