Files
morsequest/docs/superpowers/specs/2026-04-29-mvp-design.md
T
kitadmin cb0ffb7e6b Add MVP design spec and update .gitignore
Covers architecture, schema, user flows, audio engine, mnemonic
validation, scoring, content data, Docker deployment, and brand
implementation for the Pack 404 pilot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 22:41:15 +00:00

391 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 36 (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 <session_token>` header.
```
POST /api/auth/request { email } → sends magic link
GET /api/auth/verify?token= → 302 to /?session=<token>
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 <ADMIN_PASSWORD>
# 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=<token>` 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 <token>`.
### 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<string, string> = { 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<void>
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 <password>
ADMIN_PASSWORD <admin panel password>
APP_URL https://morsequest.keylinkit.net
PORT 8080
NODE_ENV production
```