chore: archive old plan docs + add survey foundation files

Move completed plan docs to docs/plans/archive/. Add survey migration 046
and reference HTML/plan files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-05 02:03:38 -05:00
parent da3788afbc
commit 932927b9df
52 changed files with 1250 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
"""Add survey_responses table.
Revision ID: 046
Revises: 045
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision: str = "046"
down_revision: str = "045"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"survey_responses",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("respondent_name", sa.String(255), nullable=True),
sa.Column("responses", JSONB, nullable=False),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("user_agent", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("survey_responses")

View File

@@ -0,0 +1,536 @@
# FlowPilot Survey — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a public-facing survey page at `/survey` that collects FlowPilot research responses from senior MSP engineers and emails the results to Michael. The survey is unauthenticated (no login required), uses the existing ResolutionFlow design system, and has a backend endpoint to receive and email submissions via the existing Resend infrastructure.
**Architecture:** Static React page (public route, no auth) → `POST /api/v1/survey/submit` (unauthenticated, rate-limited) → saves to DB → sends notification email via existing `EmailService` + Resend. No confirmation email to respondent (they may not provide an email).
**Design doc reference:** The survey content and question structure is defined in the HTML file at `frontend/public/survey-reference.html` (copy the uploaded `survey.html` there for reference). The page must be rebuilt as a proper React component using the existing design system — NOT the inline-styled HTML.
**Tech Stack:** FastAPI backend, SQLAlchemy 2.0 (async), Alembic, Pydantic v2. React 19, TypeScript, Tailwind CSS, shadcn/ui frontend.
---
## Reference File
Before starting, copy the survey HTML reference file into the project:
```bash
cp /path/to/survey.html docs/reference/flowpilot-survey-reference.html
```
This file contains the exact questions, options, types, and flow. Use it as the source of truth for survey content. The visual design should NOT copy the inline styles — instead, use the ResolutionFlow design system (Tailwind + shadcn/ui + brand tokens).
---
## Phase 1: Backend
### Task 1: Database Model — `SurveyResponse`
**Files:**
- Create: `backend/app/models/survey_response.py`
- Modify: `backend/app/models/__init__.py` (add import + `__all__` entry)
**Step 1: Create the model file**
```python
# backend/app/models/survey_response.py
"""FlowPilot survey response storage."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.core.database import Base
class SurveyResponse(Base):
__tablename__ = "survey_responses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
respondent_name = Column(String(255), nullable=True) # Optional — we may already know who they are
responses = Column(JSONB, nullable=False) # Full survey answers as JSON
ip_address = Column(String(45), nullable=True) # For rate limiting / dedup
user_agent = Column(Text, nullable=True) # Browser info
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
```
**Step 2: Add to models `__init__.py`**
Add import and `__all__` entry following the existing pattern in the file.
**Step 3: Commit**
```bash
git add backend/app/models/survey_response.py backend/app/models/__init__.py
git commit -m "feat: add SurveyResponse model for FlowPilot research"
```
---
### Task 2: Database Migration
**Files:**
- Create: new Alembic migration
**Step 1: Generate migration**
```bash
cd backend
alembic revision --autogenerate -m "add survey_responses table"
```
**Step 2: Review the generated migration** — verify it creates `survey_responses` with the correct columns.
**Step 3: Run migration**
```bash
alembic upgrade head
```
**Step 4: Commit**
```bash
git add backend/alembic/versions/
git commit -m "feat: add survey_responses migration"
```
---
### Task 3: Pydantic Schema
**Files:**
- Create: `backend/app/schemas/survey.py`
**Step 1: Create schema**
```python
# backend/app/schemas/survey.py
"""Schemas for FlowPilot survey submission."""
from typing import Any, Optional
from pydantic import BaseModel, Field
class SurveySubmission(BaseModel):
"""Incoming survey response from the public form."""
respondent_name: Optional[str] = Field(None, max_length=255)
responses: dict[str, Any] = Field(
...,
description="Question ID → answer mapping. Values can be strings, lists, or numbers.",
)
class SurveySubmissionResponse(BaseModel):
"""Response after successful submission."""
message: str = "Thank you for your response!"
id: str
```
**Step 2: Commit**
```bash
git add backend/app/schemas/survey.py
git commit -m "feat: add survey submission schemas"
```
---
### Task 4: API Endpoint
**Files:**
- Create: `backend/app/api/endpoints/survey.py`
- Modify: `backend/app/api/router.py` (register the new router)
**Step 1: Create the endpoint**
```python
# backend/app/api/endpoints/survey.py
"""Public survey submission endpoint. No authentication required."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.email import EmailService
from app.core.rate_limit import limiter
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveySubmission, SurveySubmissionResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["survey"])
@router.post("/survey/submit", response_model=SurveySubmissionResponse)
@limiter.limit("3/hour")
async def submit_survey(
request: Request,
data: SurveySubmission,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Accept a public survey submission. No auth required.
Rate limited to 3/hour per IP to prevent spam.
Saves to DB and sends notification email to configured address.
"""
# Get client info
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
# Save to database
response = SurveyResponse(
respondent_name=data.respondent_name,
responses=data.responses,
ip_address=ip,
user_agent=ua,
)
db.add(response)
await db.flush()
# Send notification email (fire-and-forget, don't fail the request)
try:
await EmailService.send_survey_notification_email(
to_email=settings.FEEDBACK_EMAIL, # Reuse existing feedback email config
respondent_name=data.respondent_name,
responses=data.responses,
)
except Exception:
logger.exception("Failed to send survey notification email")
return SurveySubmissionResponse(id=str(response.id))
```
**Step 2: Register in router.py**
Add to `backend/app/api/router.py` following the existing pattern. Import and include the survey router. **IMPORTANT:** This endpoint does NOT use `get_current_active_user` — it's public. Make sure the route is included without any auth dependency prefix.
The survey route should be registered similarly to how the auth routes are registered (no auth prefix), e.g.:
```python
from app.api.endpoints import survey
api_router.include_router(survey.router, prefix="")
```
**Step 3: Commit**
```bash
git add backend/app/api/endpoints/survey.py backend/app/api/router.py
git commit -m "feat: add public survey submission endpoint with rate limiting"
```
---
### Task 5: Email Notification
**Files:**
- Modify: `backend/app/core/email.py` (add new static method to `EmailService`)
**Step 1: Add `send_survey_notification_email` to `EmailService`**
Follow the exact same pattern as `send_feedback_email()` — it's a notification to Michael with the response content. Use the same dark-themed HTML template style as existing emails.
```python
@staticmethod
async def send_survey_notification_email(
to_email: str,
respondent_name: str | None,
responses: dict,
) -> bool:
"""Send survey response notification. Fire-and-forget."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
from datetime import datetime, timezone
resend.api_key = settings.RESEND_API_KEY
name_display = respondent_name or "Anonymous"
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
subject = f"[FlowPilot Survey] New Response from {name_display}{date_str}"
# Build response summary as HTML
rows_html = ""
for q_id, answer in responses.items():
if isinstance(answer, list):
answer_display = "<br>".join(f"{item}" for item in answer)
else:
answer_display = str(answer)
rows_html += f"""
<tr>
<td style="padding: 10px 14px; border-bottom: 1px solid #27272a; color: #818cf8; font-weight: 600; vertical-align: top; width: 120px; font-size: 13px;">{q_id}</td>
<td style="padding: 10px 14px; border-bottom: 1px solid #27272a; color: #e4e4e7; font-size: 14px; white-space: pre-wrap;">{answer_display}</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html><body style="margin:0; padding:0; background-color:#09090b; font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#09090b; padding:40px 20px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#18181b; border-radius:12px; border:1px solid #27272a;">
<tr><td style="padding:32px 32px 20px;">
<h1 style="margin:0 0 4px; font-size:20px; color:#f4f4f5;">FlowPilot Survey Response</h1>
<p style="margin:0; font-size:13px; color:#71717a;">From: {name_display} &bull; {date_str}</p>
</td></tr>
<tr><td style="padding:0 32px 32px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #27272a; border-radius:8px; overflow:hidden;">
{rows_html}
</table>
</td></tr>
<tr><td style="padding:0 32px 24px;">
<p style="margin:0; font-size:12px; color:#52525b;">This response has been saved to the survey_responses table.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
})
logger.info("Survey notification sent for respondent: %s", name_display)
return True
except Exception:
logger.exception("Failed to send survey notification email")
return False
```
**Step 2: Commit**
```bash
git add backend/app/core/email.py
git commit -m "feat: add survey notification email to EmailService"
```
---
### Task 6: CORS — Allow Public Survey Endpoint
**Files:**
- Verify: `backend/app/main.py` CORS configuration
The survey endpoint needs to accept POST requests from the frontend. Since both frontend and backend are on the same domain (`resolutionflow.com` / `api.resolutionflow.com`), this should already work with existing CORS config. **Verify** that the existing CORS middleware allows the frontend origin. No changes should be needed, but confirm.
---
## Phase 2: Frontend
### Task 7: Survey Page Component
**Files:**
- Create: `frontend/src/pages/SurveyPage.tsx`
**CRITICAL:** This is a public page. It must NOT import or depend on `authStore`, `ProtectedRoute`, or any authenticated API client. It uses its own direct `fetch` or a standalone axios call to `POST /api/v1/survey/submit`.
**Design requirements:**
- Use the existing ResolutionFlow design system: Tailwind CSS classes, shadcn/ui components (`Card`, `Button`, `Label`, `Textarea`, `Slider`, `Badge`, `Progress`)
- Dark theme by default (the page won't inherit app shell's theme toggle — hardcode `dark` class on root or use dark-only styles)
- Fonts: Plus Jakarta Sans for headings, Inter for body (already in `index.html`)
- Brand gradient: `bg-gradient-brand` or `from-[#818cf8] to-[#a78bfa]`
- Use `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`, `border-border` tokens
- Responsive (works on mobile — engineers might fill this out on their phone)
**Component structure:**
```
SurveyPage
├── SurveyHero (title, description, stats)
├── SurveyProgress (step dots + progress bar)
├── SurveySlide (rendered per active slide)
│ ├── MultipleChoice (single select)
│ ├── MultipleChoiceMulti (multi select)
│ ├── RangeSlider
│ ├── TextAnswer (textarea)
│ ├── DragRank (drag-to-reorder list)
│ └── ScenarioBox (ticket simulation display)
├── SlideNavigation (back/next buttons)
└── SurveyComplete (thank you + submit)
```
**Question data:** Extract the `SLIDES` array from the reference HTML file (`docs/reference/flowpilot-survey-reference.html`). Port it to a TypeScript constant with proper types:
```typescript
type QuestionType = 'mc' | 'mc-multi' | 'range' | 'text' | 'rank';
interface SurveyQuestion {
id: string;
type: QuestionType;
num: string;
text: string;
hint?: string;
options?: string[]; // for mc, mc-multi
items?: string[]; // for rank
min?: number; // for range
max?: number;
step?: number;
suffix?: string;
low_label?: string;
high_label?: string;
}
interface SurveySlide {
id: string;
questions: SurveyQuestion[];
scenario?: {
title: string;
symptom: string;
details: string;
};
}
```
**State management:** Use local React state (`useState` / `useReducer`) — do NOT create a Zustand store for this. It's a self-contained page.
**Submission flow:**
1. User clicks "Submit" on last slide
2. Show loading state on button
3. POST to `${VITE_API_URL}/api/v1/survey/submit` with `{ respondent_name: null, responses: answersObject }`
4. On success → show completion screen
5. On error → show error toast, keep form populated for retry
6. Also offer "Copy to Clipboard" as fallback if submission fails
**The drag-and-drop ranking component:**
- Use native HTML5 drag events (same approach as the reference HTML)
- Support touch events for mobile
- Show numbered positions that update on reorder
- Use `border-border` for item borders, `bg-accent` for drag-over state
**Step 1: Create `frontend/src/pages/SurveyPage.tsx`**
Build the complete page as a single file component (it's a standalone page, not reused elsewhere). Aim for ~400-600 lines. Use shadcn components where they fit (Button, Card, Progress, Textarea, Slider, Badge), custom Tailwind for the rest.
**Step 2: Verify build**
```bash
cd frontend && npm run build
```
**Step 3: Commit**
```bash
git add frontend/src/pages/SurveyPage.tsx
git commit -m "feat: add SurveyPage component for FlowPilot research"
```
---
### Task 8: Public Route
**Files:**
- Modify: `frontend/src/router.tsx`
**Step 1: Add public route for `/survey`**
Add as a top-level route (same level as `/login`, `/register`, `/share/:shareToken` — NOT inside the `ProtectedRoute` wrapper):
```typescript
import SurveyPage from '@/pages/SurveyPage'
// ... in the router array, at the top level alongside login/register:
{
path: '/survey',
element: <SurveyPage />,
errorElement: <RouteError />,
},
```
**IMPORTANT:** This route must be OUTSIDE the `ProtectedRoute` parent. It must not require authentication.
**Step 2: Verify build**
```bash
cd frontend && npm run build
```
**Step 3: Verify route works**
Navigate to `http://localhost:5173/survey` — should render the survey without any login redirect.
**Step 4: Commit**
```bash
git add frontend/src/router.tsx
git commit -m "feat: add public /survey route"
```
---
### Task 9: Nginx Catch-All Verification
**Files:**
- Verify: `frontend/nginx.conf`
The existing nginx config should already have a `try_files $uri $uri/ /index.html` catch-all that sends unknown paths to the React router. Verify that `/survey` will be caught by this rule and served correctly. No changes should be needed.
---
## Phase 3: Polish & Testing
### Task 10: Backend Test
**Files:**
- Create: `backend/tests/test_survey.py`
Write a test that:
1. POSTs a valid survey submission to `/api/v1/survey/submit` (no auth headers)
2. Verifies 200 response with `id` field
3. Verifies the response was saved to the database
4. Tests rate limiting (4th request within an hour returns 429)
5. Tests validation (empty `responses` dict returns 422)
Follow the existing test patterns in `backend/tests/test_auth.py`.
**Step 1: Create test file**
**Step 2: Run tests**
```bash
cd backend && pytest tests/test_survey.py -v
```
**Step 3: Commit**
```bash
git add backend/tests/test_survey.py
git commit -m "test: add survey submission endpoint tests"
```
---
### Task 11: Mobile Responsiveness Check
Verify the survey page renders correctly at these breakpoints:
- Desktop: 1024px+
- Tablet: 768px
- Mobile: 375px
The drag-and-drop ranking must work on touch devices. If shadcn's `Slider` component doesn't render well on mobile, use a custom range input with Tailwind styling.
---
## Summary
| Phase | Tasks | New Files | Modified Files |
|-------|-------|-----------|----------------|
| Backend | Model, migration, schema, endpoint, email | `models/survey_response.py`, `schemas/survey.py`, `api/endpoints/survey.py`, migration | `models/__init__.py`, `api/router.py`, `core/email.py` |
| Frontend | Survey page, public route | `pages/SurveyPage.tsx` | `router.tsx` |
| Testing | Backend test, mobile check | `tests/test_survey.py` | — |
**Total estimated effort:** 3-4 hours for Claude Code execution.
**Key constraints:**
- `/survey` route is PUBLIC — no authentication
- Rate limit: 3 submissions per hour per IP
- Email notification uses existing Resend + `FEEDBACK_EMAIL` config
- Design uses existing Tailwind/shadcn design system, NOT the inline CSS from the reference HTML
- Must work on mobile (engineers may fill this out on their phone)
- The reference HTML at `docs/reference/flowpilot-survey-reference.html` is the source of truth for question content

682
docs/plans/survey.html Normal file
View File

@@ -0,0 +1,682 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowPilot Survey — ResolutionFlow</title>
<meta name="description" content="Help shape FlowPilot, an AI assistant built for MSP engineers. 5-minute survey.">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--gradient-start: #818cf8;
--gradient-mid: #9389fa;
--gradient-end: #a78bfa;
--bg-deep: #050507;
--bg-dark: #09090b;
--bg-card: #111114;
--bg-input: #0c0c0f;
--border-subtle: #1e1e24;
--border-hover: #2a2a32;
--border-focus: #818cf8;
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-muted: #52525b;
--text-hint: #3f3f46;
--accent: #818cf8;
--accent-dim: rgba(129, 140, 248, 0.1);
--accent-glow: rgba(129, 140, 248, 0.06);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.1);
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-deep);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ── Ambient ── */
.ambient { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.orb { position: absolute; border-radius: 50%; filter: blur(140px); opacity: 0.05; }
.orb-1 { width: 600px; height: 600px; background: var(--gradient-start); top: -200px; left: -100px; animation: d1 25s ease-in-out infinite; }
.orb-2 { width: 500px; height: 500px; background: var(--gradient-end); bottom: -150px; right: -100px; animation: d2 30s ease-in-out infinite; }
@keyframes d1 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(40px,50px); } }
@keyframes d2 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(-30px,-40px); } }
.grid-pattern {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image: linear-gradient(rgba(129,140,248,0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(129,140,248,0.02) 1px, transparent 1px);
background-size: 60px 60px;
mask-image: radial-gradient(ellipse 70% 50% at 50% 30%, black, transparent);
}
/* ── Layout ── */
.container { position: relative; z-index: 1; max-width: 680px; margin: 0 auto; padding: 0 20px 100px; }
/* ── Top Bar ── */
.topbar {
position: sticky; top: 0; z-index: 100;
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
background: rgba(5,5,7,0.85); border-bottom: 1px solid var(--border-subtle);
padding: 14px 20px; margin: 0 -20px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.topbar-brand { display: flex; align-items: center; gap: 10px; font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 700; font-size: 14px; color: var(--text-secondary); white-space: nowrap; text-decoration: none; }
.topbar-logo { width: 28px; height: 28px; background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; font-weight: 800; flex-shrink: 0; }
.topbar-brand .flow { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.progress-wrap { flex: 1; max-width: 240px; display: flex; align-items: center; gap: 10px; }
.progress-bar { flex: 1; height: 3px; background: var(--border-subtle); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end)); border-radius: 2px; transition: width 0.5s ease; width: 0%; }
.progress-text { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* ── Hero ── */
.hero { text-align: center; padding: 72px 0 40px; }
.hero-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 14px; border-radius: 100px; background: var(--accent-dim); border: 1px solid rgba(129,140,248,0.15); font-size: 11px; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 20px; }
.hero h1 { font-family: 'Plus Jakarta Sans', sans-serif; font-size: clamp(26px, 5vw, 36px); font-weight: 800; line-height: 1.2; margin-bottom: 12px; }
.hero h1 span { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.hero p { font-size: 15px; color: var(--text-secondary); max-width: 500px; margin: 0 auto; line-height: 1.65; }
.hero-stats { display: flex; justify-content: center; gap: 28px; margin-top: 20px; font-size: 12px; color: var(--text-muted); }
.hero-stats span { display: flex; align-items: center; gap: 5px; }
.hero-stats svg { width: 13px; height: 13px; stroke: var(--accent); fill: none; stroke-width: 2; }
/* ── Steps indicator ── */
.steps-bar { display: flex; gap: 4px; margin-bottom: 36px; }
.step-dot { flex: 1; height: 3px; border-radius: 2px; background: var(--border-subtle); transition: background 0.3s; }
.step-dot.done { background: var(--success); }
.step-dot.active { background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end)); }
/* ── Form Slides ── */
.slide { display: none; animation: fadeIn 0.35s ease; }
.slide.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* ── Question Card ── */
.q-card {
background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 14px;
padding: 28px; margin-bottom: 16px; transition: border-color 0.2s;
}
.q-card:focus-within { border-color: rgba(129,140,248,0.25); }
.q-num { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--accent); margin-bottom: 6px; font-weight: 500; }
.q-text { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 15px; font-weight: 600; color: var(--text-primary); line-height: 1.5; margin-bottom: 4px; }
.q-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 16px; line-height: 1.5; }
/* ── MC Options ── */
.mc-options { display: flex; flex-direction: column; gap: 8px; }
.mc-opt {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-radius: 9px;
border: 1px solid var(--border-subtle); background: var(--bg-input);
cursor: pointer; transition: all 0.15s; font-size: 14px; color: var(--text-secondary);
user-select: none;
}
.mc-opt:hover { border-color: var(--border-hover); color: var(--text-primary); }
.mc-opt.selected { border-color: var(--accent); background: var(--accent-dim); color: var(--text-primary); }
.mc-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border-subtle); flex-shrink: 0; transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
.mc-opt.selected .mc-radio { border-color: var(--accent); }
.mc-opt.selected .mc-radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.mc-check { width: 18px; height: 18px; border-radius: 5px; border: 2px solid var(--border-subtle); flex-shrink: 0; transition: all 0.15s; display: flex; align-items: center; justify-content: center; font-size: 11px; color: transparent; }
.mc-opt.selected .mc-check { border-color: var(--accent); background: var(--accent); color: white; }
/* ── Range Slider ── */
.range-wrap { padding: 8px 0; }
.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
.range-val { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 24px; font-weight: 600; color: var(--accent); margin-bottom: 12px; }
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
background: var(--border-subtle); outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%;
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
cursor: pointer; border: 3px solid var(--bg-card); box-shadow: 0 0 0 1px rgba(129,140,248,0.3);
}
input[type="range"]::-moz-range-thumb {
width: 20px; height: 20px; border-radius: 50%;
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
cursor: pointer; border: 3px solid var(--bg-card);
}
/* ── Textarea ── */
textarea {
width: 100%; min-height: 100px; background: var(--bg-input); border: 1px solid var(--border-subtle);
border-radius: 9px; padding: 14px 16px; color: var(--text-primary); font-family: 'Inter', sans-serif;
font-size: 14px; line-height: 1.6; resize: vertical; transition: border-color 0.2s, box-shadow 0.2s;
}
textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
textarea::placeholder { color: var(--text-hint); }
/* ── Drag Ranking ── */
.rank-list { display: flex; flex-direction: column; gap: 6px; }
.rank-item {
display: flex; align-items: center; gap: 12px;
padding: 11px 16px; border-radius: 9px;
border: 1px solid var(--border-subtle); background: var(--bg-input);
cursor: grab; transition: all 0.15s; font-size: 14px; color: var(--text-secondary);
user-select: none;
}
.rank-item:active { cursor: grabbing; }
.rank-item.dragging { opacity: 0.5; border-color: var(--accent); }
.rank-item.drag-over { border-color: var(--accent); background: var(--accent-dim); }
.rank-num { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--accent); font-weight: 600; width: 20px; text-align: center; flex-shrink: 0; }
.rank-grip { color: var(--text-hint); flex-shrink: 0; display: flex; align-items: center; }
.rank-label { flex: 1; }
/* ── Scenario Box ── */
.scenario-box {
background: linear-gradient(135deg, rgba(129,140,248,0.06), rgba(167,139,250,0.03));
border: 1px solid rgba(129,140,248,0.12); border-radius: 10px;
padding: 16px 20px; margin-bottom: 16px; font-size: 13px;
}
.scenario-box .sc-label { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-weight: 600; }
.scenario-box .sc-row { display: flex; gap: 8px; margin-bottom: 4px; }
.scenario-box .sc-key { color: var(--text-muted); font-weight: 500; white-space: nowrap; }
.scenario-box .sc-val { color: var(--text-secondary); }
/* ── Buttons ── */
.nav-btns { display: flex; justify-content: space-between; margin-top: 28px; gap: 12px; }
.btn {
font-family: 'Inter', sans-serif; font-size: 14px; font-weight: 600; padding: 12px 24px;
border-radius: 9px; border: none; cursor: pointer; transition: all 0.2s;
display: inline-flex; align-items: center; gap: 8px;
}
.btn-ghost { background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
.btn-accent { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); color: white; }
.btn-accent:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-accent:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.btn-success { background: var(--success-dim); border: 1px solid rgba(52,211,153,0.3); color: var(--success); }
.btn-success:hover { background: rgba(52,211,153,0.15); }
/* ── Completion ── */
.complete { display: none; text-align: center; padding: 60px 0; animation: fadeIn 0.4s ease; }
.complete.active { display: block; }
.complete-icon { width: 64px; height: 64px; margin: 0 auto 20px; background: var(--success-dim); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.complete-icon svg { width: 28px; height: 28px; stroke: var(--success); fill: none; stroke-width: 2.5; }
.complete h2 { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 24px; font-weight: 700; margin-bottom: 10px; }
.complete p { color: var(--text-secondary); font-size: 14px; max-width: 440px; margin: 0 auto 28px; line-height: 1.65; }
.complete-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
/* ── Toast ── */
.toast { position: fixed; bottom: 20px; right: 20px; z-index: 200; padding: 10px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; transform: translateY(80px); opacity: 0; transition: all 0.3s ease; background: var(--success-dim); border: 1px solid rgba(52,211,153,0.3); color: var(--success); }
.toast.show { transform: translateY(0); opacity: 1; }
@media (max-width: 640px) {
.container { padding: 0 16px 80px; }
.topbar { padding: 12px 16px; margin: 0 -16px; }
.hero { padding: 56px 0 28px; }
.hero-stats { flex-wrap: wrap; gap: 12px; }
.q-card { padding: 20px; }
}
</style>
</head>
<body>
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
<div class="grid-pattern"></div>
<div class="container">
<!-- Top Bar -->
<div class="topbar">
<a class="topbar-brand" href="https://resolutionflow.com" target="_blank">
<div class="topbar-logo">R</div>
Resolution<span class="flow">Flow</span>
</a>
<div class="progress-wrap">
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<span class="progress-text" id="progressText">0 of 16</span>
</div>
</div>
<!-- Hero -->
<div class="hero" id="heroSection">
<div class="hero-badge">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
FlowPilot Research
</div>
<h1>Help Build an AI That<br>Thinks Like <span>You</span></h1>
<p>We're building an AI assistant for MSP engineers. Your expertise shapes how it thinks. Takes about 5 minutes.</p>
<div class="hero-stats">
<span><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>~5 minutes</span>
<span><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Confidential</span>
<span><svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>16 questions</span>
</div>
</div>
<!-- Steps -->
<div class="steps-bar" id="stepsBar"></div>
<!-- Slides -->
<div id="slidesWrap"></div>
<!-- Completion -->
<div class="complete" id="completeScreen">
<div class="complete-icon"><svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg></div>
<h2>Done — Thank You!</h2>
<p>Your answers will directly shape how FlowPilot troubleshoots. Please send your responses using one of the options below.</p>
<div class="complete-actions">
<button class="btn btn-accent" onclick="copyAll()">Copy Responses to Clipboard</button>
<button class="btn btn-success" onclick="downloadText()">Download as Text</button>
</div>
<p style="margin-top: 24px; font-size: 12px; color: var(--text-muted);">Paste into an email to <strong style="color: var(--text-secondary);"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="a1ccc8c2c9c0c4cde1d3c4d2cecdd4d5c8cecfc7cdced68fc2cecc">[email&#160;protected]</a></strong> or hand off however works best.</p>
</div>
</div>
<div class="toast" id="toast"></div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script>
// ══════════════════════════════════════════
// ██ SURVEY DATA — 16 questions
// ══════════════════════════════════════════
const SLIDES = [
// ── Slide 0: Pre-Troubleshooting & Preferences ──
{
id: "prework",
questions: [
{ id: "prereqs", type: "mc-multi", num: "1", text: "Before you start troubleshooting, what info do you need? (Select all that apply)", hint: "What do you gather or verify before you even start diagnosing?",
options: ["Who's affected (one user, group, everyone)", "What changed recently (patches, config, new software)", "Is there an existing/related ticket", "Client environment details (domain, VPN, on-prem vs. cloud)", "How long the issue has been happening", "User's exact error message or screenshot", "Whether the user has already tried anything", "RMM/monitoring status for the device or service"] },
{ id: "verify_fix", type: "mc", num: "2", text: "After you apply a fix, how do you verify it actually worked?", hint: null,
options: ["Have the user confirm it's working", "Test it myself from their machine or account", "Run the same diagnostic command that showed the failure", "Check monitoring/logs to confirm the service is healthy", "All of the above in sequence", "Fix it and move on — if they don't call back, it worked"] },
{ id: "steps_at_a_time", type: "range", num: "3", text: "When following a troubleshooting guide, how many steps do you prefer to see at once?", hint: "1 = show me one step at a time, 10 = give me the full picture upfront.",
min: 1, max: 10, step: 1, suffix: " steps", low_label: "One at a time", high_label: "Show me everything" },
]
},
// ── Slide 1: Troubleshooting Philosophy ──
{
id: "philosophy",
questions: [
{ id: "first_step", type: "mc", num: "4", text: "A vague ticket comes in: \"Internet is down.\" What's your FIRST move?", hint: "Before any technical check — what's the first mental step?",
options: ["Check if it's one user or many (scope it)", "Look at monitoring / RMM dashboard", "Check for recent changes or maintenance", "Ask the user clarifying questions", "Ping / basic connectivity test", "Check if other tickets are related"] },
{ id: "junior_mistake", type: "mc", num: "5", text: "What's the most common mistake you see junior engineers make?", hint: null,
options: ["Jumping to a fix before understanding the problem", "Not checking scope (one user vs. many)", "Ignoring recent changes as a cause", "Googling the error instead of reading it", "Restarting things without checking logs first", "Not documenting what they tried"] },
{ id: "pivot", type: "mc", num: "6", text: "How do you decide when to stop pursuing one theory and pivot?", hint: null,
options: ["After 23 checks that don't support the theory", "Time-based — if I've spent 15+ min with no progress", "When I find evidence that contradicts the theory", "Gut feeling / experience", "When I run out of ideas on that path"] },
]
},
// ── Slide 2: Scenario ──
{
id: "scenario",
scenario: {
title: "Incoming Ticket",
symptom: "Multiple users can't access \\\\fileserver\\shared",
details: "Started ~9 AM. Some users still can. No known recent changes. Server appears online."
},
questions: [
{ id: "scenario_approach", type: "text", num: "7",
text: "Walk through your first 3 diagnostic steps for this ticket.",
hint: "Include specific commands/tools, what you expect to see, and what a bad result tells you." },
{ id: "scenario_deeper", type: "text", num: "8",
text: "Server pings fine, you can RDP in. What do you check next on the server itself?",
hint: "Services, shares, permissions, event logs — what's your sequence?" },
]
},
// ── Slide 3: Commands & Tools ──
{
id: "commands",
questions: [
{ id: "doc_pct", type: "range", num: "9",
text: "Be honest: what percentage of your troubleshooting steps do you actually document in the ticket?",
hint: null, min: 0, max: 100, step: 10, suffix: "%", low_label: "Almost none", high_label: "Everything" },
{ id: "go_to_commands", type: "text", num: "10",
text: "What are your top 3 go-to PowerShell commands or one-liners? Include exact syntax.",
hint: "The ones you type from muscle memory — we're building FlowPilot's command library from real usage." },
{ id: "secret_weapon", type: "text", num: "11",
text: "Name a command, tool, or technique that junior engineers don't know about but saves you significant time.",
hint: "The secret weapon stuff. This is gold." },
]
},
// ── Slide 4: Tribal Knowledge ──
{
id: "tribal",
questions: [
{ id: "gotcha", type: "text", num: "12",
text: "Describe an issue where the obvious diagnosis was WRONG. What did everyone assume, and what was it actually?",
hint: "These 'gotcha' patterns help FlowPilot warn engineers before they go down the wrong path." },
{ id: "hard_rules", type: "mc-multi", num: "13",
text: "Which of these \"rules\" do you follow? (Select all that apply)",
hint: null,
options: [
"Always check recent changes before deep-diving",
"Screenshot/document current state before making changes",
"Never restart a service without checking logs first",
"Verify the user's report before troubleshooting",
"Check if it's a known issue / existing ticket first",
"Test the fix, don't assume it worked",
"Always have a rollback plan"
]},
]
},
// ── Slide 5: Domain Ranking ──
{
id: "ranking",
questions: [
{ id: "domain_rank", type: "rank", num: "14",
text: "Drag to rank: which technical domains should FlowPilot handle first?",
hint: "Most important at the top.",
items: [
"Windows Server / Active Directory",
"Microsoft 365 / Exchange",
"Networking (DNS, DHCP, VPN, Firewall)",
"Security / Compliance",
"Virtualization (Hyper-V, VMware)",
"Backup & Disaster Recovery",
"Cloud (Azure / AWS)",
"Endpoint Management"
]},
]
},
// ── Slide 6: FlowPilot Feedback ──
{
id: "flowpilot",
questions: [
{ id: "detail_level", type: "mc", num: "15",
text: "When an AI suggests a diagnostic step, how specific should it be?",
hint: null,
options: [
"High-level only (\"Check DNS resolution\")",
"Moderate (\"Run nslookup against the domain controller\")",
"Exact syntax (\"nslookup hostname dc01.domain.local — verify IP matches expected\")",
"Exact syntax + explanation of WHY and what to look for"
]},
{ id: "ai_personality", type: "mc", num: "16",
text: "What would make an AI troubleshooting assistant feel like a useful colleague instead of a chatbot?",
hint: null,
options: [
"Suggests things I haven't thought of yet",
"Knows the right diagnostic order (not just alphabetical)",
"Challenges my assumptions constructively",
"Includes real commands with exact syntax",
"Explains WHY we check things in a certain order",
"Doesn't waste my time with obvious stuff"
]},
]
},
];
// ══════════════════════════════════════════
// ██ STATE
// ══════════════════════════════════════════
let currentSlide = 0;
const answers = {};
const totalQuestions = SLIDES.reduce((n, s) => n + s.questions.length, 0);
// ══════════════════════════════════════════
// ██ RENDER
// ══════════════════════════════════════════
function init() {
// Steps bar
const bar = document.getElementById("stepsBar");
bar.innerHTML = SLIDES.map((_, i) => `<div class="step-dot" id="step-${i}"></div>`).join("");
// Slides
const wrap = document.getElementById("slidesWrap");
wrap.innerHTML = SLIDES.map((slide, si) => {
let html = `<div class="slide" id="slide-${si}">`;
// Scenario box
if (slide.scenario) {
html += `<div class="scenario-box">
<div class="sc-label">${slide.scenario.title}</div>
<div class="sc-row"><span class="sc-key">Symptom:</span><span class="sc-val">${slide.scenario.symptom}</span></div>
<div class="sc-row"><span class="sc-key">Details:</span><span class="sc-val">${slide.scenario.details}</span></div>
</div>`;
}
// Questions
slide.questions.forEach(q => {
html += `<div class="q-card" id="card-${q.id}">`;
html += `<div class="q-num">Q${q.num}</div>`;
html += `<div class="q-text">${q.text}</div>`;
if (q.hint) html += `<div class="q-hint">${q.hint}</div>`;
if (q.type === "mc") {
html += `<div class="mc-options" data-qid="${q.id}" data-type="single">`;
q.options.forEach((opt, oi) => {
html += `<div class="mc-opt" data-val="${opt}" onclick="selectMC(this, '${q.id}', 'single')"><div class="mc-radio"></div><span>${opt}</span></div>`;
});
html += `</div>`;
}
else if (q.type === "mc-multi") {
html += `<div class="mc-options" data-qid="${q.id}" data-type="multi">`;
q.options.forEach((opt, oi) => {
html += `<div class="mc-opt" data-val="${opt}" onclick="selectMC(this, '${q.id}', 'multi')"><div class="mc-check"></div><span>${opt}</span></div>`;
});
html += `</div>`;
}
else if (q.type === "range") {
html += `<div class="range-wrap">
<div class="range-val" id="rv-${q.id}">${q.min}${q.suffix || ''}</div>
<input type="range" min="${q.min}" max="${q.max}" step="${q.step}" value="${q.min}" oninput="onRange('${q.id}', this.value, '${q.suffix || ''}')">
<div class="range-labels"><span>${q.low_label}</span><span>${q.high_label}</span></div>
</div>`;
}
else if (q.type === "text") {
html += `<textarea id="ta-${q.id}" placeholder="Type your answer here..." oninput="onText('${q.id}', this.value)"></textarea>`;
}
else if (q.type === "rank") {
html += `<div class="rank-list" id="rank-${q.id}">`;
q.items.forEach((item, ii) => {
html += `<div class="rank-item" draggable="true" data-idx="${ii}" data-qid="${q.id}">
<div class="rank-grip"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg></div>
<div class="rank-num">${ii + 1}</div>
<div class="rank-label">${item}</div>
</div>`;
});
html += `</div>`;
}
html += `</div>`; // close q-card
});
// Nav
html += `<div class="nav-btns">`;
if (si > 0) html += `<button class="btn btn-ghost" onclick="goSlide(${si - 1})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg> Back</button>`;
else html += `<div></div>`;
if (si < SLIDES.length - 1) html += `<button class="btn btn-accent" onclick="goSlide(${si + 1})">Next <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg></button>`;
else html += `<button class="btn btn-accent" onclick="finish()">Submit <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg></button>`;
html += `</div>`;
html += `</div>`; // close slide
return html;
}).join("");
goSlide(0);
initDragDrop();
}
function goSlide(idx) {
document.querySelectorAll(".slide").forEach(s => s.classList.remove("active"));
document.getElementById(`slide-${idx}`).classList.add("active");
document.getElementById("completeScreen").classList.remove("active");
currentSlide = idx;
// Steps
SLIDES.forEach((_, i) => {
const dot = document.getElementById(`step-${i}`);
dot.className = "step-dot" + (i < idx ? " done" : "") + (i === idx ? " active" : "");
});
// Hero shrinks after first advance
document.getElementById("heroSection").style.display = idx === 0 ? "" : "none";
window.scrollTo({ top: 0, behavior: "smooth" });
updateProgress();
}
//
// INTERACTIONS
//
function selectMC(el, qid, mode) {
if (mode === "single") {
el.parentElement.querySelectorAll(".mc-opt").forEach(o => o.classList.remove("selected"));
el.classList.add("selected");
answers[qid] = el.dataset.val;
} else {
el.classList.toggle("selected");
const selected = [...el.parentElement.querySelectorAll(".mc-opt.selected")].map(o => o.dataset.val);
answers[qid] = selected;
}
updateProgress();
}
function onRange(qid, val, suffix) {
document.getElementById(`rv-${qid}`).textContent = val + suffix;
answers[qid] = val + suffix;
updateProgress();
}
function onText(qid, val) {
answers[qid] = val.trim();
updateProgress();
}
// Drag and drop ranking
function initDragDrop() {
document.querySelectorAll(".rank-list").forEach(list => {
let dragItem = null;
list.querySelectorAll(".rank-item").forEach(item => {
item.addEventListener("dragstart", e => {
dragItem = item;
item.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
item.addEventListener("dragend", () => {
item.classList.remove("dragging");
list.querySelectorAll(".rank-item").forEach(i => i.classList.remove("drag-over"));
// Update numbers and store answer
const qid = item.dataset.qid;
const items = [...list.querySelectorAll(".rank-item")];
items.forEach((it, idx) => it.querySelector(".rank-num").textContent = idx + 1);
answers[qid] = items.map(it => it.querySelector(".rank-label").textContent);
updateProgress();
});
item.addEventListener("dragover", e => {
e.preventDefault();
if (item !== dragItem) item.classList.add("drag-over");
});
item.addEventListener("dragleave", () => item.classList.remove("drag-over"));
item.addEventListener("drop", e => {
e.preventDefault();
item.classList.remove("drag-over");
if (item !== dragItem && dragItem) {
const allItems = [...list.querySelectorAll(".rank-item")];
const fromIdx = allItems.indexOf(dragItem);
const toIdx = allItems.indexOf(item);
if (fromIdx < toIdx) item.after(dragItem);
else item.before(dragItem);
}
});
});
// Touch drag support
let touchItem = null;
let touchClone = null;
let touchStartY = 0;
list.addEventListener("touchstart", e => {
const item = e.target.closest(".rank-item");
if (!item) return;
touchItem = item;
touchStartY = e.touches[0].clientY;
item.classList.add("dragging");
}, { passive: true });
list.addEventListener("touchmove", e => {
if (!touchItem) return;
e.preventDefault();
const touch = e.touches[0];
const items = [...list.querySelectorAll(".rank-item")];
items.forEach(i => i.classList.remove("drag-over"));
const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest(".rank-item");
if (target && target !== touchItem) target.classList.add("drag-over");
}, { passive: false });
list.addEventListener("touchend", e => {
if (!touchItem) return;
const touch = e.changedTouches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest(".rank-item");
if (target && target !== touchItem) {
const allItems = [...list.querySelectorAll(".rank-item")];
const fromIdx = allItems.indexOf(touchItem);
const toIdx = allItems.indexOf(target);
if (fromIdx < toIdx) target.after(touchItem);
else target.before(touchItem);
}
touchItem.classList.remove("dragging");
list.querySelectorAll(".rank-item").forEach(i => i.classList.remove("drag-over"));
const qid = touchItem.dataset.qid;
const items = [...list.querySelectorAll(".rank-item")];
items.forEach((it, idx) => it.querySelector(".rank-num").textContent = idx + 1);
answers[qid] = items.map(it => it.querySelector(".rank-label").textContent);
touchItem = null;
updateProgress();
});
});
// Set initial rank answers
document.querySelectorAll(".rank-list").forEach(list => {
const items = [...list.querySelectorAll(".rank-item")];
if (items.length > 0) {
const qid = items[0].dataset.qid;
answers[qid] = items.map(it => it.querySelector(".rank-label").textContent);
}
});
}
// ══════════════════════════════════════════
// ██ PROGRESS
// ══════════════════════════════════════════
function updateProgress() {
let answered = 0;
SLIDES.forEach(s => s.questions.forEach(q => {
const a = answers[q.id];
if (a !== undefined && a !== "" && (!Array.isArray(a) || a.length > 0)) answered++;
}));
// Rank always counts as answered since it has a default order
const pct = Math.round((answered / totalQuestions) * 100);
document.getElementById("progressFill").style.width = pct + "%";
document.getElementById("progressText").textContent = `${answered} of ${totalQuestions}`;
}
// ══════════════════════════════════════════
// ██ FINISH & EXPORT
// ══════════════════════════════════════════
function finish() {
document.querySelectorAll(".slide").forEach(s => s.classList.remove("active"));
document.getElementById("heroSection").style.display = "none";
document.getElementById("stepsBar").style.display = "none";
document.getElementById("completeScreen").classList.add("active");
window.scrollTo({ top: 0, behavior: "smooth" });
}
function buildOutput() {
let out = "FLOWPILOT SURVEY RESPONSE\n========================\n\n";
SLIDES.forEach(slide => {
slide.questions.forEach(q => {
out += `Q${q.num}. ${q.text}\n`;
const a = answers[q.id];
if (Array.isArray(a)) out += a.map((v, i) => ` ${i + 1}. ${v}`).join("\n") + "\n";
else if (a) out += ` ${a}\n`;
else out += ` (no answer)\n`;
out += "\n";
});
});
return out;
}
function copyAll() {
navigator.clipboard.writeText(buildOutput()).then(() => toast("Copied! Paste into an email and send.")).catch(() => toast("Copy failed — try download instead."));
}
function downloadText() {
const blob = new Blob([buildOutput()], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "flowpilot-survey-response.txt";
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
t