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>
This commit is contained in:
2026-04-29 22:41:15 +00:00
parent 7ea8f12a31
commit cb0ffb7e6b
2 changed files with 393 additions and 0 deletions
+3
View File
@@ -216,3 +216,6 @@ public/uploads/
forge-config.json forge-config.json
.forge/ .forge/
deployment-keys/ deployment-keys/
# Superpowers brainstorm sessions
.superpowers/
@@ -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 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
```