diff --git a/.gitignore b/.gitignore index a62964e..58a22d0 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,6 @@ public/uploads/ forge-config.json .forge/ deployment-keys/ + +# Superpowers brainstorm sessions +.superpowers/ diff --git a/docs/superpowers/specs/2026-04-29-mvp-design.md b/docs/superpowers/specs/2026-04-29-mvp-design.md new file mode 100644 index 0000000..1b41687 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-mvp-design.md @@ -0,0 +1,390 @@ +# MorseQuest MVP Design Spec +**Date:** 2026-04-29 +**Deployment:** morsequest.keylinkit.net (Docker on VPS) +**Purpose:** Pack 404 pilot — feedback-first, rough edges acceptable + +--- + +## 1. Scope + +### In scope (MVP) +- Magic link email authentication (allegiance pattern) +- Mnemonic creation system — the core differentiator +- Novice level: 12 letters (A E T I N S H R D L U O) +- Operator level: simple 3-letter words from Novice letters +- Full Web Audio API + synchronized visual flash playback +- Points, streaks, daily bonus scoring +- Level-up flow (Novice → Operator) +- Basic admin panel (user list, stats, per-user progress) +- Docker deployment matching allegiance pattern + +### Out of scope (Phase 2) +- Levels 3–6 (General, Extra, Expert, Legend) +- Ham radio license modules +- Payment / Authorize.net integration +- Leaderboards / social features +- PWA offline mode +- White-label / multi-tenant + +--- + +## 2. Architecture + +**Frontend:** React 18 + TypeScript, Vite bundler +**Backend:** Node.js HTTP server (no framework), better-sqlite3 +**Auth:** Magic link email via nodemailer (mail.keylinkit.net:587) +**Deployment:** Single Docker container, node:20-alpine, named volume + +### Project structure +``` +morsequest/ +├── client/ +│ ├── index.html +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx # routing: login / onboarding / game / admin +│ ├── lib/ +│ │ ├── morse.ts # MORSE map, timing constants, word pools +│ │ └── audio.ts # MorseAudioEngine class +│ ├── hooks/ +│ │ ├── useAuth.ts # session token, /api/auth/me +│ │ ├── useMorseAudio.ts # wraps MorseAudioEngine, exposes currentSignal +│ │ └── useProgress.ts # score, streak, level from /api/progress +│ ├── pages/ +│ │ ├── LoginPage.tsx +│ │ ├── OnboardingPage.tsx # mnemonic builder +│ │ ├── GamePage.tsx # practice loop +│ │ └── AdminPage.tsx +│ └── components/ +│ ├── FlashArea.tsx # visual signal display +│ ├── MnemonicBuilder.tsx # step-through letter mnemonic entry +│ ├── PracticeCard.tsx # challenge + answer input +│ └── ScoreBar.tsx +├── server/ +│ ├── server.js # Node HTTP, serves /client/dist, handles /api routes +│ ├── db.js # better-sqlite3, schema init, all query functions +│ └── mailer.js # nodemailer magic link (allegiance pattern) +├── data/ # SQLite db lives here (Docker volume) +├── Dockerfile +├── docker-compose.yml +└── package.json # root scripts: dev, build, start +``` + +### Dev vs production +- **Dev:** `vite dev` on :5173 with proxy → Node on :3001 +- **Production:** `npm run build` compiles React to `/client/dist`; Node serves `/client/dist` as static + all `/api` routes on port 8080 + +--- + +## 3. Database Schema + +`better-sqlite3`, schema created on startup in `db.js`. + +```sql +-- Auth (allegiance pattern) +CREATE TABLE profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + display_name TEXT, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); + +CREATE TABLE magic_tokens ( + token TEXT PRIMARY KEY, + profile_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + profile_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); + +-- Progress +CREATE TABLE user_progress ( + profile_id INTEGER PRIMARY KEY, + level INTEGER NOT NULL DEFAULT 1, -- 1=Novice, 2=Operator + score INTEGER NOT NULL DEFAULT 0, + streak INTEGER NOT NULL DEFAULT 0, + best_streak INTEGER NOT NULL DEFAULT 0, + total_correct INTEGER NOT NULL DEFAULT 0, + total_attempts INTEGER NOT NULL DEFAULT 0, + last_session_at INTEGER, + updated_at INTEGER NOT NULL +); + +-- Mnemonics — one row per letter per user +CREATE TABLE user_mnemonics ( + profile_id INTEGER NOT NULL, + letter TEXT NOT NULL, + phrase TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (profile_id, letter) +); + +-- Per-letter accuracy +CREATE TABLE letter_stats ( + profile_id INTEGER NOT NULL, + letter TEXT NOT NULL, + correct INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (profile_id, letter) +); +``` + +--- + +## 4. API Routes + +All routes return JSON. Auth routes match allegiance pattern. Protected routes require `Authorization: Bearer ` header. + +``` +POST /api/auth/request { email } → sends magic link +GET /api/auth/verify?token= → 302 to /?session= +GET /api/auth/me → { id, email, display_name } [auth] +DELETE /api/auth/session → log out [auth] + +GET /api/progress → { level, score, streak, ... } [auth] +POST /api/progress/answer { letter, correct } [auth] +GET /api/mnemonics → { A: "phrase", ... } [auth] +POST /api/mnemonics { letter, phrase } [auth] + +GET /admin → admin HTML (ADMIN_PASSWORD gate) +GET /api/admin/users → user list with stats [admin-token] +GET /api/admin/stats → global aggregate stats [admin-token] + +# [admin-token]: request must include Authorization: Bearer +# AdminPage.tsx stores the password in sessionStorage after the user enters it, +# and attaches it as a bearer token to all /api/admin/* requests. +# Server checks: token === process.env.ADMIN_PASSWORD +``` + +--- + +## 5. Core User Flows + +### First visit +1. Landing page — enter email → POST /api/auth/request +2. "Check your email" screen +3. Click magic link → GET /api/auth/verify → session token in URL → stored in localStorage +4. Redirect to `/onboarding` (mnemonic builder) +5. Step through all 12 Novice letters: + - Show letter + Morse pattern (dot/dash display) + - Play audio + flash on request + - Player types phrase — real-time validation: + - Word count must equal signal element count + - Words ≤2 chars = dot, words ≥3 chars = dash + - Save → 50 pts, next letter + - Skip available — skipped letters marked, caps rank at Operator +6. Onboarding complete → `/game` (Novice) + +### Practice loop +1. Load challenge: random letter/word from current level, weighted by accuracy (lower accuracy = more frequent) +2. Display signal: visual flash (FlashArea) + audio (useMorseAudio), with Play button +3. Player types answer, submits +4. **Correct:** show their mnemonic phrase as reinforcement, +points, streak++ +5. **Incorrect:** "Try again, adventurer!" — replay, no penalty +6. 10 consecutive correct → level-up modal + +### Session token extraction +After the magic link redirect, `App.tsx` reads `?session=` from the URL on mount, writes it to `localStorage`, strips it from the URL with `history.replaceState`, then fetches `/api/auth/me`. All subsequent API calls attach `Authorization: Bearer `. + +### Returning user +1. Landing page — if `localStorage` has a session token, `App.tsx` immediately fetches `/api/auth/me` +2. Valid session → redirect to `/game` at current level (no email entry needed) +3. Expired/missing → show login page + +### Admin +- `/admin` path, guarded by ADMIN_PASSWORD (same env var pattern as allegiance) +- Table: all users, email, display name, level, score, joined, last seen +- Click user → per-letter accuracy breakdown, mnemonic completion count + +--- + +## 6. Audio Engine + +`client/src/lib/audio.ts` — pure TypeScript, no dependencies. + +```typescript +// Timing: ITU standard, pilot starts at 10 WPM +// dot=120ms, dash=360ms, element gap=120ms, char gap=360ms, word gap=840ms + +const MORSE: Record = { A:'.-', B:'-...', /* ... */ } + +class MorseAudioEngine { + private ctx: AudioContext + private stopFlag = false + + // Emits events: 'signal-start' | 'signal-end' with payload {type:'dot'|'dash'} + on(event: string, cb: Function): void + async play(text: string, wpm?: number): Promise + stop(): void +} +``` + +`useMorseAudio()` hook: +- Wraps `MorseAudioEngine` +- Exposes `{ play, stop, isPlaying, currentSignal }` where `currentSignal: 'dot'|'dash'|null` +- `currentSignal` drives `FlashArea` CSS class toggle + +`FlashArea` component: +- Large colored div — off-state: dark parchment, active-state: quest gold (#D4AF37) +- Dot flash: 120ms, Dash flash: 360ms +- Synchronized to audio engine events (not setTimeout-based) + +--- + +## 7. Mnemonic Validation Logic + +Real-time validation as player types: + +```typescript +function validatePhrase(phrase: string, morsePattern: string): ValidationResult { + const words = phrase.trim().split(/\s+/) + const elements = morsePattern.split('') // '.', '-' + + if (words.length !== elements.length) { + return { valid: false, message: `Need ${elements.length} words, got ${words.length}` } + } + + for (let i = 0; i < words.length; i++) { + const isDot = elements[i] === '.' + const isShort = words[i].length <= 2 // 1-2 chars = dot + if (isDot && !isShort) return { valid: false, message: `Word ${i+1} should be short (dot)` } + if (!isDot && isShort) return { valid: false, message: `Word ${i+1} should be long (dash)` } + } + + return { valid: true } +} +``` + +Letters with Morse patterns for Novice set: +``` +A .- E . T - I .. +N -. S ... H .... R .-. +D -.. L .-.. U ..- O --- +``` + +--- + +## 8. Scoring + +```typescript +function calculatePoints(level: number, streak: number, isFirstSessionToday: boolean): number { + const base = level * 10 // Novice=10, Operator=20 + const streakMult = streak >= 10 ? 2 : 1 + const dailyMult = isFirstSessionToday ? 1.25 : 1 + return Math.floor(base * streakMult * dailyMult) +} + +// Mnemonic creation: flat 50 pts per letter +// Level-up trigger: 10 consecutive correct +``` + +--- + +## 9. Content Data + +Static in `client/src/lib/morse.ts`: + +```typescript +// Novice word pool (Operator level) — 3-letter words from A,E,T,I,N,S,H,R,D,L,U,O +export const OPERATOR_WORDS = [ + 'SEA', 'SUN', 'TAN', 'RAN', 'HIT', 'RUN', 'ANT', 'DEN', + 'HEN', 'INN', 'OAR', 'OUR', 'USE', 'TIN', 'SIN', 'HIS', + 'AIR', 'EAR', 'OIL', 'AND', 'THE', 'HOT', 'NET', 'SET' +] +``` + +Challenge selection weights: letters/words with lower `correct/attempts` ratio selected more frequently. Avoids repeating the same letter 3× in a row. + +--- + +## 10. Docker & Deployment + +### Dockerfile +Two-stage build: build stage installs all deps (including Vite) and compiles the React app; runtime stage copies only the built output and production deps. + +```dockerfile +# --- Build stage --- +FROM node:20-alpine AS builder +WORKDIR /app +RUN apk add --no-cache python3 make g++ +COPY package.json package-lock.json* ./ +RUN npm install --no-audit --no-fund # all deps, including vite +COPY . . +RUN npm run build # vite build → client/dist + +# --- Runtime stage --- +FROM node:20-alpine +ENV NODE_ENV=production PORT=8080 +WORKDIR /app +RUN apk add --no-cache python3 make g++ # for better-sqlite3 native rebuild +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev --no-audit --no-fund +COPY --from=builder /app/client/dist ./client/dist +COPY server/ ./server/ +RUN mkdir -p /app/data && chown -R node:node /app +USER node +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q --spider http://localhost:8080/ || exit 1 +CMD ["node", "server/server.js"] +``` + +### docker-compose.yml +```yaml +services: + morsequest: + image: morsequest:latest + container_name: morsequest + restart: unless-stopped + ports: + - "127.0.0.1:3004:8080" + volumes: + - morsequest-data:/app/data + environment: + PORT: "8080" + NODE_ENV: production + SMTP_HOST: mail.keylinkit.net + SMTP_PORT: "587" + SMTP_USER: ${SMTP_USER} + SMTP_PASS: ${SMTP_PASS} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + APP_URL: https://morsequest.keylinkit.net + +volumes: + morsequest-data: +``` + +Host nginx proxies `morsequest.keylinkit.net` → `127.0.0.1:3004` (same pattern as allegiance). + +--- + +## 11. Brand Implementation + +From brand guidelines: +- **Colors as CSS custom properties:** `--color-gold: #D4AF37`, `--color-blue: #4169E1`, `--color-parchment: #F5E6D3`, `--color-forest: #3C2415` +- **Fonts:** Cinzel (headers, Google Fonts), Open Sans (body), Source Code Pro (Morse display) +- **Voice:** "Try again, adventurer!" not "Incorrect". "Quest complete!" not "Level up". +- **Existing SVG assets** from `assets/` used directly (logos, icons, badges) + +--- + +## 12. Environment Variables + +``` +SMTP_HOST mail.keylinkit.net +SMTP_PORT 587 +SMTP_USER noreply@morsequest.keylinkit.net (or similar) +SMTP_PASS +ADMIN_PASSWORD +APP_URL https://morsequest.keylinkit.net +PORT 8080 +NODE_ENV production +```