# 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
{{ report_type }}
{{ flow_title }}
{% if logo_data %} {{ company_name }} {% endif %} {% if company_name %}
{{ company_name }}
{% endif %}
Engineer
{{ engineer_name }}
Client
{{ client_name or "—" }}
Ticket #
{{ ticket_number or "—" }}
Date
{{ session_date }}
Duration
{{ duration }}
Outcome
{{ outcome_display }}
{% if summary %}
Summary

{{ summary }}

{% endif %} {% if steps %}
Troubleshooting Path
{% for step in steps %}
{{ loop.index }}. {{ step.title }}
{% if step.decision %}
Decision: {{ step.decision }}
{% endif %}
{% endfor %}
{% endif %} {% if supporting_data %}
Supporting Data
{% for item in supporting_data %}
{{ item.label }}
{% if item.data_type == "text_snippet" %}
{{ item.content }}
{% elif item.data_type == "screenshot" %} {{ item.label }} {% endif %}
{% endfor %}
{% endif %} ``` - [ ] **Step 5: Commit** ```bash git add backend/requirements.txt backend/Dockerfile backend/app/templates/ git commit -m "feat: add WeasyPrint dependency, Dockerfile system deps, and PDF template" ``` --- ### Task 8: PDF Generation in Export Service + Endpoint **Files:** - Modify: `backend/app/services/export_service.py` - Modify: `backend/app/api/endpoints/sessions.py:371-440` - Create: `backend/tests/test_pdf_export.py` - [ ] **Step 1: Write PDF export tests** Create `backend/tests/test_pdf_export.py`: ```python import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_export_pdf_format(client: AsyncClient, auth_headers: dict, test_session_id: str): """Export as PDF returns application/pdf content type.""" response = await client.post( f"/api/v1/sessions/{test_session_id}/export", headers=auth_headers, json={"format": "pdf"}, ) assert response.status_code == 200 assert response.headers["content-type"] == "application/pdf" assert response.content[:4] == b"%PDF" @pytest.mark.asyncio async def test_export_pdf_no_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str): """PDF export works when session has no supporting data.""" response = await client.post( f"/api/v1/sessions/{test_session_id}/export", headers=auth_headers, json={"format": "pdf"}, ) assert response.status_code == 200 assert response.content[:4] == b"%PDF" @pytest.mark.asyncio async def test_export_markdown_still_works(client: AsyncClient, auth_headers: dict, test_session_id: str): """Existing markdown export still works after PDF addition.""" response = await client.post( f"/api/v1/sessions/{test_session_id}/export", headers=auth_headers, json={"format": "markdown"}, ) assert response.status_code == 200 assert "text/plain" in response.headers["content-type"] ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="` Expected: FAIL - [ ] **Step 3: Add generate_pdf_export function to export_service.py** **Important:** The export service uses standalone module-level functions (NOT a class). Add this as a standalone `async def` matching the pattern of `generate_markdown_export()`, `generate_text_export()`, etc. Add the following function to `backend/app/services/export_service.py`: ```python async def generate_pdf_export( session: "Session", options: "SessionExport", db: "AsyncSession", ) -> bytes: """Generate a branded PDF export using WeasyPrint.""" import weasyprint from jinja2 import Environment, FileSystemLoader from pathlib import Path from datetime import datetime, timezone from app.models.supporting_data import SessionSupportingData from sqlalchemy import select # Load template template_dir = Path(__file__).parent.parent / "templates" env = Environment(loader=FileSystemLoader(str(template_dir))) template = env.get_template("export_pdf.html") # Get tree snapshot data tree_snapshot = session.tree_snapshot or {} flow_title = tree_snapshot.get("title", "Untitled Flow") tree_type = tree_snapshot.get("tree_type", "troubleshooting") report_type_map = { "troubleshooting": "Troubleshooting Report", "procedural": "Project Report", "maintenance": "Maintenance Report", } report_type = report_type_map.get(tree_type, "Session Report") # Get branding logo_data = None logo_content_type = None company_name = None has_custom_logo = False user = session.user if user and user.team_id: from app.models.team import Team team = await db.get(Team, user.team_id) if team: if team.logo_data: logo_data = team.logo_data logo_content_type = team.logo_content_type has_custom_logo = True company_name = team.company_display_name or team.name elif user: if user.logo_data: logo_data = user.logo_data logo_content_type = user.logo_content_type has_custom_logo = True company_name = user.company_display_name # Build steps from decisions steps = [] for decision in (session.decisions or []): steps.append({ "title": decision.get("title") or decision.get("question") or decision.get("description", "Step"), "decision": decision.get("selected_option") or decision.get("answer", ""), }) # Get supporting data result = await db.execute( select(SessionSupportingData) .where(SessionSupportingData.session_id == session.id) .order_by(SessionSupportingData.sort_order) ) supporting_data_items = result.scalars().all() # Calculate duration duration = "—" if session.started_at and session.completed_at: delta = session.completed_at - session.started_at minutes = int(delta.total_seconds() / 60) if minutes < 60: duration = f"{minutes} min" else: hours = minutes // 60 remaining = minutes % 60 duration = f"{hours}h {remaining}m" # Outcome display outcome = session.outcome or "In Progress" outcome_class = "resolved" if outcome == "resolved" else "unresolved" if outcome == "unresolved" else "escalated" outcome_display = f"✓ {outcome.title()}" if outcome == "resolved" else outcome.title() # Session date session_date = "" if session.started_at: session_date = session.started_at.strftime("%B %d, %Y") # Summary summary = session.outcome_notes or "" # Engineer name engineer_name = user.name if user else "Unknown" # Generated timestamp generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC") # Render HTML html_content = template.render( report_type=report_type, flow_title=flow_title, logo_data=logo_data, logo_content_type=logo_content_type, has_custom_logo=has_custom_logo, company_name=company_name, engineer_name=engineer_name, client_name=session.client_name, ticket_number=session.ticket_number, session_date=session_date, duration=duration, outcome_class=outcome_class, outcome_display=outcome_display, summary=summary, steps=steps, supporting_data=supporting_data_items, generated_at=generated_at, ) # Generate PDF pdf_bytes = weasyprint.HTML(string=html_content).write_pdf() return pdf_bytes ``` - [ ] **Step 4: Update export endpoint for PDF format** In `backend/app/api/endpoints/sessions.py`, in the `export_session` function, add the PDF branch. After the format dispatch block (around line 395-406), add: ```python if export_options.format == "pdf": from app.services.export_service import generate_pdf_export pdf_bytes = await generate_pdf_export(session, export_options, db) # Mark as exported if completed (same logic as other formats) if session.completed_at and not session.exported: session.exported = True db.add(session) await db.commit() from fastapi.responses import Response return Response( content=pdf_bytes, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"' }, ) ``` Make sure this block runs before the existing format dispatch so it returns early for PDF. **Note:** Variable resolution and redaction are handled inside `generate_pdf_export()` before rendering — the function must call `resolve_variables()` on text content and `apply_redaction_to_text()` if `redaction_mode != "none"`, matching the pattern used by other export formats in the existing code. - [ ] **Step 5: Run tests** Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="` Expected: PASS - [ ] **Step 6: Run full test suite to verify no regressions** Run: `cd backend && pytest --override-ini="addopts="` Expected: All existing tests still pass. - [ ] **Step 7: Commit** ```bash git add backend/app/services/export_service.py backend/app/api/endpoints/sessions.py backend/tests/test_pdf_export.py git commit -m "feat: add PDF export generation via WeasyPrint with branded template" ``` --- ### Task 9: Supporting Data in Non-PDF Export Formats **Files:** - Modify: `backend/app/services/export_service.py` The spec requires supporting data to be included in ALL export formats, not just PDF. - [ ] **Step 1: Add supporting data to generate_markdown_export** After the existing decisions/steps section, add a "## Supporting Data" section that renders each item: - Text snippets: labeled fenced code blocks - Screenshots: `[Screenshot: {label}]` placeholder (base64 images don't work in plain markdown) - [ ] **Step 2: Add supporting data to generate_text_export** After the steps section, add a "SUPPORTING DATA" section: - Text snippets: labeled indented blocks - Screenshots: `[Screenshot: {label}]` placeholder - [ ] **Step 3: Add supporting data to generate_html_export** After the steps section, add a "Supporting Data" section: - Text snippets: `
` blocks with labels
- Screenshots: `` tags with base64 src

- [ ] **Step 4: Add supporting data to generate_psa_export**

After the steps section, add a "Supporting Data" section:
- Text snippets: labeled code blocks (markdown format for CW notes)
- Screenshots: `[Screenshot: {label}]` placeholder

**Note:** All four functions need to accept `db: AsyncSession` as a parameter (or the supporting data items as a pre-fetched list) to load the session's supporting data. Read the existing function signatures and follow the established pattern.

- [ ] **Step 5: Commit**

```bash
git add backend/app/services/export_service.py
git commit -m "feat: include supporting data in all export formats"
```

---

### Task 10: PR 1 Final — Integration Test Run & PR

- [ ] **Step 1: Run full backend test suite**

```bash
cd backend && pytest --override-ini="addopts=" -v
```

Expected: All tests pass.

- [ ] **Step 2: Push feature branch and create PR**

```bash
git push -u origin feat/backend-foundation-empty-states-exports
```

Create PR with title: "feat: backend foundation for empty states, onboarding, and exports"

---

## Chunk 2: PR 2 — Empty States + Guides

### File Structure

| Action | File | Responsibility |
|--------|------|---------------|
| Modify | `frontend/src/components/common/EmptyState.tsx` | Add illustration, learnMoreLink props |
| Create | `frontend/src/components/common/EmptyStateIllustrations.tsx` | SVG illustrations for each page |
| Modify | `frontend/src/pages/TreeLibraryPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/MyAnalyticsPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/TeamAnalyticsPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/SessionHistoryPage.tsx` | Upgraded empty state |
| Modify | `frontend/src/pages/StepLibraryPage.tsx` | Add empty state |
| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` | Add empty state |
| Modify | `frontend/src/pages/MySharesPage.tsx` | Upgraded empty state |
| Modify | relevant integrations page | Add empty state |
| Create | `frontend/src/pages/guides/GuidePage.tsx` | Guide route wrapper |
| Create | `frontend/src/pages/guides/CreatingFlowsGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/UnderstandingAnalyticsGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/RunningSessionsGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/PsaSetupGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/StepLibraryGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/ScriptTemplatesGuide.tsx` | Guide content |
| Create | `frontend/src/pages/guides/SharingSessionsGuide.tsx` | Guide content |
| Modify | `frontend/src/router.tsx` | Add `/guides/:slug` route |

---

### Task 11: Upgrade EmptyState Component

**Files:**
- Modify: `frontend/src/components/common/EmptyState.tsx`
- Create: `frontend/src/components/common/EmptyStateIllustrations.tsx`

- [ ] **Step 1: Update EmptyState component**

Rewrite `frontend/src/components/common/EmptyState.tsx` to support the new illustrative style:

```tsx
import { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { cn } from '@/lib/utils'

interface EmptyStateProps {
  icon?: ReactNode
  illustration?: ReactNode
  title: string
  description?: string
  action?: ReactNode
  learnMoreLink?: string
  learnMoreText?: string
  className?: string
}

export function EmptyState({
  icon,
  illustration,
  title,
  description,
  action,
  learnMoreLink,
  learnMoreText = 'Learn more',
  className,
}: EmptyStateProps) {
  return (
    
{illustration && (
{illustration}
)} {!illustration && icon && (
{icon}
)}

{title}

{description && (

{description}

)} {action &&
{action}
} {learnMoreLink && ( {learnMoreText} → )}
) } ``` - [ ] **Step 2: Create illustrations file** Create `frontend/src/components/common/EmptyStateIllustrations.tsx` with SVG illustrations for each page. Each illustration is a simple 80x60 SVG using brand colors: ```tsx export function FlowIllustration() { return ( ) } export function AnalyticsIllustration() { return ( ) } export function SessionIllustration() { return ( ) } export function IntegrationIllustration() { return ( ) } export function StepLibraryIllustration() { return ( ) } export function ScriptIllustration() { return ( > ) } export function ShareIllustration() { return ( ) } ``` - [ ] **Step 3: Verify frontend build** Run: `cd frontend && npm run build` Expected: Build succeeds. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/common/EmptyState.tsx frontend/src/components/common/EmptyStateIllustrations.tsx git commit -m "feat: upgrade EmptyState component with illustration and learn more support" ``` --- ### Task 12: Roll Out Empty States Across Pages **Files:** 8 page files (see file structure above) - [ ] **Step 1: Update each page's empty state** For each page, update the empty state usage to include the new props (illustration, description, CTA, learnMoreLink). The specific edits depend on how each page currently renders its empty state — read each file and update the `` usage. **Pattern for each page:** ```tsx import { FlowIllustration } from '@/components/common/EmptyStateIllustrations' // In the render: } title="Build your first troubleshooting flow" description="Flows guide your team through proven resolution paths, capturing every decision along the way." action={} learnMoreLink="/guides/creating-flows" /> ``` Apply the correct illustration, title, description, CTA, and guide link from the spec table for each of the 8 pages. - [ ] **Step 2: Add empty states to pages that don't have them** For StepLibraryPage, ScriptLibraryPage, and the integrations page — add the `EmptyState` component where no data exists. Read each file first to understand the current rendering logic. - [ ] **Step 3: Verify frontend build** Run: `cd frontend && npm run build` Expected: Build succeeds with no TypeScript errors. - [ ] **Step 4: Commit** ```bash git add frontend/src/pages/ git commit -m "feat: roll out illustrative empty states across 8 pages" ``` --- ### Task 13: Create Guide Pages & Route **Files:** - Create: 7 guide component files in `frontend/src/pages/guides/` - Create: `frontend/src/pages/guides/GuidePage.tsx` - Modify: `frontend/src/router.tsx` - [ ] **Step 1: Create guide page wrapper** Create `frontend/src/pages/guides/GuidePage.tsx`: ```tsx import { useParams, Link } from 'react-router-dom' import { ChevronRight } from 'lucide-react' import { EmptyState } from '@/components/common/EmptyState' import CreatingFlowsGuide from './CreatingFlowsGuide' import UnderstandingAnalyticsGuide from './UnderstandingAnalyticsGuide' import RunningSessionsGuide from './RunningSessionsGuide' import PsaSetupGuide from './PsaSetupGuide' import StepLibraryGuide from './StepLibraryGuide' import ScriptTemplatesGuide from './ScriptTemplatesGuide' import SharingSessionsGuide from './SharingSessionsGuide' const guides: Record = { 'creating-flows': { title: 'Creating Flows', component: CreatingFlowsGuide }, 'understanding-analytics': { title: 'Understanding Analytics', component: UnderstandingAnalyticsGuide }, 'running-sessions': { title: 'Running Sessions', component: RunningSessionsGuide }, 'psa-setup': { title: 'Connecting Your PSA', component: PsaSetupGuide }, 'step-library': { title: 'Using the Step Library', component: StepLibraryGuide }, 'script-templates': { title: 'Script Templates', component: ScriptTemplatesGuide }, 'sharing-sessions': { title: 'Sharing Sessions', component: SharingSessionsGuide }, } export default function GuidePage() { const { slug } = useParams<{ slug: string }>() const guide = slug ? guides[slug] : undefined if (!guide) { return ( Back to Dashboard } /> ) } const GuideContent = guide.component return (
) } ``` - [ ] **Step 2: Create each guide component** Create 7 guide files in `frontend/src/pages/guides/`. Each follows the same pattern — a functional component with heading, paragraphs, and a CTA link. Example for `CreatingFlowsGuide.tsx`: ```tsx import { Link } from 'react-router-dom' export default function CreatingFlowsGuide() { return (

Creating Flows

Flows are the core of ResolutionFlow — structured troubleshooting paths that guide your team through proven resolution steps.

Flow Types

  • Troubleshooting — Decision trees that branch based on what the engineer finds at each step.
  • Projects — Step-by-step procedural guides for installations, migrations, and setups.
  • Maintenance — Recurring check sequences you can schedule and run in batches.

Creating a Flow Manually

Click "Create a Flow" from the Flow Library, choose your flow type, and start building in the visual editor. Add decision nodes, connect paths, and define outcomes.

Using AI to Generate Flows

Describe your troubleshooting scenario in plain language and the AI assistant will generate a complete flow structure. You can then refine it in the editor.

Go to Flow Library →
) } ``` Create the remaining 6 guides following the same pattern, tailored to their topic per the spec table. Keep each 300-600 words. - [ ] **Step 3: Add route to router.tsx** In `frontend/src/router.tsx`, add the guide route inside the protected children: ```tsx { path: 'guides/:slug', element: page(GuidePage) } ``` Add the lazy import at the top: ```tsx const GuidePage = lazy(() => import('./pages/guides/GuidePage')) ``` - [ ] **Step 4: Verify frontend build** Run: `cd frontend && npm run build` Expected: Build succeeds. - [ ] **Step 5: Commit** ```bash git add frontend/src/pages/guides/ frontend/src/router.tsx git commit -m "feat: add 7 in-app user guides with /guides/:slug route" ``` --- ### Task 14: EmptyState Vitest Tests **Files:** - Create: `frontend/src/components/common/__tests__/EmptyState.test.tsx` - [ ] **Step 1: Write tests** Create `frontend/src/components/common/__tests__/EmptyState.test.tsx`: ```tsx import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import { EmptyState } from '../EmptyState' import { FlowIllustration } from '../EmptyStateIllustrations' const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ) describe('EmptyState', () => { it('renders title and description', () => { render( , { wrapper } ) expect(screen.getByText('No data')).toBeInTheDocument() expect(screen.getByText('Nothing here yet')).toBeInTheDocument() }) it('renders illustration when provided', () => { render( } />, { wrapper } ) expect(document.querySelector('svg')).toBeInTheDocument() }) it('renders action button', () => { render( Do Thing} />, { wrapper } ) expect(screen.getByText('Do Thing')).toBeInTheDocument() }) it('renders learn more link', () => { render( , { wrapper } ) const link = screen.getByText('Learn more →') expect(link).toBeInTheDocument() expect(link).toHaveAttribute('href', '/guides/test') }) it('renders without optional props', () => { render(, { wrapper }) expect(screen.getByText('Just a title')).toBeInTheDocument() expect(screen.queryByText('Learn more →')).not.toBeInTheDocument() }) }) ``` - [ ] **Step 2: Run tests** Run: `cd frontend && npx vitest run src/components/common/__tests__/EmptyState.test.tsx` Expected: PASS - [ ] **Step 3: Commit** ```bash git add frontend/src/components/common/__tests__/EmptyState.test.tsx git commit -m "test: add EmptyState component Vitest tests" ``` --- ### Task 15: PR 2 — Build Verification & PR - [ ] **Step 1: Full frontend build** ```bash cd frontend && npm run build ``` Expected: Clean build, no errors. - [ ] **Step 2: Create feature branch and PR** ```bash git checkout -b feat/empty-states-and-guides git push -u origin feat/empty-states-and-guides ``` Create PR: "feat: illustrative empty states across 8 pages with in-app guides" --- ## Chunk 3: PR 3 — Onboarding Checklist ### File Structure | Action | File | Responsibility | |--------|------|---------------| | Create | `frontend/src/components/dashboard/OnboardingChecklist.tsx` | Checklist widget component | | Create | `frontend/src/api/onboarding.ts` | API client for onboarding endpoints | | Modify | `frontend/src/pages/QuickStartPage.tsx` | Insert checklist widget | --- ### Task 16: Onboarding API Client **Files:** - Create: `frontend/src/api/onboarding.ts` - [ ] **Step 1: Create API client** Create `frontend/src/api/onboarding.ts`: ```typescript import { apiClient } from './client' export interface OnboardingStatus { created_flow: boolean ran_session: boolean exported_session: boolean tried_ai_assistant: boolean invited_teammate: boolean connected_psa: boolean is_team_user: boolean dismissed: boolean } export async function getOnboardingStatus(): Promise { const response = await apiClient.get('/users/onboarding-status') return response.data } export async function dismissOnboarding(): Promise { await apiClient.post('/users/onboarding-status/dismiss') } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/api/onboarding.ts git commit -m "feat: add onboarding status API client" ``` --- ### Task 17: OnboardingChecklist Component **Files:** - Create: `frontend/src/components/dashboard/OnboardingChecklist.tsx` - [ ] **Step 1: Create the component** Create `frontend/src/components/dashboard/OnboardingChecklist.tsx`: ```tsx import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Check, X, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { getOnboardingStatus, dismissOnboarding, OnboardingStatus } from '@/api/onboarding' interface ChecklistItem { key: keyof OnboardingStatus label: string path: string } const SOLO_ITEMS: ChecklistItem[] = [ { key: 'created_flow', label: 'Create your first flow', path: '/trees' }, { key: 'ran_session', label: 'Run your first session', path: '/trees' }, { key: 'exported_session', label: 'Export a session', path: '/sessions' }, { key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' }, ] const TEAM_ITEMS: ChecklistItem[] = [ { key: 'created_flow', label: 'Create your first flow', path: '/trees' }, { key: 'invited_teammate', label: 'Invite a team member', path: '/account' }, { key: 'ran_session', label: 'Run your first session', path: '/trees' }, { key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' }, { key: 'exported_session', label: 'Export a session', path: '/sessions' }, ] export function OnboardingChecklist() { const [status, setStatus] = useState(null) const [dismissed, setDismissed] = useState(false) const [allComplete, setAllComplete] = useState(false) const navigate = useNavigate() useEffect(() => { getOnboardingStatus() .then(setStatus) .catch(() => {}) }, []) if (!status || status.dismissed || dismissed) return null const items = status.is_team_user ? TEAM_ITEMS : SOLO_ITEMS const completedCount = items.filter((item) => status[item.key]).length const totalCount = items.length const isAllDone = completedCount === totalCount // Show "all set" briefly then auto-hide after 2 seconds useEffect(() => { if (isAllDone) { const timer = setTimeout(() => setAllComplete(true), 2000) return () => clearTimeout(timer) } }, [isAllDone]) if (allComplete) return null const handleDismiss = async () => { setDismissed(true) await dismissOnboarding().catch(() => {}) } return (
{/* Progress bar */}
Getting Started {isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`}
{items.map((item) => { const isComplete = status[item.key] return ( ) })}
) } ``` - [ ] **Step 2: Add to QuickStartPage** In `frontend/src/pages/QuickStartPage.tsx`, import and insert the checklist after the greeting section (after line ~279, before the calendar/stats section): ```tsx import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist' // In the render, after the greeting div: ``` - [ ] **Step 3: Verify frontend build** Run: `cd frontend && npm run build` Expected: Build succeeds. - [ ] **Step 4: Commit** ```bash git add frontend/src/components/dashboard/OnboardingChecklist.tsx frontend/src/pages/QuickStartPage.tsx git commit -m "feat: add onboarding checklist widget to dashboard" ``` --- ### Task 18: PR 3 — Build & PR - [ ] **Step 1: Full frontend build** ```bash cd frontend && npm run build ``` - [ ] **Step 2: Create branch and PR** ```bash git checkout -b feat/onboarding-checklist git push -u origin feat/onboarding-checklist ``` Create PR: "feat: onboarding starter checklist on QuickStartPage" --- ## Chunk 4: PR 4 — PDF Export UI + Supporting Data UI + Branding Settings ### File Structure | Action | File | Responsibility | |--------|------|---------------| | Create | `frontend/src/api/supportingData.ts` | Supporting data API client | | Create | `frontend/src/api/branding.ts` | Branding API client | | Create | `frontend/src/components/session/SupportingDataPanel.tsx` | Supporting data list + add modal | | Create | `frontend/src/components/session/AddSupportingDataModal.tsx` | Add text snippet / screenshot modal | | Modify | `frontend/src/components/session/ExportPreviewModal.tsx` | PDF format + download-only mode | | Modify | `frontend/src/pages/SessionDetailPage.tsx` | Add PDF format option | | Create | `frontend/src/components/settings/BrandingSettings.tsx` | Logo upload + company name form | | Modify | relevant team settings page | Add branding section | | Modify | session runner pages | Add supporting data button | --- ### Task 19: Supporting Data & Branding API Clients **Files:** - Create: `frontend/src/api/supportingData.ts` - Create: `frontend/src/api/branding.ts` - [ ] **Step 1: Create supporting data API client** Create `frontend/src/api/supportingData.ts`: ```typescript import { apiClient } from './client' export interface SupportingDataItem { id: string session_id: string label: string data_type: 'text_snippet' | 'screenshot' content: string content_type: string | null sort_order: number created_at: string updated_at: string } export async function getSupportingData(sessionId: string): Promise { const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`) return response.data } export async function createSupportingData( sessionId: string, data: { label: string; data_type: string; content: string; content_type?: string } ): Promise { const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data) return response.data } export async function updateSupportingData( sessionId: string, itemId: string, data: { label?: string; content?: string } ): Promise { const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data) return response.data } export async function deleteSupportingData(sessionId: string, itemId: string): Promise { await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`) } ``` - [ ] **Step 2: Create branding API client** Create `frontend/src/api/branding.ts`: ```typescript import { apiClient } from './client' export interface BrandingInfo { company_display_name: string | null logo_content_type: string | null has_logo: boolean } export async function getBranding(teamId: string): Promise { const response = await apiClient.get(`/teams/${teamId}/branding`) return response.data } export async function updateBranding( teamId: string, formData: FormData ): Promise { const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }) return response.data } export async function deleteLogo(teamId: string): Promise { await apiClient.delete(`/teams/${teamId}/branding/logo`) } ``` - [ ] **Step 3: Commit** ```bash git add frontend/src/api/supportingData.ts frontend/src/api/branding.ts git commit -m "feat: add supporting data and branding API clients" ``` --- ### Task 20: Supporting Data UI Components **Files:** - Create: `frontend/src/components/session/AddSupportingDataModal.tsx` - Create: `frontend/src/components/session/SupportingDataPanel.tsx` - [ ] **Step 1: Create the add modal** Create `frontend/src/components/session/AddSupportingDataModal.tsx` with: - Two tabs: "Text Snippet" and "Screenshot" - Text tab: label input + multiline textarea - Screenshot tab: label input + drag-and-drop zone + file picker + paste support - Submit button that calls `createSupportingData` - 2MB file size validation for screenshots - Uses glass-card modal styling per design system - [ ] **Step 2: Create the panel** Create `frontend/src/components/session/SupportingDataPanel.tsx` with: - Collapsible section showing supporting data items - Each item: type icon (Code2 for text, Image for screenshot) + label + preview + delete button - "Add Supporting Data" button that opens the modal - Fetches items via `getSupportingData` on mount - Uses the design system's glass card and muted foreground styles - [ ] **Step 3: Integrate into session runner pages** Read the troubleshooting session runner (`TreeNavigationPage`) and procedural session runner (`ProceduralNavigationPage`) to find the notes/scratchpad area. Add the `SupportingDataPanel` component near the existing notes input in both pages. - [ ] **Step 4: Verify frontend build** Run: `cd frontend && npm run build` Expected: Build succeeds. - [ ] **Step 5: Commit** ```bash git add frontend/src/components/session/AddSupportingDataModal.tsx frontend/src/components/session/SupportingDataPanel.tsx frontend/src/pages/ git commit -m "feat: add supporting data capture UI to session runners" ``` --- ### Task 21: PDF Export in ExportPreviewModal **Files:** - Modify: `frontend/src/components/session/ExportPreviewModal.tsx` - Modify: `frontend/src/pages/SessionDetailPage.tsx` - [ ] **Step 1: Update ExportPreviewModal** Read `frontend/src/components/session/ExportPreviewModal.tsx` and modify: 1. Update format type to include `'pdf'`: ```typescript format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf' ``` 2. Add a `onDownloadPdf` callback prop for triggering the PDF download. 3. When `format === 'pdf'`, hide the textarea and show a download-only UI: ```tsx {format === 'pdf' ? (

PDF exports are generated server-side with your team's branding.

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