diff --git a/.gitignore b/.gitignore
index 8fe2a9d1..cfb5aab2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -220,6 +220,12 @@ frontend/playwright-report/
frontend/test-results/
frontend/e2e/.auth/
+# Superpowers brainstorming mockups
+.superpowers/
+
+# Test results (root level)
+test-results/
+
# Railway CLI (local tooling)
node_modules/
package.json
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 0bccd57a..1cbc6ea6 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -6,6 +6,10 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
+ libpango1.0-dev \
+ libcairo2-dev \
+ libgdk-pixbuf-2.0-dev \
+ libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
diff --git a/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py
new file mode 100644
index 00000000..4627141a
--- /dev/null
+++ b/backend/alembic/versions/21ddb46ddd05_add_onboarding_and_branding_columns.py
@@ -0,0 +1,41 @@
+"""add onboarding and branding columns
+
+Revision ID: 21ddb46ddd05
+Revises: 061
+Create Date: 2026-03-16 23:30:48.910485
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '21ddb46ddd05'
+down_revision: Union[str, None] = '061'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Users: onboarding + branding columns
+ op.add_column('users', sa.Column('onboarding_dismissed', sa.Boolean(), server_default='false', nullable=False))
+ op.add_column('users', sa.Column('logo_data', sa.Text(), nullable=True))
+ op.add_column('users', sa.Column('logo_content_type', sa.String(length=50), nullable=True))
+ op.add_column('users', sa.Column('company_display_name', sa.String(length=255), nullable=True))
+
+ # Teams: branding columns
+ op.add_column('teams', sa.Column('logo_data', sa.Text(), nullable=True))
+ op.add_column('teams', sa.Column('logo_content_type', sa.String(length=50), nullable=True))
+ op.add_column('teams', sa.Column('company_display_name', sa.String(length=255), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('teams', 'company_display_name')
+ op.drop_column('teams', 'logo_content_type')
+ op.drop_column('teams', 'logo_data')
+ op.drop_column('users', 'company_display_name')
+ op.drop_column('users', 'logo_content_type')
+ op.drop_column('users', 'logo_data')
+ op.drop_column('users', 'onboarding_dismissed')
diff --git a/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py
new file mode 100644
index 00000000..9a4ecdef
--- /dev/null
+++ b/backend/alembic/versions/ee98013dd18c_add_session_supporting_data_table.py
@@ -0,0 +1,41 @@
+"""add session_supporting_data table
+
+Revision ID: ee98013dd18c
+Revises: 21ddb46ddd05
+Create Date: 2026-03-16 23:31:43.483511
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'ee98013dd18c'
+down_revision: Union[str, None] = '21ddb46ddd05'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('session_supporting_data',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('session_id', sa.UUID(), nullable=False),
+ sa.Column('label', sa.String(length=255), nullable=False),
+ sa.Column('data_type', sa.Enum('text_snippet', 'screenshot', name='supporting_data_type'), nullable=False),
+ sa.Column('content', sa.Text(), nullable=False),
+ sa.Column('content_type', sa.String(length=50), nullable=True),
+ sa.Column('sort_order', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index(op.f('ix_session_supporting_data_session_id'), 'session_supporting_data', ['session_id'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f('ix_session_supporting_data_session_id'), table_name='session_supporting_data')
+ op.drop_table('session_supporting_data')
+ op.execute("DROP TYPE IF EXISTS supporting_data_type")
diff --git a/backend/app/api/endpoints/branding.py b/backend/app/api/endpoints/branding.py
new file mode 100644
index 00000000..7e22b2f7
--- /dev/null
+++ b/backend/app/api/endpoints/branding.py
@@ -0,0 +1,130 @@
+"""Team branding endpoints — logo upload and company display name."""
+
+import base64
+from typing import Annotated, Optional
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user, 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
+
+
+def _require_team_member(user: User, team_id: UUID) -> None:
+ """Ensure the user belongs to the given team (or is super admin)."""
+ if user.is_super_admin:
+ return
+ if user.team_id != team_id:
+ raise HTTPException(status_code=403, detail="Not a member of this team")
+
+
+def _require_team_admin(user: User, team_id: UUID) -> None:
+ """Ensure the user is a team admin for the given team (or is super admin)."""
+ 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")
+
+
+@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:
+ """Retrieve branding info for a team. Any team member can read."""
+ _require_team_member(current_user, team_id)
+
+ result = await db.execute(select(Team).where(Team.id == team_id))
+ team = result.scalar_one_or_none()
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found")
+
+ 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:
+ """Upload logo and/or update company display name. Team admin only."""
+ _require_team_admin(current_user, team_id)
+
+ result = await db.execute(select(Team).where(Team.id == team_id))
+ team = result.scalar_one_or_none()
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found")
+
+ # Handle logo upload
+ if logo is not None:
+ if logo.content_type not in ALLOWED_CONTENT_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid content type '{logo.content_type}'. Allowed: {', '.join(sorted(ALLOWED_CONTENT_TYPES))}",
+ )
+
+ raw_bytes = await logo.read()
+ if len(raw_bytes) > MAX_LOGO_SIZE:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Logo exceeds maximum size of {MAX_LOGO_SIZE // (1024 * 1024)}MB",
+ )
+
+ team.logo_data = base64.b64encode(raw_bytes).decode("utf-8")
+ team.logo_content_type = logo.content_type
+
+ # Handle display name update
+ if company_display_name is not None:
+ team.company_display_name = company_display_name
+
+ 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", status_code=200, response_model=BrandingResponse)
+async def delete_logo(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> BrandingResponse:
+ """Remove the team logo. Team admin only."""
+ _require_team_admin(current_user, team_id)
+
+ result = await db.execute(select(Team).where(Team.id == team_id))
+ team = result.scalar_one_or_none()
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found")
+
+ team.logo_data = None
+ team.logo_content_type = None
+ 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=False,
+ )
diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py
new file mode 100644
index 00000000..fdb07cd8
--- /dev/null
+++ b/backend/app/api/endpoints/onboarding.py
@@ -0,0 +1,110 @@
+"""Onboarding status endpoints — track new-user checklist progress."""
+
+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.assistant_chat import AssistantChat
+from app.models.psa_connection import PsaConnection
+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:
+ """Return onboarding checklist completion status for the current user."""
+
+ # created_flow — user owns at least 1 tree
+ created_flow_q = await db.execute(
+ select(func.count()).select_from(Tree).where(Tree.author_id == current_user.id).limit(1)
+ )
+ created_flow = (created_flow_q.scalar() or 0) > 0
+
+ # ran_session — user has at least 1 session
+ ran_session_q = await db.execute(
+ select(func.count()).select_from(Session).where(Session.user_id == current_user.id).limit(1)
+ )
+ ran_session = (ran_session_q.scalar() or 0) > 0
+
+ # exported_session — user has at least 1 session with exported=True
+ exported_q = await db.execute(
+ select(func.count())
+ .select_from(Session)
+ .where(Session.user_id == current_user.id, Session.exported == True) # noqa: E712
+ .limit(1)
+ )
+ exported_session = (exported_q.scalar() or 0) > 0
+
+ # tried_ai_assistant — user has at least 1 assistant chat
+ ai_q = await db.execute(
+ select(func.count())
+ .select_from(AssistantChat)
+ .where(AssistantChat.user_id == current_user.id)
+ .limit(1)
+ )
+ tried_ai_assistant = (ai_q.scalar() or 0) > 0
+
+ # Team-dependent checks
+ is_team_user = current_user.team_id is not None
+
+ invited_teammate = False
+ connected_psa = False
+
+ if is_team_user:
+ # invited_teammate — team/account has more than 1 member
+ if current_user.account_id:
+ teammate_q = await db.execute(
+ select(func.count())
+ .select_from(User)
+ .where(
+ User.account_id == current_user.account_id,
+ User.deleted_at.is_(None),
+ )
+ )
+ invited_teammate = (teammate_q.scalar() or 0) > 1
+
+ # connected_psa — account has at least 1 PSA connection
+ if current_user.account_id:
+ psa_q = await db.execute(
+ select(func.count())
+ .select_from(PsaConnection)
+ .where(PsaConnection.account_id == current_user.account_id)
+ .limit(1)
+ )
+ connected_psa = (psa_q.scalar() or 0) > 0
+
+ return OnboardingStatus(
+ created_flow=created_flow,
+ ran_session=ran_session,
+ exported_session=exported_session,
+ tried_ai_assistant=tried_ai_assistant,
+ invited_teammate=invited_teammate,
+ connected_psa=connected_psa,
+ is_team_user=is_team_user,
+ dismissed=current_user.onboarding_dismissed,
+ )
+
+
+@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
+async def dismiss_onboarding(
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> OnboardingStatus:
+ """Dismiss the onboarding checklist for the current user."""
+ current_user.onboarding_dismissed = True
+ await db.commit()
+ await db.refresh(current_user)
+
+ # Return updated status (reuse the GET logic)
+ return await get_onboarding_status(db=db, current_user=current_user)
diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py
index e0e303a2..ea4375f1 100644
--- a/backend/app/api/endpoints/sessions.py
+++ b/backend/app/api/endpoints/sessions.py
@@ -391,18 +391,51 @@ async def export_session(
detail="You don't have access to this session"
)
+ # PDF export — separate path with binary response
+ if export_options.format == "pdf":
+ from app.services.export_service import generate_pdf_export
+ from fastapi.responses import Response
+ pdf_bytes = await generate_pdf_export(session, export_options, db)
+
+ if session.completed_at:
+ session.exported = True
+ await db.commit()
+
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'},
+ )
+
+ # Query supporting data for non-PDF formats
+ from app.models.supporting_data import SessionSupportingData
+ sd_result = await db.execute(
+ select(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session_id)
+ .order_by(SessionSupportingData.sort_order)
+ )
+ supporting_data_items = [
+ {
+ "label": sd.label,
+ "data_type": sd.data_type,
+ "content": sd.content,
+ "content_type": sd.content_type,
+ }
+ for sd in sd_result.scalars().all()
+ ]
+
# Generate export based on format
if export_options.format == "markdown":
- content = generate_markdown_export(session, export_options)
+ content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items)
media_type = "text/markdown"
elif export_options.format == "html":
- content = generate_html_export(session, export_options)
+ content = generate_html_export(session, export_options, supporting_data=supporting_data_items)
media_type = "text/html"
elif export_options.format == "psa":
- content = generate_psa_export(session, export_options)
+ content = generate_psa_export(session, export_options, supporting_data=supporting_data_items)
media_type = "text/plain"
else: # text
- content = generate_text_export(session, export_options)
+ content = generate_text_export(session, export_options, supporting_data=supporting_data_items)
media_type = "text/plain"
# Resolve variables in export output
diff --git a/backend/app/api/endpoints/supporting_data.py b/backend/app/api/endpoints/supporting_data.py
new file mode 100644
index 00000000..b7d0a33a
--- /dev/null
+++ b/backend/app/api/endpoints/supporting_data.py
@@ -0,0 +1,201 @@
+import base64
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.api.deps import get_current_active_user
+from app.models import User
+from app.models.session import Session
+from app.models.supporting_data import SessionSupportingData
+from app.schemas.supporting_data import (
+ SupportingDataCreate,
+ SupportingDataUpdate,
+ SupportingDataResponse,
+)
+
+router = APIRouter(prefix="/sessions", tags=["supporting-data"])
+
+MAX_ITEMS_PER_SESSION = 20
+MAX_TEXT_SNIPPET_CHARS = 50_000
+MAX_SCREENSHOT_RAW_BYTES = 2 * 1024 * 1024 # 2MB
+
+
+async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None:
+ """Verify user has access to the session (owner, team admin, or super admin)."""
+ 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")
+
+
+async def _get_session_or_404(session_id: UUID, db: AsyncSession) -> Session:
+ """Fetch session by ID or raise 404."""
+ result = await db.execute(select(Session).where(Session.id == session_id))
+ session = result.scalar_one_or_none()
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+ return session
+
+
+@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: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_active_user),
+):
+ """Add a supporting data item (text snippet or screenshot) to a session."""
+ session = await _get_session_or_404(session_id, db)
+ await _check_session_access(current_user, session, db)
+
+ # Check item limit
+ count_result = await db.execute(
+ select(func.count()).select_from(SessionSupportingData).where(
+ SessionSupportingData.session_id == session_id
+ )
+ )
+ current_count = count_result.scalar() or 0
+ if current_count >= MAX_ITEMS_PER_SESSION:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Maximum {MAX_ITEMS_PER_SESSION} supporting data items per session",
+ )
+
+ # Validate content size based on type
+ if data.data_type == "text_snippet":
+ if len(data.content) > MAX_TEXT_SNIPPET_CHARS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Text snippet exceeds maximum {MAX_TEXT_SNIPPET_CHARS} characters",
+ )
+ elif data.data_type == "screenshot":
+ try:
+ raw_bytes = base64.b64decode(data.content)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid base64 content for screenshot")
+ if len(raw_bytes) > MAX_SCREENSHOT_RAW_BYTES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Screenshot exceeds maximum {MAX_SCREENSHOT_RAW_BYTES // (1024 * 1024)}MB raw size",
+ )
+
+ # Auto-increment sort_order
+ max_order_result = await db.execute(
+ select(func.max(SessionSupportingData.sort_order)).where(
+ SessionSupportingData.session_id == session_id
+ )
+ )
+ max_order = max_order_result.scalar()
+ 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 item
+
+
+@router.get(
+ "/{session_id}/supporting-data",
+ response_model=list[SupportingDataResponse],
+)
+async def list_supporting_data(
+ session_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_active_user),
+):
+ """List all supporting data items for a session, ordered by sort_order."""
+ session = await _get_session_or_404(session_id, db)
+ 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)
+ )
+ return result.scalars().all()
+
+
+@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: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_active_user),
+):
+ """Update a supporting data item's label or content."""
+ session = await _get_session_or_404(session_id, db)
+ await _check_session_access(current_user, session, db)
+
+ result = await db.execute(
+ select(SessionSupportingData).where(
+ SessionSupportingData.id == item_id,
+ SessionSupportingData.session_id == session_id,
+ )
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ 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
+
+ await db.commit()
+ await db.refresh(item)
+
+ return item
+
+
+@router.delete(
+ "/{session_id}/supporting-data/{item_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def delete_supporting_data(
+ session_id: UUID,
+ item_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_active_user),
+):
+ """Remove a supporting data item from a session."""
+ session = await _get_session_or_404(session_id, db)
+ await _check_session_access(current_user, session, db)
+
+ result = await db.execute(
+ select(SessionSupportingData).where(
+ SessionSupportingData.id == item_id,
+ SessionSupportingData.session_id == session_id,
+ )
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(status_code=404, detail="Supporting data item not found")
+
+ await db.delete(item)
+ await db.commit()
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 3bc9afde..e857bd20 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -18,6 +18,9 @@ from app.api.endpoints import kb_accelerator
from app.api.endpoints import beta_signup
from app.api.endpoints import scripts
from app.api.endpoints import integrations
+from app.api.endpoints import onboarding
+from app.api.endpoints import branding
+from app.api.endpoints import supporting_data
api_router = APIRouter()
@@ -61,3 +64,6 @@ api_router.include_router(kb_accelerator.router)
api_router.include_router(beta_signup.router)
api_router.include_router(scripts.router)
api_router.include_router(integrations.router)
+api_router.include_router(onboarding.router)
+api_router.include_router(branding.router)
+api_router.include_router(supporting_data.router)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 1938cdf0..b7854b0b 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -39,6 +39,7 @@ from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from .psa_connection import PsaConnection
from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping
+from .supporting_data import SessionSupportingData
__all__ = [
"User",
@@ -92,4 +93,5 @@ __all__ = [
"PsaConnection",
"PsaPostLog",
"PsaMemberMapping",
+ "SessionSupportingData",
]
diff --git a/backend/app/models/session.py b/backend/app/models/session.py
index bbab74cf..c191572b 100644
--- a/backend/app/models/session.py
+++ b/backend/app/models/session.py
@@ -82,6 +82,7 @@ class Session(Base):
assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id])
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
+ supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order")
# PSA ticket link
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
diff --git a/backend/app/models/supporting_data.py b/backend/app/models/supporting_data.py
new file mode 100644
index 00000000..ea04cd91
--- /dev/null
+++ b/backend/app/models/supporting_data.py
@@ -0,0 +1,25 @@
+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")
diff --git a/backend/app/models/team.py b/backend/app/models/team.py
index 299c9cb9..098444dd 100644
--- a/backend/app/models/team.py
+++ b/backend/app/models/team.py
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
-from typing import TYPE_CHECKING
-from sqlalchemy import String, DateTime
+from typing import Optional, TYPE_CHECKING
+from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -23,6 +23,12 @@ class Team(Base):
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
+
+ # 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)
+
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index f0c3f3f6..c7d566d7 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
-from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint
+from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -73,6 +73,15 @@ class User(Base):
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC")
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
+
+ # 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)
+
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
diff --git a/backend/app/schemas/branding.py b/backend/app/schemas/branding.py
new file mode 100644
index 00000000..bd6baeaf
--- /dev/null
+++ b/backend/app/schemas/branding.py
@@ -0,0 +1,14 @@
+from typing import Optional
+from pydantic import BaseModel
+
+
+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
diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py
new file mode 100644
index 00000000..d21647b5
--- /dev/null
+++ b/backend/app/schemas/onboarding.py
@@ -0,0 +1,12 @@
+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
diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py
index 58f4e2df..2a764711 100644
--- a/backend/app/schemas/session.py
+++ b/backend/app/schemas/session.py
@@ -106,7 +106,7 @@ class SessionResponse(BaseModel):
class SessionExport(BaseModel):
- format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
+ format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$")
include_timestamps: bool = True
include_tree_info: bool = True
# Phase A
diff --git a/backend/app/schemas/supporting_data.py b/backend/app/schemas/supporting_data.py
new file mode 100644
index 00000000..7469fab8
--- /dev/null
+++ b/backend/app/schemas/supporting_data.py
@@ -0,0 +1,30 @@
+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)
+ 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}
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 496c46e9..a48df65b 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -46,6 +46,7 @@ class UserResponse(UserBase):
role: str = "engineer"
account_id: Optional[UUID] = None
account_role: Optional[str] = None
+ team_id: Optional[UUID] = None
is_super_admin: bool = False
is_active: bool = True
must_change_password: bool = False
diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py
index 8654eb89..7d3d8091 100644
--- a/backend/app/services/export_service.py
+++ b/backend/app/services/export_service.py
@@ -1,11 +1,13 @@
"""
Session export generators for ResolutionFlow.
-Provides markdown, plain text, HTML, and PSA/ticket note export formatters
+Provides markdown, plain text, HTML, PDF, and PSA/ticket note export formatters
for troubleshooting sessions.
"""
import html
+import os
from datetime import datetime
+from pathlib import Path
from typing import Any
from app.models.session import Session
@@ -169,7 +171,7 @@ def _escape_markdown_table(value: str) -> str:
return value.replace("|", "\\|").replace("\n", " ")
-def generate_markdown_export(session: Session, options: SessionExport) -> str:
+def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
"""Generate markdown export."""
if _is_procedural_session(session):
return _generate_procedural_markdown(session, options)
@@ -259,6 +261,22 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
lines.append(f"*{decision['timestamp']}*")
lines.append("")
+ # Supporting Data
+ if supporting_data:
+ lines.append("---")
+ lines.append("")
+ lines.append("## Supporting Data")
+ lines.append("")
+ for sd in supporting_data:
+ lines.append(f"### {sd['label']}")
+ if sd["data_type"] == "text_snippet":
+ lines.append("```")
+ lines.append(sd["content"])
+ lines.append("```")
+ else:
+ lines.append(f"[Screenshot: {sd['label']}]")
+ lines.append("")
+
# Resolution / Outcome Notes
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
@@ -284,7 +302,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
return "\n".join(lines)
-def generate_text_export(session: Session, options: SessionExport) -> str:
+def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
"""Generate plain text export."""
if _is_procedural_session(session):
return _generate_procedural_text(session, options)
@@ -359,6 +377,19 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
if duration_seconds is not None:
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
+ # Supporting Data
+ if supporting_data:
+ lines.append("")
+ lines.append("SUPPORTING DATA")
+ lines.append("-" * 20)
+ for sd in supporting_data:
+ lines.append(f"\n {sd['label']}:")
+ if sd["data_type"] == "text_snippet":
+ for content_line in sd["content"].splitlines():
+ lines.append(f" {content_line}")
+ else:
+ lines.append(f" [Screenshot: {sd['label']}]")
+
# Resolution
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
@@ -380,7 +411,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
return "\n".join(lines)
-def generate_html_export(session: Session, options: SessionExport) -> str:
+def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
"""Generate HTML export."""
if _is_procedural_session(session):
return _generate_procedural_html(session, options)
@@ -470,6 +501,15 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'
{html.escape(str(decision["timestamp"]))}
')
html_parts.append('')
+ # Supporting Data
+ if supporting_data:
+ html_parts.append('Supporting Data ')
+ for sd in supporting_data:
+ if sd["data_type"] == "text_snippet":
+ html_parts.append(f'{html.escape(sd["label"])} {html.escape(sd["content"])} ')
+ else:
+ html_parts.append(f'{html.escape(sd["label"])} ')
+
# Resolution
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
@@ -488,7 +528,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
return "\n".join(html_parts)
-def generate_psa_export(session: Session, options: SessionExport) -> str:
+def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
if _is_procedural_session(session):
return _generate_procedural_psa(session, options)
@@ -559,6 +599,19 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append("No steps recorded.")
lines.append("")
+ # Supporting Data
+ if supporting_data:
+ lines.append("--- SUPPORTING DATA ---")
+ for sd in supporting_data:
+ if sd["data_type"] == "text_snippet":
+ lines.append(f"## {sd['label']}")
+ lines.append("```")
+ lines.append(sd["content"])
+ lines.append("```")
+ else:
+ lines.append(f"[Screenshot: {sd['label']}]")
+ lines.append("")
+
# Resolution — only for completed sessions
if session.completed_at:
lines.append("--- RESOLUTION ---")
@@ -904,3 +957,188 @@ def _generate_procedural_psa(session: Session, options: SessionExport) -> str:
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
return "\n".join(lines)
+
+
+async def generate_pdf_export(session: Session, options: SessionExport, db) -> bytes:
+ """Generate PDF export using WeasyPrint and a Jinja2 HTML template.
+
+ Args:
+ session: The session to export.
+ options: Export options (redaction_mode, max_step_index, etc.).
+ db: Async database session for loading supporting data and branding.
+
+ Returns:
+ PDF file contents as bytes.
+ """
+ from jinja2 import Environment, FileSystemLoader
+ import weasyprint
+ from sqlalchemy import select as sa_select
+
+ # Load Jinja2 template
+ template_dir = Path(__file__).resolve().parent.parent / "templates"
+ env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True)
+ template = env.get_template("export_pdf.html")
+
+ # Tree snapshot data
+ tree_snapshot = session.tree_snapshot or {}
+ flow_title = tree_snapshot.get("name", "Session Export")
+ tree_type = tree_snapshot.get("tree_type", "troubleshooting")
+ is_procedural = tree_type == "procedural"
+ report_type = "Procedure Report" if is_procedural else "Troubleshooting Report"
+
+ # Branding — check team first, then user (solo pros)
+ logo_data = None
+ logo_content_type = None
+ company_name = None
+
+ from app.models.user import User
+ user_result = await db.execute(
+ sa_select(User).where(User.id == session.user_id)
+ )
+ user = user_result.scalar_one_or_none()
+ engineer_name = user.name if user else "Unknown"
+
+ if user and user.team_id:
+ from app.models.team import Team
+ team_result = await db.execute(
+ sa_select(Team).where(Team.id == user.team_id)
+ )
+ team = team_result.scalar_one_or_none()
+ if team:
+ logo_data = team.logo_data
+ logo_content_type = team.logo_content_type
+ company_name = team.company_display_name or team.name
+ elif user:
+ logo_data = user.logo_data
+ logo_content_type = user.logo_content_type
+ company_name = user.company_display_name
+
+ has_custom_logo = bool(logo_data)
+
+ # Build steps list from decisions
+ decisions = session.decisions or []
+ if options.max_step_index is not None:
+ decisions = decisions[:options.max_step_index]
+
+ steps = []
+ for decision in decisions:
+ title = decision.get("question") or decision.get("action_performed", "Step")
+ answer = decision.get("answer", "")
+ notes = decision.get("notes", "")
+ duration_seconds = _get_step_duration_seconds(decision)
+ duration_str = _format_step_duration(duration_seconds) if duration_seconds is not None else None
+
+ if is_procedural:
+ completed = answer == "completed"
+ decision_text = "Completed" if completed else ("Skipped" if answer else "")
+ else:
+ decision_text = answer
+
+ steps.append({
+ "title": title,
+ "decision": decision_text,
+ "notes": notes,
+ "duration": duration_str,
+ })
+
+ # Query supporting data
+ from app.models.supporting_data import SessionSupportingData
+ sd_result = await db.execute(
+ sa_select(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session.id)
+ .order_by(SessionSupportingData.sort_order)
+ )
+ supporting_data_rows = sd_result.scalars().all()
+ supporting_data = [
+ {
+ "label": sd.label,
+ "data_type": sd.data_type,
+ "content": sd.content,
+ "content_type": sd.content_type,
+ }
+ for sd in supporting_data_rows
+ ]
+
+ # Calculate duration and format outcome
+ duration = _format_duration(session.started_at, session.completed_at)
+ session_date = session.started_at.strftime("%Y-%m-%d %H:%M")
+ outcome_label = _get_outcome_label(session) or ("In Progress" if not session.completed_at else "Completed")
+ outcome_raw = getattr(session, "outcome", None) or ""
+ outcome_class = f"outcome-{outcome_raw}" if outcome_raw else ""
+
+ # Build summary text
+ summary_text = ""
+ if options.include_summary:
+ summary_fields = _build_summary_fields(session)
+ parts = []
+ for label, value in summary_fields.items():
+ if value:
+ parts.append(f"{label.replace('_', ' ').title()}: {value}")
+ summary_text = "\n".join(parts)
+
+ # Resolution / outcome notes as summary fallback
+ if not summary_text:
+ _raw_notes = getattr(session, "outcome_notes", None)
+ if isinstance(_raw_notes, str) and _raw_notes.strip():
+ summary_text = _raw_notes.strip()
+
+ generated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
+
+ # Variable resolution
+ session_vars = getattr(session, "session_variables", None) or {}
+ if session_vars:
+ from app.services.variable_service import resolve_variables
+ flow_title = resolve_variables(flow_title, session_vars)
+ summary_text = resolve_variables(summary_text, session_vars)
+ for step in steps:
+ step["title"] = resolve_variables(step["title"], session_vars)
+ if step["decision"]:
+ step["decision"] = resolve_variables(step["decision"], session_vars)
+ if step["notes"]:
+ step["notes"] = resolve_variables(step["notes"], session_vars)
+ for sd in supporting_data:
+ if sd["data_type"] == "text_snippet":
+ sd["content"] = resolve_variables(sd["content"], session_vars)
+
+ # Apply redaction
+ if options.redaction_mode == "mask":
+ from app.services.redaction_service import apply_redaction_to_text
+ try:
+ flow_title, _ = apply_redaction_to_text(flow_title)
+ summary_text, _ = apply_redaction_to_text(summary_text)
+ for step in steps:
+ step["title"], _ = apply_redaction_to_text(step["title"])
+ if step["decision"]:
+ step["decision"], _ = apply_redaction_to_text(step["decision"])
+ if step["notes"]:
+ step["notes"], _ = apply_redaction_to_text(step["notes"])
+ for sd in supporting_data:
+ if sd["data_type"] == "text_snippet":
+ sd["content"], _ = apply_redaction_to_text(sd["content"])
+ except Exception:
+ pass # Redaction is best-effort for PDF
+
+ # Render HTML
+ html_content = template.render(
+ report_type=report_type,
+ flow_title=flow_title,
+ logo_data=logo_data,
+ logo_content_type=logo_content_type or "image/png",
+ 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_label,
+ summary=summary_text,
+ steps=steps,
+ supporting_data=supporting_data,
+ generated_at=generated_at,
+ )
+
+ # Convert to PDF
+ pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
+ return pdf_bytes
diff --git a/backend/app/templates/export_pdf.html b/backend/app/templates/export_pdf.html
new file mode 100644
index 00000000..8a7b7c0a
--- /dev/null
+++ b/backend/app/templates/export_pdf.html
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if summary %}
+
+
Summary
+
{{ summary }}
+
+ {% endif %}
+
+
+ {% if steps %}
+
+
Troubleshooting Path
+ {% for step in steps %}
+
+
+
{{ loop.index }}. {{ step.title }}
+ {% if step.decision %}
+
{{ step.decision }}
+ {% endif %}
+ {% if step.notes %}
+
{{ step.notes }}
+ {% endif %}
+ {% if step.duration %}
+
{{ step.duration }}
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% if supporting_data %}
+
+
Supporting Data
+ {% for item in supporting_data %}
+
+
{{ item.label }}
+
+ {% if item.data_type == "screenshot" %}
+
+ {% else %}
+
{{ item.content }}
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 189c871e..b884f6e7 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -42,6 +42,10 @@ voyageai>=0.3.0
# Monitoring
sentry-sdk[fastapi]>=2.54.0
+# PDF Export
+weasyprint>=62.0
+jinja2>=3.1.0
+
# Utilities
python-dotenv==1.0.1
croniter>=2.0.0
diff --git a/backend/tests/test_branding.py b/backend/tests/test_branding.py
new file mode 100644
index 00000000..c94c696a
--- /dev/null
+++ b/backend/tests/test_branding.py
@@ -0,0 +1,249 @@
+"""Tests for team branding endpoints (logo upload + company display name)."""
+
+import uuid
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from app.core.security import get_password_hash
+from app.models.team import Team
+from app.models.user import User
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+async def _create_team_with_admin(
+ test_db: AsyncSession,
+ client: AsyncClient,
+ *,
+ team_name: str = "Branding Test Team",
+) -> tuple[dict, str, Team]:
+ """Create a team + team admin user. Returns (auth_headers, team_id_str, team)."""
+ team = Team(name=team_name)
+ test_db.add(team)
+ await test_db.flush()
+
+ email = f"admin_{uuid.uuid4().hex[:8]}@test.com"
+ user = User(
+ email=email,
+ password_hash=get_password_hash("Password123!"),
+ name="Team Admin",
+ is_active=True,
+ team_id=team.id,
+ is_team_admin=True,
+ role="engineer",
+ )
+ test_db.add(user)
+ await test_db.commit()
+
+ resp = await client.post(
+ "/api/v1/auth/login/json",
+ json={"email": email, "password": "Password123!"},
+ )
+ assert resp.status_code == 200
+ token = resp.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+ return headers, str(team.id), team
+
+
+async def _create_team_member(
+ test_db: AsyncSession,
+ client: AsyncClient,
+ team: Team,
+ *,
+ is_team_admin: bool = False,
+) -> dict:
+ """Create a regular team member. Returns auth_headers."""
+ email = f"member_{uuid.uuid4().hex[:8]}@test.com"
+ user = User(
+ email=email,
+ password_hash=get_password_hash("Password123!"),
+ name="Team Member",
+ is_active=True,
+ team_id=team.id,
+ is_team_admin=is_team_admin,
+ role="engineer",
+ )
+ test_db.add(user)
+ await test_db.commit()
+
+ resp = await client.post(
+ "/api/v1/auth/login/json",
+ json={"email": email, "password": "Password123!"},
+ )
+ assert resp.status_code == 200
+ token = resp.json()["access_token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_get_branding_defaults(client: AsyncClient, test_db: AsyncSession):
+ """GET branding with no logo returns defaults (has_logo=False)."""
+ headers, team_id, _ = await _create_team_with_admin(test_db, client)
+
+ resp = await client.get(f"/api/v1/teams/{team_id}/branding", headers=headers)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["has_logo"] is False
+ assert data["company_display_name"] is None
+ assert data["logo_content_type"] is None
+
+
+@pytest.mark.asyncio
+async def test_upload_logo_with_company_name(client: AsyncClient, test_db: AsyncSession):
+ """PATCH with valid PNG logo + company name succeeds."""
+ headers, team_id, _ = await _create_team_with_admin(test_db, client)
+
+ # 1x1 transparent PNG (67 bytes)
+ png_bytes = (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
+ b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
+ b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
+ b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
+ )
+
+ resp = await client.patch(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=headers,
+ files={"logo": ("logo.png", png_bytes, "image/png")},
+ data={"company_display_name": "Acme MSP"},
+ )
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert data["has_logo"] is True
+ assert data["logo_content_type"] == "image/png"
+ assert data["company_display_name"] == "Acme MSP"
+
+
+@pytest.mark.asyncio
+async def test_upload_oversized_logo(client: AsyncClient, test_db: AsyncSession):
+ """PATCH with >2MB file returns 400."""
+ headers, team_id, _ = await _create_team_with_admin(test_db, client)
+
+ big_bytes = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte
+
+ resp = await client.patch(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=headers,
+ files={"logo": ("big.png", big_bytes, "image/png")},
+ )
+ assert resp.status_code == 400
+ assert "maximum size" in resp.json()["detail"].lower()
+
+
+@pytest.mark.asyncio
+async def test_upload_invalid_content_type(client: AsyncClient, test_db: AsyncSession):
+ """PATCH with application/pdf content type returns 400."""
+ headers, team_id, _ = await _create_team_with_admin(test_db, client)
+
+ resp = await client.patch(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=headers,
+ files={"logo": ("doc.pdf", b"%PDF-fake", "application/pdf")},
+ )
+ assert resp.status_code == 400
+ assert "content type" in resp.json()["detail"].lower()
+
+
+@pytest.mark.asyncio
+async def test_delete_logo(client: AsyncClient, test_db: AsyncSession):
+ """DELETE logo clears logo_data while keeping company_display_name."""
+ headers, team_id, _ = await _create_team_with_admin(test_db, client)
+
+ # Upload a logo + name first
+ png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
+ await client.patch(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=headers,
+ files={"logo": ("logo.png", png_bytes, "image/png")},
+ data={"company_display_name": "Keep This Name"},
+ )
+
+ # Delete logo
+ resp = await client.delete(
+ f"/api/v1/teams/{team_id}/branding/logo",
+ headers=headers,
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["has_logo"] is False
+ assert data["logo_content_type"] is None
+ assert data["company_display_name"] == "Keep This Name"
+
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_update(client: AsyncClient, test_db: AsyncSession):
+ """Regular team member (non-admin) cannot PATCH branding — returns 403."""
+ admin_headers, team_id, team = await _create_team_with_admin(test_db, client)
+ member_headers = await _create_team_member(test_db, client, team)
+
+ resp = await client.patch(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=member_headers,
+ data={"company_display_name": "Should Fail"},
+ )
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_delete_logo(client: AsyncClient, test_db: AsyncSession):
+ """Regular team member cannot DELETE logo — returns 403."""
+ admin_headers, team_id, team = await _create_team_with_admin(test_db, client)
+ member_headers = await _create_team_member(test_db, client, team)
+
+ resp = await client.delete(
+ f"/api/v1/teams/{team_id}/branding/logo",
+ headers=member_headers,
+ )
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_non_member_cannot_read(client: AsyncClient, test_db: AsyncSession):
+ """User from a different team cannot GET branding — returns 403."""
+ _, team_id, _ = await _create_team_with_admin(test_db, client, team_name="Team A")
+ other_headers, _, _ = await _create_team_with_admin(test_db, client, team_name="Team B")
+
+ resp = await client.get(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=other_headers,
+ )
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_member_can_read_branding(client: AsyncClient, test_db: AsyncSession):
+ """Regular team member CAN read branding."""
+ admin_headers, team_id, team = await _create_team_with_admin(test_db, client)
+ member_headers = await _create_team_member(test_db, client, team)
+
+ resp = await client.get(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=member_headers,
+ )
+ assert resp.status_code == 200
+ assert resp.json()["has_logo"] is False
+
+
+@pytest.mark.asyncio
+async def test_update_display_name_only(client: AsyncClient, test_db: AsyncSession):
+ """PATCH with only company_display_name (no logo) succeeds."""
+ headers, team_id, _ = await _create_team_with_admin(test_db, client)
+
+ resp = await client.patch(
+ f"/api/v1/teams/{team_id}/branding",
+ headers=headers,
+ data={"company_display_name": "Just A Name"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["company_display_name"] == "Just A Name"
+ assert data["has_logo"] is False
diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py
new file mode 100644
index 00000000..aa4f48d8
--- /dev/null
+++ b/backend/tests/test_onboarding.py
@@ -0,0 +1,72 @@
+"""Tests for onboarding status endpoints."""
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_onboarding_status_fresh_user(client, auth_headers):
+ """Fresh user should have all onboarding 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["invited_teammate"] is False
+ assert data["connected_psa"] is False
+ assert data["is_team_user"] is False
+ assert data["dismissed"] is False
+
+
+@pytest.mark.asyncio
+async def test_onboarding_dismiss(client, auth_headers):
+ """Dismiss endpoint should set dismissed to true."""
+ # Verify starts as false
+ response = await client.get(
+ "/api/v1/users/onboarding-status",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["dismissed"] is False
+
+ # Dismiss
+ response = await client.post(
+ "/api/v1/users/onboarding-status/dismiss",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["dismissed"] is True
+
+ # Verify persisted
+ response = await client.get(
+ "/api/v1/users/onboarding-status",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["dismissed"] is True
+
+
+@pytest.mark.asyncio
+async def test_onboarding_created_flow_after_tree_creation(client, auth_headers, test_tree):
+ """After creating a tree, created_flow should be true."""
+ response = await client.get(
+ "/api/v1/users/onboarding-status",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert response.json()["created_flow"] is True
+
+
+@pytest.mark.asyncio
+async def test_onboarding_requires_auth(client):
+ """Unauthenticated requests should be rejected."""
+ response = await client.get("/api/v1/users/onboarding-status")
+ assert response.status_code == 401
+
+ response = await client.post("/api/v1/users/onboarding-status/dismiss")
+ assert response.status_code == 401
diff --git a/backend/tests/test_pdf_export.py b/backend/tests/test_pdf_export.py
new file mode 100644
index 00000000..da2666dc
--- /dev/null
+++ b/backend/tests/test_pdf_export.py
@@ -0,0 +1,96 @@
+"""Tests for PDF export via WeasyPrint."""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+class TestPDFExport:
+ """Test PDF export endpoint."""
+
+ async def test_export_pdf_returns_pdf_content(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test that PDF export returns application/pdf content starting with %PDF."""
+ # Create a session
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"], "ticket_number": "PDF-001"},
+ headers=auth_headers,
+ )
+ assert create_response.status_code in (200, 201)
+ session_id = create_response.json()["id"]
+
+ # Add a decision so there's content
+ await client.put(
+ f"/api/v1/sessions/{session_id}",
+ json={
+ "decisions": [
+ {
+ "node_id": "root",
+ "question": "Is this a test?",
+ "answer": "Yes",
+ "notes": "PDF export test",
+ "timestamp": "2026-03-17T10:00:00Z",
+ }
+ ]
+ },
+ headers=auth_headers,
+ )
+
+ # Export as PDF
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "pdf", "include_tree_info": True},
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/pdf"
+ assert "session-export-" in response.headers.get("content-disposition", "")
+ # PDF files start with %PDF
+ assert response.content[:5] == b"%PDF-"
+
+ async def test_export_pdf_with_no_supporting_data(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Test PDF export works when session has no supporting data."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers,
+ )
+ assert create_response.status_code in (200, 201)
+ session_id = create_response.json()["id"]
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "pdf"},
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/pdf"
+ assert response.content[:5] == b"%PDF-"
+
+ async def test_existing_markdown_export_still_works(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """Verify markdown export is unaffected by PDF addition."""
+ create_response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"], "ticket_number": "MD-001"},
+ headers=auth_headers,
+ )
+ assert create_response.status_code in (200, 201)
+ session_id = create_response.json()["id"]
+
+ response = await client.post(
+ f"/api/v1/sessions/{session_id}/export",
+ json={"format": "markdown", "include_tree_info": True},
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 200
+ assert "text/markdown" in response.headers["content-type"]
+ assert "MD-001" in response.text
diff --git a/backend/tests/test_supporting_data.py b/backend/tests/test_supporting_data.py
new file mode 100644
index 00000000..dc5dd4db
--- /dev/null
+++ b/backend/tests/test_supporting_data.py
@@ -0,0 +1,217 @@
+import base64
+import pytest
+from httpx import AsyncClient
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+async def test_session(client: AsyncClient, auth_headers: dict, test_tree: dict):
+ """Create a test session from the test tree."""
+ response = await client.post(
+ "/api/v1/sessions",
+ json={"tree_id": test_tree["id"]},
+ headers=auth_headers,
+ )
+ assert response.status_code == 201, f"Failed to create session: {response.text}"
+ return response.json()
+
+
+# --- Create ---
+
+
+async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """Create a text snippet supporting data item — returns 201."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "Error log",
+ "data_type": "text_snippet",
+ "content": "NullReferenceException at line 42",
+ },
+ headers=auth_headers,
+ )
+ assert response.status_code == 201
+ data = response.json()
+ assert data["label"] == "Error log"
+ assert data["data_type"] == "text_snippet"
+ assert data["content"] == "NullReferenceException at line 42"
+ assert data["sort_order"] == 1
+ assert data["session_id"] == test_session["id"]
+
+
+async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """Create a screenshot supporting data item — returns 201."""
+ # Small valid base64 content (a tiny PNG-like payload)
+ small_content = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100).decode()
+ response = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "Error screenshot",
+ "data_type": "screenshot",
+ "content": small_content,
+ "content_type": "image/png",
+ },
+ headers=auth_headers,
+ )
+ assert response.status_code == 201
+ data = response.json()
+ assert data["label"] == "Error screenshot"
+ assert data["data_type"] == "screenshot"
+ assert data["content_type"] == "image/png"
+
+
+# --- List ---
+
+
+async def test_list_items_in_sort_order(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """List returns items ordered by sort_order."""
+ # Create 3 items
+ for i in range(3):
+ resp = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": f"Item {i}",
+ "data_type": "text_snippet",
+ "content": f"Content {i}",
+ },
+ headers=auth_headers,
+ )
+ assert resp.status_code == 201
+
+ response = await client.get(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ items = response.json()
+ assert len(items) == 3
+ assert items[0]["label"] == "Item 0"
+ assert items[1]["label"] == "Item 1"
+ assert items[2]["label"] == "Item 2"
+ assert items[0]["sort_order"] < items[1]["sort_order"] < items[2]["sort_order"]
+
+
+# --- Delete ---
+
+
+async def test_delete_item(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """Delete removes the item."""
+ create_resp = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "To delete",
+ "data_type": "text_snippet",
+ "content": "Will be removed",
+ },
+ headers=auth_headers,
+ )
+ assert create_resp.status_code == 201
+ item_id = create_resp.json()["id"]
+
+ delete_resp = await client.delete(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}",
+ headers=auth_headers,
+ )
+ assert delete_resp.status_code == 204
+
+ # Verify it's gone
+ list_resp = await client.get(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ headers=auth_headers,
+ )
+ assert list_resp.status_code == 200
+ assert len(list_resp.json()) == 0
+
+
+# --- Validation ---
+
+
+async def test_exceed_20_item_limit(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """Cannot exceed 20 items per session — returns 400."""
+ for i in range(20):
+ resp = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": f"Item {i}",
+ "data_type": "text_snippet",
+ "content": f"Content {i}",
+ },
+ headers=auth_headers,
+ )
+ assert resp.status_code == 201, f"Failed creating item {i}: {resp.text}"
+
+ # 21st should fail
+ response = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "One too many",
+ "data_type": "text_snippet",
+ "content": "Should fail",
+ },
+ headers=auth_headers,
+ )
+ assert response.status_code == 400
+ assert "20" in response.json()["detail"]
+
+
+async def test_screenshot_exceeds_2mb(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """Screenshot over 2MB raw (base64 decoded) — returns 400."""
+ # Create content that decodes to > 2MB
+ large_raw = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte
+ large_b64 = base64.b64encode(large_raw).decode()
+
+ response = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "Large screenshot",
+ "data_type": "screenshot",
+ "content": large_b64,
+ },
+ headers=auth_headers,
+ )
+ assert response.status_code == 400
+ assert "2MB" in response.json()["detail"]
+
+
+async def test_text_snippet_over_50k_chars(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """Text snippet over 50,000 characters — returns 400."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "Huge text",
+ "data_type": "text_snippet",
+ "content": "x" * 50_001,
+ },
+ headers=auth_headers,
+ )
+ assert response.status_code == 400
+ assert "50000" in response.json()["detail"]
+
+
+# --- Update ---
+
+
+async def test_patch_update_label(client: AsyncClient, auth_headers: dict, test_session: dict):
+ """PATCH to update label returns updated item."""
+ create_resp = await client.post(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data",
+ json={
+ "label": "Original label",
+ "data_type": "text_snippet",
+ "content": "Some content",
+ },
+ headers=auth_headers,
+ )
+ assert create_resp.status_code == 201
+ item_id = create_resp.json()["id"]
+
+ patch_resp = await client.patch(
+ f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}",
+ json={"label": "Updated label"},
+ headers=auth_headers,
+ )
+ assert patch_resp.status_code == 200
+ data = patch_resp.json()
+ assert data["label"] == "Updated label"
+ assert data["content"] == "Some content" # unchanged
diff --git a/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md b/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
new file mode 100644
index 00000000..df04162b
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
@@ -0,0 +1,2650 @@
+# Empty States, Onboarding & Professional Exports — Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add illustrative empty states, onboarding checklist, team branding, PDF exports, and supporting data capture to make ResolutionFlow feel polished and professional.
+
+**Architecture:** Bottom-up — backend foundation (migrations, endpoints, PDF generation) first, then frontend empty states + guides, then onboarding checklist, then PDF/supporting data UI. Four PRs, each independently shippable.
+
+**Tech Stack:** FastAPI, SQLAlchemy, Alembic, WeasyPrint, Jinja2, React 19, TypeScript, Tailwind CSS, Vitest, Playwright
+
+**Spec:** `docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md`
+
+---
+
+## Chunk 1: PR 1 — Backend Foundation
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Modify | `backend/app/models/user.py` | Add `onboarding_dismissed`, branding columns |
+| Modify | `backend/app/models/team.py` | Add branding columns |
+| Create | `backend/app/models/supporting_data.py` | SessionSupportingData model |
+| Modify | `backend/app/models/__init__.py` | Import new model |
+| Create | `backend/alembic/versions/*_add_onboarding_and_branding.py` | Migration: user + team columns |
+| Create | `backend/alembic/versions/*_add_supporting_data_table.py` | Migration: supporting data table |
+| Create | `backend/app/schemas/onboarding.py` | Onboarding status response schema |
+| Create | `backend/app/schemas/branding.py` | Branding request/response schemas |
+| Create | `backend/app/schemas/supporting_data.py` | Supporting data CRUD schemas |
+| Modify | `backend/app/schemas/session.py:109` | Add `pdf` to format pattern |
+| Create | `backend/app/api/endpoints/onboarding.py` | Onboarding status + dismiss endpoints |
+| Create | `backend/app/api/endpoints/branding.py` | Team branding CRUD endpoints |
+| Create | `backend/app/api/endpoints/supporting_data.py` | Supporting data CRUD endpoints |
+| Modify | `backend/app/api/router.py` | Register new endpoint routers |
+| Modify | `backend/app/services/export_service.py` | Add `generate_pdf_export()` function + supporting data in all formats |
+| Modify | `backend/app/api/endpoints/sessions.py:371-440` | Add PDF format branch |
+| Create | `backend/app/templates/export_pdf.html` | Jinja2 PDF template |
+| Modify | `backend/requirements.txt` | Add `weasyprint`, `jinja2` |
+| Modify | `backend/Dockerfile` | Add WeasyPrint system deps |
+| Create | `backend/tests/test_onboarding.py` | Onboarding endpoint tests |
+| Create | `backend/tests/test_branding.py` | Branding endpoint tests |
+| Create | `backend/tests/test_supporting_data.py` | Supporting data endpoint tests |
+| Create | `backend/tests/test_pdf_export.py` | PDF export tests |
+
+---
+
+### Task 0: Create Feature Branch
+
+- [ ] **Step 1: Create feature branch before any commits**
+
+```bash
+git checkout -b feat/backend-foundation-empty-states-exports
+```
+
+Per CLAUDE.md: "Always create feature branch BEFORE committing."
+
+---
+
+### Task 1: Database Migrations — User & Team Branding Columns
+
+**Files:**
+- Modify: `backend/app/models/user.py:75` (after `avatar_url`)
+- Modify: `backend/app/models/team.py:25` (after `name`)
+- Create: migration file
+
+**Note:** All model code uses `Mapped[]`/`mapped_column()` syntax to match the existing codebase pattern. Do NOT use legacy `Column()` style.
+
+- [ ] **Step 1: Add columns to User model**
+
+In `backend/app/models/user.py`, after `avatar_url` (line 75), add:
+
+```python
+ # Onboarding
+ onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
+
+ # Branding (solo pros without a team)
+ logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
+ company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+```
+
+Ensure `Optional` is imported from `typing` and `mapped_column`, `Mapped` from `sqlalchemy.orm` (should already be imported in the file).
+
+- [ ] **Step 2: Add columns to Team model**
+
+In `backend/app/models/team.py`, after `name` (line 25), add:
+
+```python
+ # Branding
+ logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
+ company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
+```
+
+- [ ] **Step 3: Generate migration**
+
+Run: `cd backend && alembic revision --autogenerate -m "add onboarding and branding columns"`
+
+- [ ] **Step 4: Review and apply migration**
+
+Review the generated migration file, then run:
+`cd backend && alembic upgrade head`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/models/user.py backend/app/models/team.py backend/alembic/versions/
+git commit -m "feat: add onboarding_dismissed and branding columns to user and team models"
+```
+
+---
+
+### Task 2: SessionSupportingData Model & Migration
+
+**Files:**
+- Create: `backend/app/models/supporting_data.py`
+- Modify: `backend/app/models/__init__.py`
+
+- [ ] **Step 1: Create the model**
+
+Create `backend/app/models/supporting_data.py` using `Mapped[]`/`mapped_column()` to match existing model patterns:
+
+```python
+import uuid
+from datetime import datetime, timezone
+from typing import Optional
+
+from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.core.database import Base
+
+
+class SessionSupportingData(Base):
+ __tablename__ = "session_supporting_data"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+ session_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("sessions.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ label: Mapped[str] = mapped_column(String(255), nullable=False)
+ data_type: Mapped[str] = mapped_column(
+ Enum("text_snippet", "screenshot", name="supporting_data_type"),
+ nullable=False,
+ )
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ nullable=False,
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ nullable=False,
+ )
+
+ session = relationship("Session", back_populates="supporting_data")
+```
+
+- [ ] **Step 2: Add relationship to Session model**
+
+In `backend/app/models/session.py`, add to the relationships section:
+
+```python
+ supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order")
+```
+
+- [ ] **Step 3: Register in models __init__.py**
+
+In `backend/app/models/__init__.py`, add the import:
+
+```python
+from app.models.supporting_data import SessionSupportingData
+```
+
+- [ ] **Step 4: Generate and apply migration**
+
+```bash
+cd backend && alembic revision --autogenerate -m "add session_supporting_data table"
+cd backend && alembic upgrade head
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/models/supporting_data.py backend/app/models/__init__.py backend/app/models/session.py backend/alembic/versions/
+git commit -m "feat: add session_supporting_data model and migration"
+```
+
+---
+
+### Task 3: Pydantic Schemas
+
+**Files:**
+- Create: `backend/app/schemas/onboarding.py`
+- Create: `backend/app/schemas/branding.py`
+- Create: `backend/app/schemas/supporting_data.py`
+- Modify: `backend/app/schemas/session.py:109`
+
+- [ ] **Step 1: Create onboarding schema**
+
+Create `backend/app/schemas/onboarding.py`:
+
+```python
+from pydantic import BaseModel
+
+
+class OnboardingStatus(BaseModel):
+ created_flow: bool
+ ran_session: bool
+ exported_session: bool
+ tried_ai_assistant: bool
+ invited_teammate: bool
+ connected_psa: bool
+ is_team_user: bool
+ dismissed: bool
+```
+
+- [ ] **Step 2: Create branding schemas**
+
+Create `backend/app/schemas/branding.py`:
+
+```python
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+
+class BrandingResponse(BaseModel):
+ company_display_name: Optional[str] = None
+ logo_content_type: Optional[str] = None
+ has_logo: bool = False
+
+
+class BrandingLogoResponse(BaseModel):
+ company_display_name: Optional[str] = None
+ logo_data: Optional[str] = None
+ logo_content_type: Optional[str] = None
+```
+
+- [ ] **Step 3: Create supporting data schemas**
+
+Create `backend/app/schemas/supporting_data.py`:
+
+```python
+from datetime import datetime
+from typing import Literal, Optional
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+
+class SupportingDataCreate(BaseModel):
+ label: str = Field(..., min_length=1, max_length=255)
+ data_type: Literal["text_snippet", "screenshot"]
+ content: str = Field(..., min_length=1, max_length=5_000_000) # ~2MB base64 for screenshots, 50K chars for text validated in endpoint
+ content_type: Optional[str] = Field(None, max_length=50)
+
+
+class SupportingDataUpdate(BaseModel):
+ label: Optional[str] = Field(None, min_length=1, max_length=255)
+ content: Optional[str] = Field(None, min_length=1)
+
+
+class SupportingDataResponse(BaseModel):
+ id: UUID
+ session_id: UUID
+ label: str
+ data_type: str
+ content: str
+ content_type: Optional[str]
+ sort_order: int
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
+```
+
+- [ ] **Step 4: Update SessionExport format pattern**
+
+In `backend/app/schemas/session.py`, line 109, change:
+
+```python
+ format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
+```
+
+to:
+
+```python
+ format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$")
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/schemas/onboarding.py backend/app/schemas/branding.py backend/app/schemas/supporting_data.py backend/app/schemas/session.py
+git commit -m "feat: add onboarding, branding, and supporting data schemas"
+```
+
+---
+
+### Task 4: Onboarding Endpoints
+
+**Files:**
+- Create: `backend/app/api/endpoints/onboarding.py`
+- Modify: `backend/app/api/router.py`
+
+**Important:** The existing `conftest.py` only provides `client`, `test_user`, `auth_headers`, and `test_tree` fixtures. Tests that need team admins, engineers, teams, or sessions must create them inline. Follow the pattern in existing test files — register a user via the API, create a team, etc. within each test or via a local fixture in the test file.
+
+- [ ] **Step 1: Write the onboarding status test**
+
+Create `backend/tests/test_onboarding.py`:
+
+```python
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_onboarding_status_fresh_user(client: AsyncClient, auth_headers: dict):
+ """Fresh user should have all items false."""
+ response = await client.get("/api/v1/users/onboarding-status", headers=auth_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["created_flow"] is False
+ assert data["ran_session"] is False
+ assert data["exported_session"] is False
+ assert data["tried_ai_assistant"] is False
+ assert data["dismissed"] is False
+
+
+@pytest.mark.asyncio
+async def test_onboarding_dismiss(client: AsyncClient, auth_headers: dict):
+ """Dismiss endpoint should set dismissed to true."""
+ response = await client.post("/api/v1/users/onboarding-status/dismiss", headers=auth_headers)
+ assert response.status_code == 200
+
+ response = await client.get("/api/v1/users/onboarding-status", headers=auth_headers)
+ assert response.json()["dismissed"] is True
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd backend && pytest tests/test_onboarding.py -v --override-ini="addopts="`
+Expected: FAIL — endpoint does not exist yet.
+
+- [ ] **Step 3: Create the onboarding endpoint**
+
+Create `backend/app/api/endpoints/onboarding.py`:
+
+```python
+from typing import Annotated
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user
+from app.core.database import get_db
+from app.models.session import Session
+from app.models.tree import Tree
+from app.models.user import User
+from app.schemas.onboarding import OnboardingStatus
+
+router = APIRouter(prefix="/users", tags=["onboarding"])
+
+
+@router.get("/onboarding-status", response_model=OnboardingStatus)
+async def get_onboarding_status(
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> OnboardingStatus:
+ user_id = current_user.id
+ team_id = current_user.team_id
+
+ # Check created_flow
+ flow_count = await db.scalar(
+ select(func.count()).select_from(Tree).where(Tree.created_by == user_id)
+ )
+
+ # Check ran_session
+ session_count = await db.scalar(
+ select(func.count()).select_from(Session).where(Session.user_id == user_id)
+ )
+
+ # Check exported_session
+ exported_count = await db.scalar(
+ select(func.count())
+ .select_from(Session)
+ .where(Session.user_id == user_id, Session.exported == True)
+ )
+
+ # Check tried_ai_assistant
+ from app.models.assistant_chat import AssistantChat
+ ai_count = await db.scalar(
+ select(func.count()).select_from(AssistantChat).where(AssistantChat.user_id == user_id)
+ )
+
+ # Check team-specific items
+ is_team_user = team_id is not None
+ invited_teammate = False
+ connected_psa = False
+
+ if is_team_user:
+ team_member_count = await db.scalar(
+ select(func.count()).select_from(User).where(User.team_id == team_id)
+ )
+ invited_teammate = (team_member_count or 0) > 1
+
+ from app.models.psa_connection import PsaConnection
+ psa_count = await db.scalar(
+ select(func.count())
+ .select_from(PsaConnection)
+ .where(PsaConnection.team_id == team_id)
+ )
+ connected_psa = (psa_count or 0) > 0
+
+ return OnboardingStatus(
+ created_flow=(flow_count or 0) > 0,
+ ran_session=(session_count or 0) > 0,
+ exported_session=(exported_count or 0) > 0,
+ tried_ai_assistant=(ai_count or 0) > 0,
+ invited_teammate=invited_teammate,
+ connected_psa=connected_psa,
+ is_team_user=is_team_user,
+ dismissed=current_user.onboarding_dismissed,
+ )
+
+
+@router.post("/onboarding-status/dismiss")
+async def dismiss_onboarding(
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+):
+ current_user.onboarding_dismissed = True
+ db.add(current_user)
+ await db.commit()
+ return {"status": "dismissed"}
+```
+
+- [ ] **Step 4: Register in router**
+
+In `backend/app/api/router.py`, add import and include:
+
+```python
+from app.api.endpoints import onboarding
+# ...
+api_router.include_router(onboarding.router)
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cd backend && pytest tests/test_onboarding.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/onboarding.py backend/app/api/router.py backend/tests/test_onboarding.py
+git commit -m "feat: add onboarding status and dismiss endpoints with tests"
+```
+
+---
+
+### Task 5: Branding Endpoints
+
+**Files:**
+- Create: `backend/app/api/endpoints/branding.py`
+- Create: `backend/tests/test_branding.py`
+- Modify: `backend/app/api/router.py`
+
+- [ ] **Step 1: Write branding tests**
+
+Create `backend/tests/test_branding.py`. **Note:** `team_admin_headers`, `engineer_headers`, and `test_team_id` don't exist in conftest.py. Each test must create a team and users inline via the API (register user, create team, log in to get headers). Follow existing test patterns in the codebase — read other test files first to see how they set up users/teams.
+
+The tests must cover:
+- Get branding with no logo returns defaults (has_logo=False)
+- Upload a valid 1x1 PNG logo with company name — verify has_logo=True
+- Upload oversized file (>2MB) — returns 400
+- Upload invalid content type (application/pdf) — returns 400
+- Delete logo — clears it
+- Non-admin cannot update branding — returns 403
+- Non-team-member cannot read branding — returns 403
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_branding.py -v --override-ini="addopts="`
+Expected: FAIL — endpoints don't exist.
+
+- [ ] **Step 3: Create branding endpoint**
+
+Create `backend/app/api/endpoints/branding.py`:
+
+```python
+import base64
+from typing import Annotated, Optional
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user
+from app.core.database import get_db
+from app.models.team import Team
+from app.models.user import User
+from app.schemas.branding import BrandingResponse
+
+router = APIRouter(prefix="/teams", tags=["branding"])
+
+ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/svg+xml"}
+MAX_LOGO_SIZE = 2 * 1024 * 1024 # 2MB
+
+
+async def _get_team_or_404(db: AsyncSession, team_id: UUID) -> Team:
+ team = await db.get(Team, team_id)
+ if not team:
+ raise HTTPException(status_code=404, detail="Team not found")
+ return team
+
+
+def _require_team_admin(user: User, team_id: UUID) -> None:
+ if user.is_super_admin:
+ return
+ if not user.is_team_admin or user.team_id != team_id:
+ raise HTTPException(status_code=403, detail="Team admin required")
+
+
+def _require_team_member(user: User, team_id: UUID) -> None:
+ if user.is_super_admin:
+ return
+ if user.team_id != team_id:
+ raise HTTPException(status_code=403, detail="Not a member of this team")
+
+
+@router.get("/{team_id}/branding", response_model=BrandingResponse)
+async def get_branding(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> BrandingResponse:
+ _require_team_member(current_user, team_id)
+ team = await _get_team_or_404(db, team_id)
+ return BrandingResponse(
+ company_display_name=team.company_display_name,
+ logo_content_type=team.logo_content_type,
+ has_logo=team.logo_data is not None,
+ )
+
+
+@router.patch("/{team_id}/branding", response_model=BrandingResponse)
+async def update_branding(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+ logo: Annotated[Optional[UploadFile], File()] = None,
+ company_display_name: Annotated[Optional[str], Form()] = None,
+) -> BrandingResponse:
+ _require_team_admin(current_user, team_id)
+ team = await _get_team_or_404(db, team_id)
+
+ if logo is not None:
+ if logo.content_type not in ALLOWED_CONTENT_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid content type. Allowed: {', '.join(ALLOWED_CONTENT_TYPES)}",
+ )
+ logo_bytes = await logo.read()
+ if len(logo_bytes) > MAX_LOGO_SIZE:
+ raise HTTPException(status_code=400, detail="Logo must be under 2MB")
+ team.logo_data = base64.b64encode(logo_bytes).decode("utf-8")
+ team.logo_content_type = logo.content_type
+
+ if company_display_name is not None:
+ team.company_display_name = company_display_name
+
+ db.add(team)
+ await db.commit()
+ await db.refresh(team)
+
+ return BrandingResponse(
+ company_display_name=team.company_display_name,
+ logo_content_type=team.logo_content_type,
+ has_logo=team.logo_data is not None,
+ )
+
+
+@router.delete("/{team_id}/branding/logo")
+async def delete_logo(
+ team_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+):
+ _require_team_admin(current_user, team_id)
+ team = await _get_team_or_404(db, team_id)
+ team.logo_data = None
+ team.logo_content_type = None
+ db.add(team)
+ await db.commit()
+ return {"status": "logo_deleted"}
+```
+
+- [ ] **Step 4: Register in router**
+
+In `backend/app/api/router.py`, add:
+
+```python
+from app.api.endpoints import branding
+api_router.include_router(branding.router)
+```
+
+- [ ] **Step 5: Run tests**
+
+Run: `cd backend && pytest tests/test_branding.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/branding.py backend/tests/test_branding.py backend/app/api/router.py
+git commit -m "feat: add team branding CRUD endpoints with tests"
+```
+
+---
+
+### Task 6: Supporting Data Endpoints
+
+**Files:**
+- Create: `backend/app/api/endpoints/supporting_data.py`
+- Create: `backend/tests/test_supporting_data.py`
+- Modify: `backend/app/api/router.py`
+
+- [ ] **Step 1: Write supporting data tests**
+
+Create `backend/tests/test_supporting_data.py`:
+
+```python
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Create a text snippet supporting data item."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={
+ "label": "Ping Output",
+ "data_type": "text_snippet",
+ "content": "PING 8.8.8.8: 64 bytes from 8.8.8.8: icmp_seq=1 ttl=117",
+ },
+ )
+ assert response.status_code == 201
+ data = response.json()
+ assert data["label"] == "Ping Output"
+ assert data["data_type"] == "text_snippet"
+
+
+@pytest.mark.asyncio
+async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Create a screenshot supporting data item."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={
+ "label": "Error Dialog",
+ "data_type": "screenshot",
+ "content": "iVBORw0KGgoAAAANSUhEUg==",
+ "content_type": "image/png",
+ },
+ )
+ assert response.status_code == 201
+ assert response.json()["data_type"] == "screenshot"
+
+
+@pytest.mark.asyncio
+async def test_list_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """List returns items in sort order."""
+ response = await client.get(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+ assert isinstance(response.json(), list)
+
+
+@pytest.mark.asyncio
+async def test_delete_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Delete removes item."""
+ # Create an item first
+ create_resp = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={"label": "To Delete", "data_type": "text_snippet", "content": "temp"},
+ )
+ item_id = create_resp.json()["id"]
+
+ response = await client.delete(
+ f"/api/v1/sessions/{test_session_id}/supporting-data/{item_id}",
+ headers=auth_headers,
+ )
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_exceed_max_items(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Reject when exceeding 20 items per session."""
+ for i in range(20):
+ await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={"label": f"Item {i}", "data_type": "text_snippet", "content": "data"},
+ )
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={"label": "Item 21", "data_type": "text_snippet", "content": "data"},
+ )
+ assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_exceed_screenshot_size(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Reject screenshots over 2MB."""
+ import base64
+ big_content = base64.b64encode(b"x" * (2 * 1024 * 1024 + 1)).decode()
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/supporting-data",
+ headers=auth_headers,
+ json={
+ "label": "Big Screenshot",
+ "data_type": "screenshot",
+ "content": big_content,
+ "content_type": "image/png",
+ },
+ )
+ assert response.status_code == 400
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_supporting_data.py -v --override-ini="addopts="`
+Expected: FAIL
+
+- [ ] **Step 3: Create supporting data endpoint**
+
+Create `backend/app/api/endpoints/supporting_data.py`:
+
+```python
+import base64
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_active_user
+from app.core.database import get_db
+from app.models.session import Session
+from app.models.supporting_data import SessionSupportingData
+from app.models.user import User
+from app.schemas.supporting_data import (
+ SupportingDataCreate,
+ SupportingDataResponse,
+ SupportingDataUpdate,
+)
+
+router = APIRouter(prefix="/sessions", tags=["supporting-data"])
+
+MAX_ITEMS_PER_SESSION = 20
+MAX_SCREENSHOT_SIZE = 2 * 1024 * 1024 # 2MB raw (before base64)
+
+
+async def _get_session_or_404(db: AsyncSession, session_id: UUID) -> Session:
+ session = await db.get(Session, session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+ return session
+
+
+async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None:
+ if user.is_super_admin:
+ return
+ if session.user_id == user.id:
+ return
+ # Team admins can only access sessions from their own team members
+ if user.is_team_admin and user.team_id is not None:
+ session_owner = await db.get(User, session.user_id)
+ if session_owner and session_owner.team_id == user.team_id:
+ return
+ raise HTTPException(status_code=403, detail="Access denied")
+
+
+@router.post(
+ "/{session_id}/supporting-data",
+ response_model=SupportingDataResponse,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_supporting_data(
+ session_id: UUID,
+ data: SupportingDataCreate,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> SupportingDataResponse:
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ # Check item limit
+ count = await db.scalar(
+ select(func.count())
+ .select_from(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session_id)
+ )
+ if (count or 0) >= MAX_ITEMS_PER_SESSION:
+ raise HTTPException(status_code=400, detail=f"Maximum {MAX_ITEMS_PER_SESSION} items per session")
+
+ # Check text snippet length
+ if data.data_type == "text_snippet" and len(data.content) > 50_000:
+ raise HTTPException(status_code=400, detail="Text snippet must be under 50,000 characters")
+
+ # Check screenshot size (base64 decode to get raw size)
+ if data.data_type == "screenshot":
+ try:
+ raw_bytes = base64.b64decode(data.content)
+ if len(raw_bytes) > MAX_SCREENSHOT_SIZE:
+ raise HTTPException(status_code=400, detail="Screenshot must be under 2MB")
+ except Exception as e:
+ if isinstance(e, HTTPException):
+ raise
+ raise HTTPException(status_code=400, detail="Invalid base64 content")
+
+ # Get next sort_order
+ max_order = await db.scalar(
+ select(func.max(SessionSupportingData.sort_order))
+ .where(SessionSupportingData.session_id == session_id)
+ )
+ next_order = (max_order or 0) + 1
+
+ item = SessionSupportingData(
+ session_id=session_id,
+ label=data.label,
+ data_type=data.data_type,
+ content=data.content,
+ content_type=data.content_type,
+ sort_order=next_order,
+ )
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+ return SupportingDataResponse.model_validate(item)
+
+
+@router.get("/{session_id}/supporting-data", response_model=list[SupportingDataResponse])
+async def list_supporting_data(
+ session_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> list[SupportingDataResponse]:
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ result = await db.execute(
+ select(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session_id)
+ .order_by(SessionSupportingData.sort_order)
+ )
+ items = result.scalars().all()
+ return [SupportingDataResponse.model_validate(item) for item in items]
+
+
+@router.patch("/{session_id}/supporting-data/{item_id}", response_model=SupportingDataResponse)
+async def update_supporting_data(
+ session_id: UUID,
+ item_id: UUID,
+ data: SupportingDataUpdate,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+) -> SupportingDataResponse:
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ item = await db.get(SessionSupportingData, item_id)
+ if not item or item.session_id != session_id:
+ raise HTTPException(status_code=404, detail="Supporting data item not found")
+
+ if data.label is not None:
+ item.label = data.label
+ if data.content is not None:
+ item.content = data.content
+
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+ return SupportingDataResponse.model_validate(item)
+
+
+@router.delete("/{session_id}/supporting-data/{item_id}")
+async def delete_supporting_data(
+ session_id: UUID,
+ item_id: UUID,
+ db: Annotated[AsyncSession, Depends(get_db)],
+ current_user: Annotated[User, Depends(get_current_active_user)],
+):
+ session = await _get_session_or_404(db, session_id)
+ await _check_session_access(current_user, session, db)
+
+ item = await db.get(SessionSupportingData, item_id)
+ if not item or item.session_id != session_id:
+ raise HTTPException(status_code=404, detail="Supporting data item not found")
+
+ await db.delete(item)
+ await db.commit()
+ return {"status": "deleted"}
+```
+
+- [ ] **Step 4: Register in router**
+
+In `backend/app/api/router.py`, add:
+
+```python
+from app.api.endpoints import supporting_data
+api_router.include_router(supporting_data.router)
+```
+
+- [ ] **Step 5: Run tests**
+
+Run: `cd backend && pytest tests/test_supporting_data.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/api/endpoints/supporting_data.py backend/tests/test_supporting_data.py backend/app/api/router.py
+git commit -m "feat: add supporting data CRUD endpoints with tests"
+```
+
+---
+
+### Task 7: PDF Export — WeasyPrint Setup & Template
+
+**Files:**
+- Modify: `backend/requirements.txt`
+- Modify: `backend/Dockerfile`
+- Create: `backend/app/templates/export_pdf.html`
+
+- [ ] **Step 1: Add WeasyPrint to requirements**
+
+Add to `backend/requirements.txt`:
+
+```
+weasyprint>=62.0
+jinja2>=3.1.0
+```
+
+- [ ] **Step 2: Install locally**
+
+```bash
+cd backend && pip install weasyprint jinja2
+```
+
+Note: WeasyPrint requires system libraries. On Ubuntu/Debian:
+```bash
+sudo apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev
+```
+
+- [ ] **Step 3: Update Dockerfile**
+
+In `backend/Dockerfile`, update the apt-get line to include WeasyPrint deps:
+
+Change:
+```dockerfile
+RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
+```
+
+To:
+```dockerfile
+RUN apt-get update && apt-get install -y gcc libpq-dev libpango1.0-dev libcairo2-dev libgdk-pixbuf2.0-dev libffi-dev && rm -rf /var/lib/apt/lists/*
+```
+
+- [ ] **Step 4: Create PDF HTML template**
+
+Create `backend/app/templates/export_pdf.html`:
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% if summary %}
+Summary
+{{ summary }}
+{% endif %}
+
+
+{% if steps %}
+Troubleshooting Path
+
+ {% for step in steps %}
+
+
+
{{ loop.index }}. {{ step.title }}
+ {% if step.decision %}
+
Decision: {{ step.decision }}
+ {% endif %}
+
+ {% endfor %}
+
+{% endif %}
+
+
+{% if supporting_data %}
+
+
Supporting Data
+ {% for item in supporting_data %}
+
+
{{ item.label }}
+ {% if item.data_type == "text_snippet" %}
+
{{ item.content }}
+ {% elif item.data_type == "screenshot" %}
+
+ {% endif %}
+
+ {% endfor %}
+
+{% endif %}
+
+
+
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/requirements.txt backend/Dockerfile backend/app/templates/
+git commit -m "feat: add WeasyPrint dependency, Dockerfile system deps, and PDF template"
+```
+
+---
+
+### Task 8: PDF Generation in Export Service + Endpoint
+
+**Files:**
+- Modify: `backend/app/services/export_service.py`
+- Modify: `backend/app/api/endpoints/sessions.py:371-440`
+- Create: `backend/tests/test_pdf_export.py`
+
+- [ ] **Step 1: Write PDF export tests**
+
+Create `backend/tests/test_pdf_export.py`:
+
+```python
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+async def test_export_pdf_format(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Export as PDF returns application/pdf content type."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/export",
+ headers=auth_headers,
+ json={"format": "pdf"},
+ )
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/pdf"
+ assert response.content[:4] == b"%PDF"
+
+
+@pytest.mark.asyncio
+async def test_export_pdf_no_supporting_data(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """PDF export works when session has no supporting data."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/export",
+ headers=auth_headers,
+ json={"format": "pdf"},
+ )
+ assert response.status_code == 200
+ assert response.content[:4] == b"%PDF"
+
+
+@pytest.mark.asyncio
+async def test_export_markdown_still_works(client: AsyncClient, auth_headers: dict, test_session_id: str):
+ """Existing markdown export still works after PDF addition."""
+ response = await client.post(
+ f"/api/v1/sessions/{test_session_id}/export",
+ headers=auth_headers,
+ json={"format": "markdown"},
+ )
+ assert response.status_code == 200
+ assert "text/plain" in response.headers["content-type"]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="`
+Expected: FAIL
+
+- [ ] **Step 3: Add generate_pdf_export function to export_service.py**
+
+**Important:** The export service uses standalone module-level functions (NOT a class). Add this as a standalone `async def` matching the pattern of `generate_markdown_export()`, `generate_text_export()`, etc.
+
+Add the following function to `backend/app/services/export_service.py`:
+
+```python
+async def generate_pdf_export(
+ session: "Session",
+ options: "SessionExport",
+ db: "AsyncSession",
+ ) -> bytes:
+ """Generate a branded PDF export using WeasyPrint."""
+ import weasyprint
+ from jinja2 import Environment, FileSystemLoader
+ from pathlib import Path
+ from datetime import datetime, timezone
+
+ from app.models.supporting_data import SessionSupportingData
+ from sqlalchemy import select
+
+ # Load template
+ template_dir = Path(__file__).parent.parent / "templates"
+ env = Environment(loader=FileSystemLoader(str(template_dir)))
+ template = env.get_template("export_pdf.html")
+
+ # Get tree snapshot data
+ tree_snapshot = session.tree_snapshot or {}
+ flow_title = tree_snapshot.get("title", "Untitled Flow")
+ tree_type = tree_snapshot.get("tree_type", "troubleshooting")
+
+ report_type_map = {
+ "troubleshooting": "Troubleshooting Report",
+ "procedural": "Project Report",
+ "maintenance": "Maintenance Report",
+ }
+ report_type = report_type_map.get(tree_type, "Session Report")
+
+ # Get branding
+ logo_data = None
+ logo_content_type = None
+ company_name = None
+ has_custom_logo = False
+
+ user = session.user
+ if user and user.team_id:
+ from app.models.team import Team
+ team = await db.get(Team, user.team_id)
+ if team:
+ if team.logo_data:
+ logo_data = team.logo_data
+ logo_content_type = team.logo_content_type
+ has_custom_logo = True
+ company_name = team.company_display_name or team.name
+ elif user:
+ if user.logo_data:
+ logo_data = user.logo_data
+ logo_content_type = user.logo_content_type
+ has_custom_logo = True
+ company_name = user.company_display_name
+
+ # Build steps from decisions
+ steps = []
+ for decision in (session.decisions or []):
+ steps.append({
+ "title": decision.get("title") or decision.get("question") or decision.get("description", "Step"),
+ "decision": decision.get("selected_option") or decision.get("answer", ""),
+ })
+
+ # Get supporting data
+ result = await db.execute(
+ select(SessionSupportingData)
+ .where(SessionSupportingData.session_id == session.id)
+ .order_by(SessionSupportingData.sort_order)
+ )
+ supporting_data_items = result.scalars().all()
+
+ # Calculate duration
+ duration = "—"
+ if session.started_at and session.completed_at:
+ delta = session.completed_at - session.started_at
+ minutes = int(delta.total_seconds() / 60)
+ if minutes < 60:
+ duration = f"{minutes} min"
+ else:
+ hours = minutes // 60
+ remaining = minutes % 60
+ duration = f"{hours}h {remaining}m"
+
+ # Outcome display
+ outcome = session.outcome or "In Progress"
+ outcome_class = "resolved" if outcome == "resolved" else "unresolved" if outcome == "unresolved" else "escalated"
+ outcome_display = f"✓ {outcome.title()}" if outcome == "resolved" else outcome.title()
+
+ # Session date
+ session_date = ""
+ if session.started_at:
+ session_date = session.started_at.strftime("%B %d, %Y")
+
+ # Summary
+ summary = session.outcome_notes or ""
+
+ # Engineer name
+ engineer_name = user.name if user else "Unknown"
+
+ # Generated timestamp
+ generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC")
+
+ # Render HTML
+ html_content = template.render(
+ report_type=report_type,
+ flow_title=flow_title,
+ logo_data=logo_data,
+ logo_content_type=logo_content_type,
+ has_custom_logo=has_custom_logo,
+ company_name=company_name,
+ engineer_name=engineer_name,
+ client_name=session.client_name,
+ ticket_number=session.ticket_number,
+ session_date=session_date,
+ duration=duration,
+ outcome_class=outcome_class,
+ outcome_display=outcome_display,
+ summary=summary,
+ steps=steps,
+ supporting_data=supporting_data_items,
+ generated_at=generated_at,
+ )
+
+ # Generate PDF
+ pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
+ return pdf_bytes
+```
+
+- [ ] **Step 4: Update export endpoint for PDF format**
+
+In `backend/app/api/endpoints/sessions.py`, in the `export_session` function, add the PDF branch. After the format dispatch block (around line 395-406), add:
+
+```python
+ if export_options.format == "pdf":
+ from app.services.export_service import generate_pdf_export
+
+ pdf_bytes = await generate_pdf_export(session, export_options, db)
+
+ # Mark as exported if completed (same logic as other formats)
+ if session.completed_at and not session.exported:
+ session.exported = True
+ db.add(session)
+ await db.commit()
+
+ from fastapi.responses import Response
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'
+ },
+ )
+```
+
+Make sure this block runs before the existing format dispatch so it returns early for PDF.
+
+**Note:** Variable resolution and redaction are handled inside `generate_pdf_export()` before rendering — the function must call `resolve_variables()` on text content and `apply_redaction_to_text()` if `redaction_mode != "none"`, matching the pattern used by other export formats in the existing code.
+
+- [ ] **Step 5: Run tests**
+
+Run: `cd backend && pytest tests/test_pdf_export.py -v --override-ini="addopts="`
+Expected: PASS
+
+- [ ] **Step 6: Run full test suite to verify no regressions**
+
+Run: `cd backend && pytest --override-ini="addopts="`
+Expected: All existing tests still pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add backend/app/services/export_service.py backend/app/api/endpoints/sessions.py backend/tests/test_pdf_export.py
+git commit -m "feat: add PDF export generation via WeasyPrint with branded template"
+```
+
+---
+
+### Task 9: Supporting Data in Non-PDF Export Formats
+
+**Files:**
+- Modify: `backend/app/services/export_service.py`
+
+The spec requires supporting data to be included in ALL export formats, not just PDF.
+
+- [ ] **Step 1: Add supporting data to generate_markdown_export**
+
+After the existing decisions/steps section, add a "## Supporting Data" section that renders each item:
+- Text snippets: labeled fenced code blocks
+- Screenshots: `[Screenshot: {label}]` placeholder (base64 images don't work in plain markdown)
+
+- [ ] **Step 2: Add supporting data to generate_text_export**
+
+After the steps section, add a "SUPPORTING DATA" section:
+- Text snippets: labeled indented blocks
+- Screenshots: `[Screenshot: {label}]` placeholder
+
+- [ ] **Step 3: Add supporting data to generate_html_export**
+
+After the steps section, add a "Supporting Data" section:
+- Text snippets: `` blocks with labels
+- Screenshots: ` ` tags with base64 src
+
+- [ ] **Step 4: Add supporting data to generate_psa_export**
+
+After the steps section, add a "Supporting Data" section:
+- Text snippets: labeled code blocks (markdown format for CW notes)
+- Screenshots: `[Screenshot: {label}]` placeholder
+
+**Note:** All four functions need to accept `db: AsyncSession` as a parameter (or the supporting data items as a pre-fetched list) to load the session's supporting data. Read the existing function signatures and follow the established pattern.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/export_service.py
+git commit -m "feat: include supporting data in all export formats"
+```
+
+---
+
+### Task 10: PR 1 Final — Integration Test Run & PR
+
+- [ ] **Step 1: Run full backend test suite**
+
+```bash
+cd backend && pytest --override-ini="addopts=" -v
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 2: Push feature branch and create PR**
+
+```bash
+git push -u origin feat/backend-foundation-empty-states-exports
+```
+
+Create PR with title: "feat: backend foundation for empty states, onboarding, and exports"
+
+---
+
+## Chunk 2: PR 2 — Empty States + Guides
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Modify | `frontend/src/components/common/EmptyState.tsx` | Add illustration, learnMoreLink props |
+| Create | `frontend/src/components/common/EmptyStateIllustrations.tsx` | SVG illustrations for each page |
+| Modify | `frontend/src/pages/TreeLibraryPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/MyAnalyticsPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/TeamAnalyticsPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/SessionHistoryPage.tsx` | Upgraded empty state |
+| Modify | `frontend/src/pages/StepLibraryPage.tsx` | Add empty state |
+| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` | Add empty state |
+| Modify | `frontend/src/pages/MySharesPage.tsx` | Upgraded empty state |
+| Modify | relevant integrations page | Add empty state |
+| Create | `frontend/src/pages/guides/GuidePage.tsx` | Guide route wrapper |
+| Create | `frontend/src/pages/guides/CreatingFlowsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/UnderstandingAnalyticsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/RunningSessionsGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/PsaSetupGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/StepLibraryGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/ScriptTemplatesGuide.tsx` | Guide content |
+| Create | `frontend/src/pages/guides/SharingSessionsGuide.tsx` | Guide content |
+| Modify | `frontend/src/router.tsx` | Add `/guides/:slug` route |
+
+---
+
+### Task 11: Upgrade EmptyState Component
+
+**Files:**
+- Modify: `frontend/src/components/common/EmptyState.tsx`
+- Create: `frontend/src/components/common/EmptyStateIllustrations.tsx`
+
+- [ ] **Step 1: Update EmptyState component**
+
+Rewrite `frontend/src/components/common/EmptyState.tsx` to support the new illustrative style:
+
+```tsx
+import { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+import { cn } from '@/lib/utils'
+
+interface EmptyStateProps {
+ icon?: ReactNode
+ illustration?: ReactNode
+ title: string
+ description?: string
+ action?: ReactNode
+ learnMoreLink?: string
+ learnMoreText?: string
+ className?: string
+}
+
+export function EmptyState({
+ icon,
+ illustration,
+ title,
+ description,
+ action,
+ learnMoreLink,
+ learnMoreText = 'Learn more',
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {illustration && (
+
+ {illustration}
+
+ )}
+ {!illustration && icon && (
+
{icon}
+ )}
+
{title}
+ {description && (
+
{description}
+ )}
+ {action &&
{action}
}
+ {learnMoreLink && (
+
+ {learnMoreText} →
+
+ )}
+
+ )
+}
+```
+
+- [ ] **Step 2: Create illustrations file**
+
+Create `frontend/src/components/common/EmptyStateIllustrations.tsx` with SVG illustrations for each page. Each illustration is a simple 80x60 SVG using brand colors:
+
+```tsx
+export function FlowIllustration() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function AnalyticsIllustration() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export function SessionIllustration() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function IntegrationIllustration() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function StepLibraryIllustration() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ScriptIllustration() {
+ return (
+
+
+
+
+
+
+ >
+
+ )
+}
+
+export function ShareIllustration() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/common/EmptyState.tsx frontend/src/components/common/EmptyStateIllustrations.tsx
+git commit -m "feat: upgrade EmptyState component with illustration and learn more support"
+```
+
+---
+
+### Task 12: Roll Out Empty States Across Pages
+
+**Files:** 8 page files (see file structure above)
+
+- [ ] **Step 1: Update each page's empty state**
+
+For each page, update the empty state usage to include the new props (illustration, description, CTA, learnMoreLink). The specific edits depend on how each page currently renders its empty state — read each file and update the `` usage.
+
+**Pattern for each page:**
+
+```tsx
+import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
+
+// In the render:
+ }
+ title="Build your first troubleshooting flow"
+ description="Flows guide your team through proven resolution paths, capturing every decision along the way."
+ action={Create a Flow }
+ learnMoreLink="/guides/creating-flows"
+/>
+```
+
+Apply the correct illustration, title, description, CTA, and guide link from the spec table for each of the 8 pages.
+
+- [ ] **Step 2: Add empty states to pages that don't have them**
+
+For StepLibraryPage, ScriptLibraryPage, and the integrations page — add the `EmptyState` component where no data exists. Read each file first to understand the current rendering logic.
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds with no TypeScript errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/pages/
+git commit -m "feat: roll out illustrative empty states across 8 pages"
+```
+
+---
+
+### Task 13: Create Guide Pages & Route
+
+**Files:**
+- Create: 7 guide component files in `frontend/src/pages/guides/`
+- Create: `frontend/src/pages/guides/GuidePage.tsx`
+- Modify: `frontend/src/router.tsx`
+
+- [ ] **Step 1: Create guide page wrapper**
+
+Create `frontend/src/pages/guides/GuidePage.tsx`:
+
+```tsx
+import { useParams, Link } from 'react-router-dom'
+import { ChevronRight } from 'lucide-react'
+import { EmptyState } from '@/components/common/EmptyState'
+import CreatingFlowsGuide from './CreatingFlowsGuide'
+import UnderstandingAnalyticsGuide from './UnderstandingAnalyticsGuide'
+import RunningSessionsGuide from './RunningSessionsGuide'
+import PsaSetupGuide from './PsaSetupGuide'
+import StepLibraryGuide from './StepLibraryGuide'
+import ScriptTemplatesGuide from './ScriptTemplatesGuide'
+import SharingSessionsGuide from './SharingSessionsGuide'
+
+const guides: Record = {
+ 'creating-flows': { title: 'Creating Flows', component: CreatingFlowsGuide },
+ 'understanding-analytics': { title: 'Understanding Analytics', component: UnderstandingAnalyticsGuide },
+ 'running-sessions': { title: 'Running Sessions', component: RunningSessionsGuide },
+ 'psa-setup': { title: 'Connecting Your PSA', component: PsaSetupGuide },
+ 'step-library': { title: 'Using the Step Library', component: StepLibraryGuide },
+ 'script-templates': { title: 'Script Templates', component: ScriptTemplatesGuide },
+ 'sharing-sessions': { title: 'Sharing Sessions', component: SharingSessionsGuide },
+}
+
+export default function GuidePage() {
+ const { slug } = useParams<{ slug: string }>()
+ const guide = slug ? guides[slug] : undefined
+
+ if (!guide) {
+ return (
+
+ Back to Dashboard
+
+ }
+ />
+ )
+ }
+
+ const GuideContent = guide.component
+
+ return (
+
+
+ Home
+
+ {guide.title}
+
+
+
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Create each guide component**
+
+Create 7 guide files in `frontend/src/pages/guides/`. Each follows the same pattern — a functional component with heading, paragraphs, and a CTA link. Example for `CreatingFlowsGuide.tsx`:
+
+```tsx
+import { Link } from 'react-router-dom'
+
+export default function CreatingFlowsGuide() {
+ return (
+
+ Creating Flows
+
+ Flows are the core of ResolutionFlow — structured troubleshooting paths that guide your team
+ through proven resolution steps.
+
+
+ Flow Types
+
+ Troubleshooting — Decision trees that branch based on what the engineer finds at each step.
+ Projects — Step-by-step procedural guides for installations, migrations, and setups.
+ Maintenance — Recurring check sequences you can schedule and run in batches.
+
+
+ Creating a Flow Manually
+
+ Click "Create a Flow" from the Flow Library, choose your flow type, and start building
+ in the visual editor. Add decision nodes, connect paths, and define outcomes.
+
+
+ Using AI to Generate Flows
+
+ Describe your troubleshooting scenario in plain language and the AI assistant will generate
+ a complete flow structure. You can then refine it in the editor.
+
+
+
+
+ Go to Flow Library →
+
+
+
+ )
+}
+```
+
+Create the remaining 6 guides following the same pattern, tailored to their topic per the spec table. Keep each 300-600 words.
+
+- [ ] **Step 3: Add route to router.tsx**
+
+In `frontend/src/router.tsx`, add the guide route inside the protected children:
+
+```tsx
+{ path: 'guides/:slug', element: page(GuidePage) }
+```
+
+Add the lazy import at the top:
+```tsx
+const GuidePage = lazy(() => import('./pages/guides/GuidePage'))
+```
+
+- [ ] **Step 4: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/pages/guides/ frontend/src/router.tsx
+git commit -m "feat: add 7 in-app user guides with /guides/:slug route"
+```
+
+---
+
+### Task 14: EmptyState Vitest Tests
+
+**Files:**
+- Create: `frontend/src/components/common/__tests__/EmptyState.test.tsx`
+
+- [ ] **Step 1: Write tests**
+
+Create `frontend/src/components/common/__tests__/EmptyState.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { EmptyState } from '../EmptyState'
+import { FlowIllustration } from '../EmptyStateIllustrations'
+
+const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe('EmptyState', () => {
+ it('renders title and description', () => {
+ render(
+ ,
+ { wrapper }
+ )
+ expect(screen.getByText('No data')).toBeInTheDocument()
+ expect(screen.getByText('Nothing here yet')).toBeInTheDocument()
+ })
+
+ it('renders illustration when provided', () => {
+ render(
+ } />,
+ { wrapper }
+ )
+ expect(document.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('renders action button', () => {
+ render(
+ Do Thing} />,
+ { wrapper }
+ )
+ expect(screen.getByText('Do Thing')).toBeInTheDocument()
+ })
+
+ it('renders learn more link', () => {
+ render(
+ ,
+ { wrapper }
+ )
+ const link = screen.getByText('Learn more →')
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/guides/test')
+ })
+
+ it('renders without optional props', () => {
+ render( , { wrapper })
+ expect(screen.getByText('Just a title')).toBeInTheDocument()
+ expect(screen.queryByText('Learn more →')).not.toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 2: Run tests**
+
+Run: `cd frontend && npx vitest run src/components/common/__tests__/EmptyState.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/src/components/common/__tests__/EmptyState.test.tsx
+git commit -m "test: add EmptyState component Vitest tests"
+```
+
+---
+
+### Task 15: PR 2 — Build Verification & PR
+
+- [ ] **Step 1: Full frontend build**
+
+```bash
+cd frontend && npm run build
+```
+
+Expected: Clean build, no errors.
+
+- [ ] **Step 2: Create feature branch and PR**
+
+```bash
+git checkout -b feat/empty-states-and-guides
+git push -u origin feat/empty-states-and-guides
+```
+
+Create PR: "feat: illustrative empty states across 8 pages with in-app guides"
+
+---
+
+## Chunk 3: PR 3 — Onboarding Checklist
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Create | `frontend/src/components/dashboard/OnboardingChecklist.tsx` | Checklist widget component |
+| Create | `frontend/src/api/onboarding.ts` | API client for onboarding endpoints |
+| Modify | `frontend/src/pages/QuickStartPage.tsx` | Insert checklist widget |
+
+---
+
+### Task 16: Onboarding API Client
+
+**Files:**
+- Create: `frontend/src/api/onboarding.ts`
+
+- [ ] **Step 1: Create API client**
+
+Create `frontend/src/api/onboarding.ts`:
+
+```typescript
+import { apiClient } from './client'
+
+export interface OnboardingStatus {
+ created_flow: boolean
+ ran_session: boolean
+ exported_session: boolean
+ tried_ai_assistant: boolean
+ invited_teammate: boolean
+ connected_psa: boolean
+ is_team_user: boolean
+ dismissed: boolean
+}
+
+export async function getOnboardingStatus(): Promise {
+ const response = await apiClient.get('/users/onboarding-status')
+ return response.data
+}
+
+export async function dismissOnboarding(): Promise {
+ await apiClient.post('/users/onboarding-status/dismiss')
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add frontend/src/api/onboarding.ts
+git commit -m "feat: add onboarding status API client"
+```
+
+---
+
+### Task 17: OnboardingChecklist Component
+
+**Files:**
+- Create: `frontend/src/components/dashboard/OnboardingChecklist.tsx`
+
+- [ ] **Step 1: Create the component**
+
+Create `frontend/src/components/dashboard/OnboardingChecklist.tsx`:
+
+```tsx
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Check, X, ChevronRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { getOnboardingStatus, dismissOnboarding, OnboardingStatus } from '@/api/onboarding'
+
+interface ChecklistItem {
+ key: keyof OnboardingStatus
+ label: string
+ path: string
+}
+
+const SOLO_ITEMS: ChecklistItem[] = [
+ { key: 'created_flow', label: 'Create your first flow', path: '/trees' },
+ { key: 'ran_session', label: 'Run your first session', path: '/trees' },
+ { key: 'exported_session', label: 'Export a session', path: '/sessions' },
+ { key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' },
+]
+
+const TEAM_ITEMS: ChecklistItem[] = [
+ { key: 'created_flow', label: 'Create your first flow', path: '/trees' },
+ { key: 'invited_teammate', label: 'Invite a team member', path: '/account' },
+ { key: 'ran_session', label: 'Run your first session', path: '/trees' },
+ { key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' },
+ { key: 'exported_session', label: 'Export a session', path: '/sessions' },
+]
+
+export function OnboardingChecklist() {
+ const [status, setStatus] = useState(null)
+ const [dismissed, setDismissed] = useState(false)
+ const [allComplete, setAllComplete] = useState(false)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ getOnboardingStatus()
+ .then(setStatus)
+ .catch(() => {})
+ }, [])
+
+ if (!status || status.dismissed || dismissed) return null
+
+ const items = status.is_team_user ? TEAM_ITEMS : SOLO_ITEMS
+ const completedCount = items.filter((item) => status[item.key]).length
+ const totalCount = items.length
+ const isAllDone = completedCount === totalCount
+
+ // Show "all set" briefly then auto-hide after 2 seconds
+ useEffect(() => {
+ if (isAllDone) {
+ const timer = setTimeout(() => setAllComplete(true), 2000)
+ return () => clearTimeout(timer)
+ }
+ }, [isAllDone])
+
+ if (allComplete) return null
+
+ const handleDismiss = async () => {
+ setDismissed(true)
+ await dismissOnboarding().catch(() => {})
+ }
+
+ return (
+
+ {/* Progress bar */}
+
+
+
+
+
+ Getting Started
+
+
+ {isAllDone ? "You're all set!" : `${completedCount} of ${totalCount} complete`}
+
+
+
+
+
+
+
+
+ {items.map((item) => {
+ const isComplete = status[item.key]
+ return (
+
!isComplete && navigate(item.path)}
+ className={cn(
+ 'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors',
+ isComplete
+ ? 'text-muted-foreground cursor-default'
+ : 'text-foreground hover:bg-[rgba(255,255,255,0.04)]'
+ )}
+ >
+
+ {isComplete && }
+
+ {item.label}
+ {!isComplete && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Add to QuickStartPage**
+
+In `frontend/src/pages/QuickStartPage.tsx`, import and insert the checklist after the greeting section (after line ~279, before the calendar/stats section):
+
+```tsx
+import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'
+
+// In the render, after the greeting div:
+
+```
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/dashboard/OnboardingChecklist.tsx frontend/src/pages/QuickStartPage.tsx
+git commit -m "feat: add onboarding checklist widget to dashboard"
+```
+
+---
+
+### Task 18: PR 3 — Build & PR
+
+- [ ] **Step 1: Full frontend build**
+
+```bash
+cd frontend && npm run build
+```
+
+- [ ] **Step 2: Create branch and PR**
+
+```bash
+git checkout -b feat/onboarding-checklist
+git push -u origin feat/onboarding-checklist
+```
+
+Create PR: "feat: onboarding starter checklist on QuickStartPage"
+
+---
+
+## Chunk 4: PR 4 — PDF Export UI + Supporting Data UI + Branding Settings
+
+### File Structure
+
+| Action | File | Responsibility |
+|--------|------|---------------|
+| Create | `frontend/src/api/supportingData.ts` | Supporting data API client |
+| Create | `frontend/src/api/branding.ts` | Branding API client |
+| Create | `frontend/src/components/session/SupportingDataPanel.tsx` | Supporting data list + add modal |
+| Create | `frontend/src/components/session/AddSupportingDataModal.tsx` | Add text snippet / screenshot modal |
+| Modify | `frontend/src/components/session/ExportPreviewModal.tsx` | PDF format + download-only mode |
+| Modify | `frontend/src/pages/SessionDetailPage.tsx` | Add PDF format option |
+| Create | `frontend/src/components/settings/BrandingSettings.tsx` | Logo upload + company name form |
+| Modify | relevant team settings page | Add branding section |
+| Modify | session runner pages | Add supporting data button |
+
+---
+
+### Task 19: Supporting Data & Branding API Clients
+
+**Files:**
+- Create: `frontend/src/api/supportingData.ts`
+- Create: `frontend/src/api/branding.ts`
+
+- [ ] **Step 1: Create supporting data API client**
+
+Create `frontend/src/api/supportingData.ts`:
+
+```typescript
+import { apiClient } from './client'
+
+export interface SupportingDataItem {
+ id: string
+ session_id: string
+ label: string
+ data_type: 'text_snippet' | 'screenshot'
+ content: string
+ content_type: string | null
+ sort_order: number
+ created_at: string
+ updated_at: string
+}
+
+export async function getSupportingData(sessionId: string): Promise {
+ const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`)
+ return response.data
+}
+
+export async function createSupportingData(
+ sessionId: string,
+ data: { label: string; data_type: string; content: string; content_type?: string }
+): Promise {
+ const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data)
+ return response.data
+}
+
+export async function updateSupportingData(
+ sessionId: string,
+ itemId: string,
+ data: { label?: string; content?: string }
+): Promise {
+ const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
+ return response.data
+}
+
+export async function deleteSupportingData(sessionId: string, itemId: string): Promise {
+ await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`)
+}
+```
+
+- [ ] **Step 2: Create branding API client**
+
+Create `frontend/src/api/branding.ts`:
+
+```typescript
+import { apiClient } from './client'
+
+export interface BrandingInfo {
+ company_display_name: string | null
+ logo_content_type: string | null
+ has_logo: boolean
+}
+
+export async function getBranding(teamId: string): Promise {
+ const response = await apiClient.get(`/teams/${teamId}/branding`)
+ return response.data
+}
+
+export async function updateBranding(
+ teamId: string,
+ formData: FormData
+): Promise {
+ const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+ return response.data
+}
+
+export async function deleteLogo(teamId: string): Promise {
+ await apiClient.delete(`/teams/${teamId}/branding/logo`)
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add frontend/src/api/supportingData.ts frontend/src/api/branding.ts
+git commit -m "feat: add supporting data and branding API clients"
+```
+
+---
+
+### Task 20: Supporting Data UI Components
+
+**Files:**
+- Create: `frontend/src/components/session/AddSupportingDataModal.tsx`
+- Create: `frontend/src/components/session/SupportingDataPanel.tsx`
+
+- [ ] **Step 1: Create the add modal**
+
+Create `frontend/src/components/session/AddSupportingDataModal.tsx` with:
+- Two tabs: "Text Snippet" and "Screenshot"
+- Text tab: label input + multiline textarea
+- Screenshot tab: label input + drag-and-drop zone + file picker + paste support
+- Submit button that calls `createSupportingData`
+- 2MB file size validation for screenshots
+- Uses glass-card modal styling per design system
+
+- [ ] **Step 2: Create the panel**
+
+Create `frontend/src/components/session/SupportingDataPanel.tsx` with:
+- Collapsible section showing supporting data items
+- Each item: type icon (Code2 for text, Image for screenshot) + label + preview + delete button
+- "Add Supporting Data" button that opens the modal
+- Fetches items via `getSupportingData` on mount
+- Uses the design system's glass card and muted foreground styles
+
+- [ ] **Step 3: Integrate into session runner pages**
+
+Read the troubleshooting session runner (`TreeNavigationPage`) and procedural session runner (`ProceduralNavigationPage`) to find the notes/scratchpad area. Add the `SupportingDataPanel` component near the existing notes input in both pages.
+
+- [ ] **Step 4: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/components/session/AddSupportingDataModal.tsx frontend/src/components/session/SupportingDataPanel.tsx frontend/src/pages/
+git commit -m "feat: add supporting data capture UI to session runners"
+```
+
+---
+
+### Task 21: PDF Export in ExportPreviewModal
+
+**Files:**
+- Modify: `frontend/src/components/session/ExportPreviewModal.tsx`
+- Modify: `frontend/src/pages/SessionDetailPage.tsx`
+
+- [ ] **Step 1: Update ExportPreviewModal**
+
+Read `frontend/src/components/session/ExportPreviewModal.tsx` and modify:
+
+1. Update format type to include `'pdf'`:
+```typescript
+format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
+```
+
+2. Add a `onDownloadPdf` callback prop for triggering the PDF download.
+
+3. When `format === 'pdf'`, hide the textarea and show a download-only UI:
+```tsx
+{format === 'pdf' ? (
+
+
PDF exports are generated server-side with your team's branding.
+
+ {loading ? 'Generating PDF...' : 'Download PDF'}
+
+
+) : (
+ // existing textarea + copy/download buttons
+)}
+```
+
+- [ ] **Step 2: Update SessionDetailPage**
+
+In `frontend/src/pages/SessionDetailPage.tsx`, add 'pdf' to the format options and implement the PDF download handler:
+
+```typescript
+const handleDownloadPdf = async () => {
+ setLoading(true)
+ try {
+ const response = await apiClient.post(
+ `/sessions/${sessionId}/export`,
+ { format: 'pdf' },
+ { responseType: 'blob' }
+ )
+ const url = URL.createObjectURL(response.data)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `session-export-${sessionId}.pdf`
+ a.click()
+ URL.revokeObjectURL(url)
+ } catch (error) {
+ console.error('PDF export failed:', error)
+ } finally {
+ setLoading(false)
+ }
+}
+```
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/session/ExportPreviewModal.tsx frontend/src/pages/SessionDetailPage.tsx
+git commit -m "feat: add PDF export option with download-only mode in export modal"
+```
+
+---
+
+### Task 22: Branding Settings UI
+
+**Files:**
+- Create: `frontend/src/components/settings/BrandingSettings.tsx`
+- Modify: relevant team settings page
+
+- [ ] **Step 1: Create BrandingSettings component**
+
+Create `frontend/src/components/settings/BrandingSettings.tsx` with:
+- Company display name text input
+- Logo upload area (drag-and-drop + file picker)
+- Logo preview showing current logo
+- Delete logo button
+- Save button that calls `updateBranding` with FormData
+- 2MB file size validation
+- Accepts PNG, JPG, SVG only
+- Preview of how logo appears on export (small mockup showing header layout)
+
+- [ ] **Step 2: Add to team settings page**
+
+Read the team settings page and add a "Branding" section with the `BrandingSettings` component. Only visible to team admins.
+
+- [ ] **Step 3: Verify frontend build**
+
+Run: `cd frontend && npm run build`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/components/settings/BrandingSettings.tsx frontend/src/pages/
+git commit -m "feat: add branding settings UI for team logo and company name"
+```
+
+---
+
+### Task 23: Playwright E2E Tests
+
+**Files:**
+- Create: `frontend/e2e/empty-states.spec.ts`
+- Create: `frontend/e2e/guides.spec.ts`
+
+- [ ] **Step 1: Write empty state Playwright test**
+
+Create `frontend/e2e/empty-states.spec.ts`:
+
+```typescript
+import { test, expect } from '@playwright/test'
+
+test.describe('Empty States', () => {
+ test('Flow Library shows empty state with CTA and Learn more', async ({ page }) => {
+ // Login as a fresh user (or user with no flows)
+ // Navigate to /trees
+ // Verify empty state illustration is visible
+ // Verify "Build your first troubleshooting flow" title
+ // Verify "Create a Flow" CTA button
+ // Verify "Learn more →" link pointing to /guides/creating-flows
+ await page.goto('/trees')
+ await expect(page.getByText('Build your first troubleshooting flow')).toBeVisible()
+ await expect(page.getByText('Learn more →')).toBeVisible()
+ })
+})
+```
+
+- [ ] **Step 2: Write guide page Playwright test**
+
+Create `frontend/e2e/guides.spec.ts`:
+
+```typescript
+import { test, expect } from '@playwright/test'
+
+test.describe('Guide Pages', () => {
+ test('Guide page loads from Learn more link', async ({ page }) => {
+ await page.goto('/guides/creating-flows')
+ await expect(page.getByText('Creating Flows')).toBeVisible()
+ })
+
+ test('Unknown guide slug shows not-found state', async ({ page }) => {
+ await page.goto('/guides/nonexistent')
+ await expect(page.getByText('Guide not found')).toBeVisible()
+ })
+})
+```
+
+**Note:** These tests need authentication setup. Use the existing Playwright auth fixtures from `frontend/e2e/fixtures/auth.ts`. Adapt the test user and login pattern to the project's existing Playwright setup. Read the existing e2e tests first.
+
+- [ ] **Step 3: Run Playwright tests**
+
+Run: `cd frontend && npx playwright test e2e/empty-states.spec.ts e2e/guides.spec.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/e2e/empty-states.spec.ts frontend/e2e/guides.spec.ts
+git commit -m "test: add Playwright e2e tests for empty states and guides"
+```
+
+---
+
+### Task 24: PR 4 — Final Build & PR
+
+- [ ] **Step 1: Full frontend build**
+
+```bash
+cd frontend && npm run build
+```
+
+- [ ] **Step 2: Full backend test suite**
+
+```bash
+cd backend && pytest --override-ini="addopts=" -v
+```
+
+- [ ] **Step 3: Push branch and create PR**
+
+```bash
+git push -u origin feat/pdf-export-supporting-data-branding-ui
+```
+
+Create PR: "feat: PDF export, supporting data capture, and branding settings UI"
+
+---
+
+## Post-Implementation
+
+- [ ] Update `CURRENT-STATE.md` — move empty states, onboarding, and exports to completed
+- [ ] Update `03-DEVELOPMENT-ROADMAP.md` — check off these items
+- [ ] Close related GitHub issues
+- [ ] Update `CLAUDE.md` if new patterns emerged (e.g., guide page pattern, WeasyPrint usage)
diff --git a/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
new file mode 100644
index 00000000..3a9b8c92
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-16-empty-states-onboarding-exports-design.md
@@ -0,0 +1,440 @@
+# Empty States, Onboarding & Professional Exports — Design Spec
+
+> **Date:** 2026-03-16
+> **Product:** ResolutionFlow
+> **Approach:** Bottom-up (foundation → empty states → onboarding → exports)
+
+---
+
+## Purpose
+
+Make ResolutionFlow feel polished and professional by eliminating dead-end empty pages, guiding new users through setup, and providing client-ready PDF exports that MSPs can hand directly to customers.
+
+---
+
+## Scope
+
+### In Scope
+
+1. Illustrative empty states across 8 pages with benefit-oriented copy and "Learn more" guide links
+2. Onboarding starter checklist widget on QuickStartPage (solo and team variants)
+3. Team branding settings (logo upload, company display name)
+4. PDF export via WeasyPrint with branded templates
+5. Supporting data capture during sessions (text snippets + screenshots)
+6. 7 in-app user guides linked from empty states
+7. Tests: backend integration, frontend unit, Playwright e2e
+
+### Out of Scope
+
+- Bring-your-own-storage (S3/Azure) for supporting data — future feature
+- Full file attachments beyond screenshots
+- Removing "Powered by ResolutionFlow" footer (potential premium tier)
+- Multi-browser Playwright matrix
+
+---
+
+## 1. Empty States
+
+### Component Upgrade
+
+Extend the existing `EmptyState.tsx` component to support the illustrative style:
+
+- **SVG illustration slot** — optional prop, renders a brand-colored line-art illustration above the title
+- **Benefit-oriented description** — explains what the page does and why it matters, not just "no data"
+- **Primary CTA button** — navigates to the action that populates the page
+- **Secondary "Learn more" link** — navigates to the relevant in-app guide
+
+### Pages (8 total)
+
+| Page | Title | Description | CTA | Guide Link |
+|------|-------|-------------|-----|------------|
+| Flow Library (no flows) | Build your first troubleshooting flow | Flows guide your team through proven resolution paths, capturing every decision along the way. | Create a Flow | `/guides/creating-flows` |
+| Flow Library (no filter results) | No flows match your filters | Try adjusting your search or filters. | Clear Filters | — |
+| Analytics (My/Team) | Track your troubleshooting performance | Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions. | Run Your First Session | `/guides/understanding-analytics` |
+| Session History (empty) | Your session history will appear here | Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review. | Start a Session | `/guides/running-sessions` |
+| Integrations | Connect your PSA for seamless workflows | Link ConnectWise or other PSA tools to pull ticket context into sessions and push documentation back automatically. | Connect Integration | `/guides/psa-setup` |
+| Step Library (empty) | Build a reusable step library | Save common troubleshooting steps once, reuse them across flows. Keeps your team consistent and saves build time. | Browse Steps | `/guides/step-library` |
+| Script Library (empty) | Automate with script templates | Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more. | Explore Templates | `/guides/script-templates` |
+| My Shares (empty) | Share session results with your team | Create shareable links to completed sessions for knowledge sharing and client communication. | View Sessions | `/guides/sharing-sessions` |
+
+### Illustrations
+
+Simple SVG line art using the cyan brand color palette (`#06b6d4` → `#22d3ee`). Each page gets a unique illustration relevant to its content. Lightweight — no complex animations or heavy graphics.
+
+### Visual Style
+
+- Container: centered content within the page's existing layout
+- Illustration: 60-80px height, `opacity: 0.4` → `0.7` range (needs to be visible on `#101114` dark background)
+- Title: `text-foreground`, `text-lg` (18px), `font-semibold` (matches existing `EmptyState.tsx`)
+- Description: `text-muted-foreground`, 13px, max-width ~400px for readability
+- CTA: `bg-gradient-brand` primary button style
+- Learn more: `text-muted-foreground` with `→` arrow, hover brightens
+
+---
+
+## 2. Onboarding Starter Checklist
+
+### Location
+
+Dismissible `.glass-card` widget on QuickStartPage, positioned below the greeting and above the stats/activity sections.
+
+### Visibility Rules
+
+- Shows for users who haven't dismissed it and haven't completed all items
+- Auto-hides with a brief "You're all set!" state once all items are checked, then disappears
+- Dismissible at any time via "×" button
+- Dismissed/completed state stored in a new `onboarding_dismissed` Boolean column on the `users` table (requires migration). Not using JSON — a simple column is clearer and queryable.
+- Never reappears once dismissed or completed
+
+### Completion Tracking
+
+No new database table. A single API endpoint queries existing data to determine completion status.
+
+**Endpoint:** `GET /api/v1/users/onboarding-status`
+
+**Response:**
+
+```json
+{
+ "created_flow": true,
+ "ran_session": false,
+ "exported_session": false,
+ "tried_ai_assistant": false,
+ "invited_teammate": false,
+ "connected_psa": false,
+ "is_team_user": true,
+ "dismissed": false
+}
+```
+
+**Completion queries:**
+
+| Item | Condition |
+|------|-----------|
+| `created_flow` | User owns at least 1 tree |
+| `ran_session` | User has at least 1 session |
+| `exported_session` | User has at least 1 session with `exported=True` |
+| `tried_ai_assistant` | User has at least 1 assistant chat |
+| `invited_teammate` | Team has more than 1 member |
+| `connected_psa` | Team has at least 1 PSA connection |
+
+**Dismiss endpoint:** `POST /api/v1/users/onboarding-status/dismiss` — sets `onboarding_dismissed=True` on the user record.
+
+### Checklist Variants
+
+**Solo pro (4 items):**
+
+1. Create your first flow → navigates to Flow Library
+2. Run your first session → navigates to Flow Library
+3. Export a session → navigates to Session History
+4. Try the AI assistant → navigates to AI Chat
+
+**Team admin (5 items):**
+
+1. Create your first flow → navigates to Flow Library
+2. Invite a team member → navigates to Team Settings
+3. Run your first session → navigates to Flow Library
+4. Connect a PSA integration → navigates to Integrations
+5. Export a session → navigates to Session History
+
+### Visual Design
+
+- `.glass-card` container with `border-radius: 16px`
+- Cyan progress bar at top showing completion (e.g., "2 of 5 complete")
+- Section label: "Getting Started" in `font-label text-[0.625rem] uppercase tracking-[0.1em]`
+- Each item: checkbox (auto-checked with cyan fill when complete) + label + subtle navigation arrow
+- Completed items: muted text with cyan checkmark
+- Uncompleted items: `text-foreground` with hover highlight, clickable to navigate
+
+---
+
+## 3. Team Branding & Logo Upload
+
+### Location
+
+New "Branding" section on the existing Team Settings page. Team admin only. Solo pros get a simpler version on their Account Settings page.
+
+### Fields
+
+- **Company logo** — image upload (PNG, JPG, or SVG, max 2MB)
+- **Company display name** — text field, falls back to team name if empty
+- **Logo preview** — shows how the logo will appear on exports
+
+### Backend
+
+**New columns on `teams` table:**
+
+- `logo_data` — Text, base64-encoded image data, nullable
+- `logo_content_type` — String (e.g., `image/png`), nullable
+- `company_display_name` — String, nullable (falls back to `team.name`)
+
+**Endpoints:**
+
+- `PATCH /api/v1/teams/{team_id}/branding` — upload logo (multipart form) + display name. Team admin only.
+- `GET /api/v1/teams/{team_id}/branding` — retrieve logo data + display name. Any team member.
+- `DELETE /api/v1/teams/{team_id}/branding/logo` — remove logo. Team admin only.
+
+**Validation:**
+
+- File size: max 2MB
+- Content type: `image/png`, `image/jpeg`, `image/svg+xml`
+- Solo pros: branding columns (`logo_data`, `logo_content_type`, `company_display_name`) added directly to the `users` table. Same schema as teams. Solo pros without a team still get branded exports.
+
+### Why Base64 in DB
+
+Logos are small (< 2MB raw, ~2.67MB base64-encoded) and there's one per team/user. The 2MB validation limit applies to the raw uploaded file size (before base64 encoding). Avoids S3/file storage dependency entirely. Easy to migrate to object storage later when BYOS is implemented for supporting data.
+
+---
+
+## 4. PDF Export via WeasyPrint
+
+### Backend
+
+**New dependency:** `weasyprint` in `requirements.txt`
+
+**System dependencies (required by WeasyPrint):**
+- `libpango1.0-dev`, `libcairo2-dev`, `libgdk-pixbuf2.0-dev`, `libffi-dev`
+- Must be added to the Railway Dockerfile via `apt-get install`
+- Local dev: install via system package manager (`apt-get` on Ubuntu/Debian)
+- CI: add to the e2e job's setup step
+
+**Export service changes:**
+
+- New `generate_pdf()` method in `export_service.py`
+- Renders a Jinja2 HTML template with session data + branding, then converts to PDF via WeasyPrint
+- Template location: `backend/app/templates/export_pdf.html`
+
+**Existing endpoint change:**
+
+- `POST /sessions/{session_id}/export` gains `format: "pdf"` option
+- Update `SessionExport` schema: change `format` field pattern from `^(text|markdown|html|psa)$` to `^(text|markdown|html|psa|pdf)$`
+- PDF format returns `Response(content=pdf_bytes, media_type="application/pdf")` with `Content-Disposition: attachment; filename="session-export-{id}.pdf"` header (different return type from the existing `PlainTextResponse` used by other formats — endpoint must branch on format)
+- Non-PDF formats continue returning `PlainTextResponse` as before
+
+### PDF Template Structure
+
+Matches the approved mockup layout:
+
+1. **Header** — Report type label (e.g., "Troubleshooting Report"), flow title, MSP logo or ResolutionFlow logo, company name
+2. **Metadata grid** — Engineer, Client, Ticket #, Date, Duration, Outcome (3×2 grid)
+3. **Summary** — AI-generated session summary (from existing feature)
+4. **Troubleshooting Path** — Visual timeline with cyan step dots, step titles, and decisions at each node. Final resolution step uses green dot.
+5. **Supporting Data** — Labeled text snippets (rendered as code blocks) + embedded screenshot images
+6. **Footer** — Generation timestamp (left) + "Powered by ResolutionFlow" (right)
+
+### CSS/Styling
+
+- White background, dark text (print-optimized)
+- Cyan accent color (`#06b6d4`) for section borders, timeline dots, and branding
+- `@page` rules for margins, header/footer positioning
+- Page break before Supporting Data section if content runs long
+- `break-inside: avoid` on individual supporting data items
+- JetBrains Mono for code/command output blocks
+
+### Frontend Changes
+
+- Add "PDF" to the format selector in `ExportPreviewModal`
+- PDF option triggers a direct file download (no textarea preview — PDFs aren't editable inline). The modal should switch to a "download-only" mode when PDF is selected: hide the textarea, show a download button with loading state. The format selector stays visible for switching between formats.
+- Show a loading spinner while PDF generates server-side
+- Existing formats (markdown, text, HTML, PSA) continue to work as before with the textarea preview
+
+### Branding Logic
+
+1. If team has a logo → use team logo + company display name in header, "Powered by ResolutionFlow" in footer
+2. If no team logo → use ResolutionFlow logo in header, no "Powered by" footer (it's already the primary brand)
+3. Solo pro with logo → same as team logo behavior
+
+---
+
+## 5. Supporting Data Capture
+
+### Database
+
+**New table: `session_supporting_data`**
+
+| Column | Type | Notes |
+|--------|------|-------|
+| `id` | UUID | Primary key |
+| `session_id` | UUID | FK to sessions |
+| `label` | String(255) | User-provided label (e.g., "Port Scan Output") |
+| `data_type` | Enum | `text_snippet` or `screenshot` |
+| `content` | Text | Raw text or base64-encoded image |
+| `content_type` | String(50) | Nullable. e.g., `image/png` for screenshots |
+| `sort_order` | Integer | Display ordering |
+| `created_at` | DateTime(timezone=True) | Auto-set |
+| `updated_at` | DateTime(timezone=True) | Auto-set, auto-update |
+
+### API Endpoints
+
+- `POST /api/v1/sessions/{session_id}/supporting-data` — add an item (label, type, content). Returns created item.
+- `GET /api/v1/sessions/{session_id}/supporting-data` — list all items for a session, ordered by `sort_order`.
+- `PATCH /api/v1/sessions/{session_id}/supporting-data/{id}` — update label or content.
+- `DELETE /api/v1/sessions/{session_id}/supporting-data/{id}` — remove an item.
+
+### Validation
+
+- Image size: max 2MB per screenshot (keeps DB growth manageable — at 20 items × 2.67MB base64 = ~53MB worst case per session)
+- Text snippet: max 50,000 characters
+- Max 20 items per session
+- Only the session owner or team admins can add/delete
+- Monitor DB size growth in production — if supporting data exceeds expectations, prioritize BYOS migration
+
+### Session Runner UI
+
+- **"Add Supporting Data" button** — positioned near the existing notes input in both troubleshooting and procedural session runners
+- **Add modal** with two tabs/options:
+ - **Text Snippet** — label input + multiline textarea
+ - **Screenshot** — label input + drag-and-drop zone / file picker + clipboard paste (`Ctrl+V`) support
+- **Supporting data list** — collapsible section below session notes showing added items:
+ - Each item: type icon (code bracket for text, image icon for screenshot) + label + preview (truncated text or thumbnail) + delete button
+ - No reordering in v1 — items display in creation order
+
+### Export Integration
+
+Supporting data is included in all export formats:
+
+| Format | Text Snippets | Screenshots |
+|--------|--------------|-------------|
+| Markdown | Labeled fenced code blocks | `` or `[Screenshot: label]` |
+| Plain Text | Labeled indented blocks | `[Screenshot: {label}]` placeholder |
+| HTML | `` blocks with labels | ` ` tags with base64 src |
+| PSA | Labeled code blocks (markdown) | `[Screenshot: {label}]` placeholder |
+| PDF | Styled code blocks matching mockup | Embedded images |
+
+---
+
+## 6. User Guides
+
+### Route
+
+`/guides/:slug` — new frontend route inside the authenticated app shell.
+
+### Implementation
+
+- Markdown files stored in `frontend/src/content/guides/`
+- Written as React components in `frontend/src/pages/guides/` (avoids `react-markdown` dependency for only 7 short pages). Each guide is a simple functional component using existing typography classes.
+- Displayed in a `.glass-card-static` container within the standard app shell layout
+- Simple breadcrumb: "Guides → {title}"
+- Images/illustrations stored in `frontend/public/guides/` and referenced via absolute paths
+- Unknown slugs show a "Guide not found" empty state with a link back to the dashboard
+
+### Guides (7)
+
+| Slug | Title | Content Covers |
+|------|-------|---------------|
+| `creating-flows` | Creating Flows | Manual flow creation, AI-assisted creation, flow types (troubleshooting, procedural, maintenance), basic editor usage |
+| `understanding-analytics` | Understanding Analytics | What each metric means, how data populates over time, team vs personal views |
+| `running-sessions` | Running Sessions | Starting a session, navigating decisions, adding notes, adding supporting data, completing and exporting |
+| `psa-setup` | Connecting Your PSA | ConnectWise setup walkthrough, where to find API credentials, what the integration enables |
+| `step-library` | Using the Step Library | Browsing shared steps, adding steps to flows, creating reusable steps |
+| `script-templates` | Script Templates | Browsing templates, using scripts during sessions, creating custom templates |
+| `sharing-sessions` | Sharing Sessions | Creating share links, public vs account-only access, revoking shares |
+
+### Guide Content Style
+
+- Concise — each guide should be 300-600 words
+- Task-oriented — "How to do X" structure, not reference documentation
+- Include relevant screenshots/illustrations where helpful
+- End with a CTA that links back to the relevant feature page
+
+---
+
+## 7. Testing
+
+### Backend Integration Tests (pytest)
+
+**Onboarding status:**
+- Returns correct booleans for a fresh user (all false)
+- Returns `created_flow: true` after user creates a tree
+- Returns `ran_session: true` after user starts a session
+- Returns correct `is_team_user` flag
+- Dismiss endpoint sets `dismissed: true`
+
+**Team branding:**
+- Upload logo — stores base64, returns success
+- Upload oversized file — returns 400
+- Upload invalid content type — returns 400
+- Retrieve branding — returns logo data + display name
+- Delete logo — clears logo data
+- Non-admin cannot update branding — returns 403
+
+**Supporting data:**
+- Create text snippet — stores and returns item
+- Create screenshot — stores base64 and returns item
+- List items — returns in sort order
+- Delete item — removes from DB
+- Exceed 20 item limit — returns 400
+- Exceed 2MB screenshot — returns 400
+- Non-owner cannot add to session — returns 403
+
+**PDF export:**
+- Generate PDF — returns valid PDF bytes with correct content type
+- PDF includes branding when team has logo
+- PDF uses ResolutionFlow defaults when no team logo
+- PDF includes supporting data items
+- PDF handles session with no supporting data gracefully
+
+### Frontend Unit Tests (Vitest)
+
+- `EmptyState` component — renders illustration, title, description, CTA, and learn more link with correct props
+- `EmptyState` without optional props — renders without illustration or learn more link
+- Onboarding checklist — renders correct items for solo vs team user
+- Onboarding checklist — completed items show cyan checkmark and muted style
+- Onboarding checklist — dismiss button calls dismiss endpoint
+- Export format selector — includes PDF option
+- Export modal — PDF selection triggers download behavior instead of textarea preview
+
+### Playwright E2E Tests
+
+- **Empty state flow** — log in as fresh user, navigate to Flow Library, verify empty state renders with CTA and "Learn more" link
+- **Onboarding checklist** — log in as fresh user, verify checklist visible on dashboard, create a flow, verify checklist item updates
+- **PDF export** — complete a session, navigate to session detail, select PDF format, verify download triggers
+- **Guide page** — click "Learn more" from an empty state, verify guide page loads with content
+
+These Playwright tests focus on happy paths only — one representative flow per feature area.
+
+---
+
+## Implementation Order (Bottom-Up)
+
+### PR 1: Backend Foundation
+- `onboarding_dismissed` column on `users` table + migration
+- Team branding columns (`logo_data`, `logo_content_type`, `company_display_name`) on `teams` table + migration
+- Solo pro branding columns on `users` table + migration (can combine with onboarding migration)
+- Branding CRUD endpoints
+- Supporting data table + migration
+- Supporting data CRUD endpoints (POST, GET, PATCH, DELETE)
+- Onboarding status endpoint + dismiss endpoint
+- WeasyPrint dependency + system deps in Dockerfile + PDF generation in export service
+- Update `SessionExport` schema format pattern to include `pdf`
+- Backend tests for all of the above
+
+### PR 2: Empty States + Guides
+- Upgrade `EmptyState.tsx` component
+- Roll out across 8 pages
+- Create 7 markdown guides
+- Add `/guides/:slug` route
+- Frontend unit tests for EmptyState
+
+### PR 3: Onboarding Checklist
+- QuickStartPage checklist widget
+- Solo vs team variant logic
+- Dismiss and auto-complete behavior
+- Frontend unit tests
+- Playwright test for checklist
+
+### PR 4: PDF Export + Supporting Data UI
+- Supporting data capture in session runner
+- PDF format option in ExportPreviewModal
+- Team branding section on Team Settings page
+- Playwright tests for export and empty states
+
+---
+
+## Future Considerations
+
+- **BYOS (Bring Your Own Storage):** Allow MSPs to configure their own S3/Azure blob storage for supporting data. Removes our storage burden and addresses data sovereignty.
+- **Premium branding tier:** Option to remove "Powered by ResolutionFlow" footer for higher-tier plans.
+- **Full file attachments:** Extend supporting data to accept arbitrary file types (logs, configs, CSVs) once object storage is in place.
+- **Export templates:** Let teams customize the PDF template layout, colors, and sections included.
+- **Onboarding expansion:** Feature tours, tooltips, and contextual help beyond the starter checklist.
diff --git a/frontend/src/api/branding.ts b/frontend/src/api/branding.ts
new file mode 100644
index 00000000..37387efb
--- /dev/null
+++ b/frontend/src/api/branding.ts
@@ -0,0 +1,23 @@
+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`)
+}
diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts
new file mode 100644
index 00000000..4f54e687
--- /dev/null
+++ b/frontend/src/api/onboarding.ts
@@ -0,0 +1,21 @@
+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')
+}
diff --git a/frontend/src/api/supportingData.ts b/frontend/src/api/supportingData.ts
new file mode 100644
index 00000000..0dc36546
--- /dev/null
+++ b/frontend/src/api/supportingData.ts
@@ -0,0 +1,39 @@
+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}`)
+}
diff --git a/frontend/src/components/common/EmptyState.tsx b/frontend/src/components/common/EmptyState.tsx
index 22e4a266..b917eb6b 100644
--- a/frontend/src/components/common/EmptyState.tsx
+++ b/frontend/src/components/common/EmptyState.tsx
@@ -1,23 +1,51 @@
import type { 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, title, description, action, className }: EmptyStateProps) {
+export function EmptyState({
+ icon,
+ illustration,
+ title,
+ description,
+ action,
+ learnMoreLink,
+ learnMoreText = 'Learn more',
+ className,
+}: EmptyStateProps) {
return (
- {icon &&
{icon}
}
+ {illustration && (
+
+ {illustration}
+
+ )}
+ {!illustration && icon && (
+
{icon}
+ )}
{title}
{description && (
-
{description}
+
{description}
)}
{action &&
{action}
}
+ {learnMoreLink && (
+
+ {learnMoreText} →
+
+ )}
)
}
diff --git a/frontend/src/components/common/EmptyStateIllustrations.tsx b/frontend/src/components/common/EmptyStateIllustrations.tsx
new file mode 100644
index 00000000..5ea17830
--- /dev/null
+++ b/frontend/src/components/common/EmptyStateIllustrations.tsx
@@ -0,0 +1,130 @@
+/**
+ * SVG illustrations for EmptyState components.
+ * Each uses the brand cyan palette (#06b6d4 / #22d3ee) at low opacity.
+ * ViewBox: 80x60, simple line art style.
+ */
+
+export function FlowIllustration() {
+ return (
+
+ {/* Root node */}
+
+ {/* Branches */}
+
+
+ {/* Left child */}
+
+ {/* Right child */}
+
+ {/* Leaf branches */}
+
+
+
+
+
+ )
+}
+
+export function AnalyticsIllustration() {
+ return (
+
+ {/* Bars */}
+
+
+
+
+ {/* Baseline */}
+
+
+ )
+}
+
+export function SessionIllustration() {
+ return (
+
+ {/* Card 1 */}
+
+
+
+ {/* Card 2 */}
+
+
+
+ {/* Card 3 */}
+
+
+
+
+ )
+}
+
+export function IntegrationIllustration() {
+ return (
+
+ {/* Left box */}
+
+
+
+ {/* Right box */}
+
+
+
+ {/* Dashed arrows */}
+
+
+ {/* Arrow tips */}
+
+
+
+ )
+}
+
+export function StepLibraryIllustration() {
+ return (
+
+ {/* List items */}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ScriptIllustration() {
+ return (
+
+ {/* Terminal window */}
+
+ {/* Title bar */}
+
+
+
+ {/* Code lines */}
+
+
+
+
+
+ )
+}
+
+export function ShareIllustration() {
+ return (
+
+ {/* Center node */}
+
+ {/* Top-right node */}
+
+ {/* Bottom-right node */}
+
+ {/* Connecting lines */}
+
+
+
+ )
+}
diff --git a/frontend/src/components/common/__tests__/EmptyState.test.tsx b/frontend/src/components/common/__tests__/EmptyState.test.tsx
new file mode 100644
index 00000000..83b81a3c
--- /dev/null
+++ b/frontend/src/components/common/__tests__/EmptyState.test.tsx
@@ -0,0 +1,92 @@
+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'
+
+function renderWithRouter(ui: React.ReactElement) {
+ return render({ui} )
+}
+
+describe('EmptyState', () => {
+ it('renders title and description', () => {
+ renderWithRouter(
+
+ )
+
+ expect(screen.getByText('No items found')).toBeInTheDocument()
+ expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument()
+ })
+
+ it('renders illustration when provided', () => {
+ const { container } = renderWithRouter(
+ }
+ />
+ )
+
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+
+ it('renders action button', () => {
+ renderWithRouter(
+ Create New}
+ />
+ )
+
+ expect(screen.getByRole('button', { name: 'Create New' })).toBeInTheDocument()
+ })
+
+ it('renders learn more link with correct href', () => {
+ renderWithRouter(
+
+ )
+
+ const link = screen.getByText(/Learn more/i)
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveAttribute('href', '/guides/creating-flows')
+ })
+
+ it('renders custom learn more text', () => {
+ renderWithRouter(
+
+ )
+
+ expect(screen.getByText(/View guide/i)).toBeInTheDocument()
+ })
+
+ it('renders without optional props', () => {
+ renderWithRouter( )
+
+ expect(screen.getByText('Just a title')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ })
+
+ it('prefers illustration over icon when both provided', () => {
+ const { container } = renderWithRouter(
+ icon}
+ illustration={ }
+ />
+ )
+
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/dashboard/OnboardingChecklist.tsx b/frontend/src/components/dashboard/OnboardingChecklist.tsx
new file mode 100644
index 00000000..f4552204
--- /dev/null
+++ b/frontend/src/components/dashboard/OnboardingChecklist.tsx
@@ -0,0 +1,160 @@
+import { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Check, X, ChevronRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding'
+import type { 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 navigate = useNavigate()
+ const [status, setStatus] = useState(null)
+ const [dismissed, setDismissed] = useState(false)
+ const [allComplete, setAllComplete] = useState(false)
+
+ useEffect(() => {
+ getOnboardingStatus()
+ .then(setStatus)
+ .catch(() => {
+ // Silently fail — don't show checklist if endpoint unavailable
+ })
+ }, [])
+
+ const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS
+ const completedCount = status
+ ? items.filter((item) => status[item.key]).length
+ : 0
+ const totalCount = items.length
+ const isAllDone = completedCount === totalCount && status !== null
+
+ useEffect(() => {
+ if (isAllDone) {
+ const timer = setTimeout(() => setAllComplete(true), 2000)
+ return () => clearTimeout(timer)
+ }
+ }, [isAllDone])
+
+ // Don't render if dismissed, fully complete, or not loaded yet
+ if (!status || status.dismissed || dismissed || allComplete) return null
+
+ const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
+
+ const handleDismiss = async () => {
+ setDismissed(true)
+ try {
+ await dismissOnboarding()
+ } catch {
+ // Already hidden locally
+ }
+ }
+
+ return (
+
+ {/* Progress bar */}
+
+
+
+ {/* Header */}
+
+
+
+ Getting Started
+
+
+ {isAllDone ? (
+ You're all set!
+ ) : (
+
+ {completedCount}
+ {' '}of {totalCount} complete
+
+ )}
+
+
+
+
+
+
+
+ {/* Checklist items */}
+
+ {items.map((item) => {
+ const done = status[item.key]
+ return (
+
+ !done && navigate(item.path)}
+ disabled={done}
+ className={cn(
+ 'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
+ done
+ ? 'cursor-default'
+ : 'hover:bg-[rgba(255,255,255,0.04)]'
+ )}
+ >
+ {/* Checkbox */}
+
+ {done && }
+
+
+ {/* Label */}
+
+ {item.label}
+
+
+ {/* Arrow for incomplete items */}
+ {!done && (
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/frontend/src/components/scripts/ScriptTemplateList.tsx b/frontend/src/components/scripts/ScriptTemplateList.tsx
index e0f32476..f1dc21da 100644
--- a/frontend/src/components/scripts/ScriptTemplateList.tsx
+++ b/frontend/src/components/scripts/ScriptTemplateList.tsx
@@ -1,5 +1,7 @@
-import { FileCode, Search } from 'lucide-react'
+import { Search } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
+import { EmptyState } from '@/components/common/EmptyState'
+import { ScriptIllustration } from '@/components/common/EmptyStateIllustrations'
import { TemplateCard } from './TemplateCard'
interface Props {
@@ -52,10 +54,13 @@ export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: P
)
}
return (
-
+ }
+ title="Automate with script templates"
+ description="Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more."
+ learnMoreLink="/guides/script-templates"
+ className="px-4"
+ />
)
}
diff --git a/frontend/src/components/session/AddSupportingDataModal.tsx b/frontend/src/components/session/AddSupportingDataModal.tsx
new file mode 100644
index 00000000..23a85883
--- /dev/null
+++ b/frontend/src/components/session/AddSupportingDataModal.tsx
@@ -0,0 +1,263 @@
+import { useState, useRef, useCallback } from 'react'
+import { Code2, ImageIcon, Upload } from 'lucide-react'
+import { Modal } from '@/components/common/Modal'
+import { Button } from '@/components/ui/Button'
+import { cn } from '@/lib/utils'
+import { createSupportingData } from '@/api/supportingData'
+import { toast } from '@/lib/toast'
+
+interface AddSupportingDataModalProps {
+ isOpen: boolean
+ onClose: () => void
+ sessionId: string
+ onAdded: () => void
+}
+
+type TabType = 'text_snippet' | 'screenshot'
+
+const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
+
+export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }: AddSupportingDataModalProps) {
+ const [activeTab, setActiveTab] = useState('text_snippet')
+ const [label, setLabel] = useState('')
+ const [textContent, setTextContent] = useState('')
+ const [imageBase64, setImageBase64] = useState(null)
+ const [imageContentType, setImageContentType] = useState(null)
+ const [imageFileName, setImageFileName] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const fileInputRef = useRef(null)
+
+ const resetForm = () => {
+ setLabel('')
+ setTextContent('')
+ setImageBase64(null)
+ setImageContentType(null)
+ setImageFileName(null)
+ setError(null)
+ setActiveTab('text_snippet')
+ }
+
+ const handleClose = () => {
+ resetForm()
+ onClose()
+ }
+
+ const processFile = useCallback((file: File) => {
+ if (file.size > MAX_FILE_SIZE) {
+ setError('File must be under 2MB')
+ return
+ }
+ if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
+ setError('Only PNG, JPEG, and SVG files are supported')
+ return
+ }
+ setError(null)
+ setImageFileName(file.name)
+ setImageContentType(file.type)
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ // Strip the data:... prefix to get raw base64
+ const base64 = result.includes(',') ? result.split(',')[1] : result
+ setImageBase64(base64)
+ }
+ reader.readAsDataURL(file)
+ }, [])
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (file) processFile(file)
+ }
+
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
+ const items = e.clipboardData?.items
+ if (!items) return
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ e.preventDefault()
+ const file = item.getAsFile()
+ if (file) processFile(file)
+ return
+ }
+ }
+ }, [processFile])
+
+ const handleSubmit = async () => {
+ if (!label.trim()) {
+ setError('Label is required')
+ return
+ }
+
+ if (activeTab === 'text_snippet' && !textContent.trim()) {
+ setError('Content is required')
+ return
+ }
+
+ if (activeTab === 'screenshot' && !imageBase64) {
+ setError('Please select or paste an image')
+ return
+ }
+
+ setIsSubmitting(true)
+ setError(null)
+ try {
+ await createSupportingData(sessionId, {
+ label: label.trim(),
+ data_type: activeTab,
+ content: activeTab === 'text_snippet' ? textContent : imageBase64!,
+ content_type: activeTab === 'screenshot' ? (imageContentType ?? undefined) : undefined,
+ })
+ toast.success('Supporting data added')
+ onAdded()
+ handleClose()
+ } catch (err) {
+ console.error('Failed to add supporting data:', err)
+ setError('Failed to save. Please try again.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+ {/* Tabs */}
+
+ setActiveTab('text_snippet')}
+ className={cn(
+ 'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
+ activeTab === 'text_snippet'
+ ? 'bg-card text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+
+ Text Snippet
+
+ setActiveTab('screenshot')}
+ className={cn(
+ 'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
+ activeTab === 'screenshot'
+ ? 'bg-card text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+
+ Screenshot
+
+
+
+ {/* Label */}
+
+
+ Label
+
+ setLabel(e.target.value)}
+ placeholder={activeTab === 'text_snippet' ? 'e.g. Error log output' : 'e.g. Blue screen photo'}
+ className={cn(
+ 'w-full rounded-md border border-border bg-card px-3 py-2 text-sm',
+ 'text-foreground placeholder:text-muted-foreground',
+ 'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
+ )}
+ />
+
+
+ {/* Text Snippet Tab Content */}
+ {activeTab === 'text_snippet' && (
+
+
+ Content
+
+
+ )}
+
+ {/* Screenshot Tab Content */}
+ {activeTab === 'screenshot' && (
+
+
+ Image
+
+ {imageBase64 ? (
+
+
+
{imageFileName}
+
{
+ setImageBase64(null)
+ setImageContentType(null)
+ setImageFileName(null)
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ }}
+ className="mt-2 w-full text-center text-xs text-muted-foreground hover:text-foreground"
+ >
+ Remove and choose another
+
+
+ ) : (
+
fileInputRef.current?.click()}
+ className={cn(
+ 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed border-border',
+ 'bg-card/50 py-10 transition-colors hover:border-muted-foreground'
+ )}
+ >
+
+
Click to upload or paste from clipboard
+
PNG, JPEG, or SVG - max 2MB
+
+ )}
+
+
+ )}
+
+ {/* Error */}
+ {error && (
+ {error}
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ {isSubmitting ? 'Saving...' : 'Add'}
+
+
+
+ )
+}
+
+export default AddSupportingDataModal
diff --git a/frontend/src/components/session/ExportPreviewModal.tsx b/frontend/src/components/session/ExportPreviewModal.tsx
index 5228a258..d1eca396 100644
--- a/frontend/src/components/session/ExportPreviewModal.tsx
+++ b/frontend/src/components/session/ExportPreviewModal.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { Copy, Download, Check, RotateCcw } from 'lucide-react'
+import { Copy, Download, Check, RotateCcw, FileDown } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
import type { RedactionSummary } from '@/types'
@@ -9,8 +9,10 @@ interface ExportPreviewModalProps {
onClose: () => void
content: string
filename: string
- format: 'markdown' | 'text' | 'html' | 'psa'
+ format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
onDownload: (content: string) => void
+ onDownloadPdf?: () => void
+ pdfLoading?: boolean
includeSummary?: boolean
onToggleSummary?: (include: boolean) => void
redactionEnabled?: boolean
@@ -25,6 +27,8 @@ export function ExportPreviewModal({
filename,
format,
onDownload,
+ onDownloadPdf,
+ pdfLoading = false,
includeSummary = false,
onToggleSummary,
redactionEnabled = false,
@@ -72,7 +76,7 @@ export function ExportPreviewModal({
Filename: {filename}
- {format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
+ {format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : format === 'pdf' ? 'PDF' : 'Plain Text'}
{isModified && (
(edited)
@@ -130,56 +134,79 @@ export function ExportPreviewModal({
- {/* Editable Content */}
-
- Export content
-
-