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>
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(afteravatar_url) - Modify:
backend/app/models/team.py:25(aftername) - 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">></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
getSupportingDataon 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:
- Update format type to include
'pdf':
format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
-
Add a
onDownloadPdfcallback prop for triggering the PDF download. -
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
updateBrandingwith 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.mdif new patterns emerged (e.g., guide page pattern, WeasyPrint usage)