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

85 KiB

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
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:

    # 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:

    # 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
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:

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:

    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:

from app.models.supporting_data import SessionSupportingData
  • Step 4: Generate and apply migration
cd backend && alembic revision --autogenerate -m "add session_supporting_data table"
cd backend && alembic upgrade head
  • Step 5: Commit
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:

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:

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:

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:

    format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")

to:

    format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$")
  • Step 5: Commit
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:

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:

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:

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
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:

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:

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
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:

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:

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:

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
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
cd backend && pip install weasyprint jinja2

Note: WeasyPrint requires system libraries. On Ubuntu/Debian:

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:

RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*

To:

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:

<!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
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:

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:

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:

    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
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
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
cd backend && pytest --override-ini="addopts=" -v

Expected: All tests pass.

  • Step 2: Push feature branch and create PR
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:

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:

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
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:

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
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:

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:

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:

{ path: 'guides/:slug', element: page(GuidePage) }

Add the lazy import at the top:

const GuidePage = lazy(() => import('./pages/guides/GuidePage'))
  • Step 4: Verify frontend build

Run: cd frontend && npm run build Expected: Build succeeds.

  • Step 5: Commit
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:

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
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
cd frontend && npm run build

Expected: Clean build, no errors.

  • Step 2: Create feature branch and PR
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:

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
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:

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):

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
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
cd frontend && npm run build
  • Step 2: Create branch and PR
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:

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:

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
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
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':
format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
  1. Add a onDownloadPdf callback prop for triggering the PDF download.

  2. When format === 'pdf', hide the textarea and show a download-only UI:

{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:

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
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
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:

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:

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
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
cd frontend && npm run build
  • Step 2: Full backend test suite
cd backend && pytest --override-ini="addopts=" -v
  • Step 3: Push branch and create PR
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)