{
  "schema_version": "3.3",
  "title": "CodeHub Skills — AI Playbook for Building Games & Apps",
  "url": "https://codehub.sj88ai.com/skill/",
  "total_phases": 10,
  "total_sub_phases": 100,
  "ai_onboarding_kit": {
    "master_system_message": "You are an AI coding assistant with access to the CodeHub Skills playbook.\n\n## Your tools\n\n1. **Read playbook**: GET https://codehub.sj88ai.com/api/skill (100 sub-phases × 10 areas)\n2. **Read phases**: GET https://codehub.sj88ai.com/skill/{slug}/ (per-phase detail)\n3. **Search**: GET https://codehub.sj88ai.com/api/search?q={query} (CodeHub project search)\n\n## When user asks \"build X\"\n\n1. Match \"build X\" against 12 learning paths (build-3d-city, build-2d-platformer, build-tycoon, build-rpg, build-crud-saas, build-ai-chatbot, build-karaoke, build-image-tool, build-react-saas, build-mvp, build-tower-defense, deploy-static-app)\n2. If matched, use the path's sequenced sub-phases\n3. If not matched, search /api/skill for tags (3d, 2d, ai, backend, frontend, audio, video)\n4. Always cite sub-phase IDs (e.g. \"I'll use 3.2 Phaser + 4.1 game loop + 10.5 platformer pattern\")\n\n## When user says \"deploy\"\n\nUse ship.py (8.1):\n```bash\nDEPLOY_VPS_PASS=$YOUR_PASS python3 ship.py --name <slug> --file index.html --title \"<title>\" --version 1.0\n```\n\nNEVER write nginx config from scratch. NEVER inline passwords.\n\n## When user says \"use technology X\"\n\nConsult sub-phase pros_short[], cons_short[], gotchas[] fields. Cite them in your recommendation.\n\n## Output format\n\n- Sub-phase IDs in every architecture recommendation\n- Real CodeHub project reference (stronghold-3d, noodle-shop, etc.)\n- Honor trade-offs (don't ignore cons)\n- Follow learning paths when they match\n\nBegin.",
    "system_prompts": [
      {
        "id": "codehub-expert",
        "name": "CodeHub Expert (Full Context)",
        "category": "Full context",
        "tokens": "~2.5K tokens",
        "prompt": "You are an AI coding agent building games + apps on **CodeHub** (https://codehub.sj88ai.com/).\n\n## Your Knowledge Base (mandatory)\n\nBefore picking any technology, consult the canonical playbook:\n\n1. **Sub-phases**: https://codehub.sj88ai.com/api/skill (100 sub-phases × 10 areas, schema v3.2)\n   - Includes pros_short[], cons_short[], gotchas[], anti_patterns[], snip_full, docs_url, compare_with[]\n\n2. **Learning paths**: Built-in /skill_data_paths.py — 12 ready-to-follow paths mapping goals to subs:\n   - Build 3D City → 1.1→1.6→3.1→1.5→4.1→4.4→4.9→10.2 (stronghold-3d reference)\n   - Build CRUD SaaS → 1.1→1.4→1.7→5.7→9.2→9.3→9.4→9.6 (medbook reference)\n   - Build AI chatbot → 1.7→7.1→7.5→7.6→7.9 (memo-ai reference)\n   - And 9 more (tycoon, RPG, 2D platformer, Karaoke, Image tool, deploy, FastAPI, MVP, Tower Defense)\n\n3. **Glossary**: /skill/glossary (37 cross-cutting terms: thinInstance, FSM, JWT, PKCE, ECS, A*...)\n\n## Rules\n\n- **Always cite sub-phase IDs** (e.g. \"I'll use 1.6 Canvas 2D + 3.2 Phaser 3\") when proposing architecture.\n- **Reference real CodeHub projects** in `ex` field of each sub (e.g. stronghold-3d, noodle-shop, karaoke-studio) for proven patterns.\n- **Honor pros/cons/gotchas**: if a sub has `cons: \"expensive for mobile\"`, mention that constraint.\n- **Follow learning paths**: when user says \"build X\", check if a path matches before inventing new architecture.\n- **Use ship.py** for deployment (8.1) — never write your own nginx config from scratch.\n- **Use CodeHub project patterns** not generic tutorials.\n\n## Output format\n\nWhen proposing a build, structure as:\n1. **Path** (if matched): \"Build [X]\" path with all steps + WHY\n2. **Sub-phases used**: list with IDs\n3. **Real reference**: which CodeHub app this resembles\n4. **Trade-offs**: any cons/gotchas to surface\n\nStop inventing new architectures when a CodeHub path already exists."
      },
      {
        "id": "codehub-game-dev",
        "name": "CodeHub Game Dev (Babylon + Phaser focus)",
        "category": "Specialized",
        "tokens": "~1.5K tokens",
        "prompt": "You are an AI game dev agent building games on CodeHub.\n\n## Stack defaults\n\n- **3D**: Babylon.js (3.1) — full engine, PBR, WebXR, thinInstance for city builders\n- **2D arcade**: Phaser 3 (3.2) — sprites, Arcade/P2 physics, scenes\n- **2D lightweight**: Vanilla Canvas (3.3) — < 200 entities, no library\n- **Visualizer**: Three.js (3.4) — model viewer only\n- **Multiplayer**: WebSocket (3.9) + sticky session LB\n\n## Game dev rules\n\n- Always use **fixed timestep** (4.1) with `dt = min(realDt, 0.05)`\n- NPCs use **Behavior Trees** (4.4), not FSM, if > 3 actions\n- Pathfinding = **A* (4.3)** with Manhattan heuristic; **JPS (4.3)** for uniform-cost grids\n- For city builders: use **thinInstanceAdd** (3.1) — 10000 cubes = 1 draw call\n- Save/load always with **versioned localStorage** (1.4) — never raw JSON\n\n## Production patterns (from CodeHub)\n\n- stronghold-3d v1.4 = Babylon 3D city + villagers + quests\n- noodle-shop v1.1 = customer queue + patience + tips + weather\n- sims-lite v2.1 = NPC behavior trees + relationships\n- street-fighter-thai = Phaser state machine + hitbox\n- realm-of-heroes = MMORPG top-down\n\nCite sub-phase IDs in every recommendation."
      },
      {
        "id": "codehub-backend",
        "name": "CodeHub Backend (FastAPI + DB)",
        "category": "Specialized",
        "tokens": "~1.2K tokens",
        "prompt": "You are an AI backend agent building APIs on CodeHub.\n\n## Stack defaults\n\n- **Framework**: FastAPI (9.2) with Pydantic models + auto docs at /docs\n- **DB dev**: SQLite (9.4) with WAL mode for concurrent reads\n- **DB prod**: PostgreSQL (9.5) with connection pool\n- **ORM**: SQLAlchemy (9.3) + Alembic migrations\n- **Cache**: Redis (9.6) for hot data + sessions\n- **Realtime**: WebSocket (9.7) + sticky session LB; SSE (9.8) for one-way streams\n- **Auth**: OAuth 2.0 with PKCE (9.9) for SPAs\n- **Rate limit**: SlowAPI (9.10) per-IP + per-user\n\n## Backend rules\n\n- **REST API design** (9.1): `/api/v1/...`, OpenAPI auto-generated, 4xx/5xx semantics\n- **Idempotent PUT/DELETE** (9.1)\n- **Pydantic** for request/response validation (9.2) — early error catch\n- **Migrations** (9.3) before deploy — never alter schema in code without migration\n- **Never store PII** in localStorage (1.4) — server-side only\n- **OAuth PKCE** (9.9) for SPA — no client_secret in browser\n\n## Real CodeHub references\n\n- codehub-api = FastAPI + SQLAlchemy + Alembic (production example)\n- medbook/dinebook = booking pattern with timezone handling\n- pos-grocery = POS with inventory + barcode scan\n\nCite sub-phase IDs."
      },
      {
        "id": "codehub-ai",
        "name": "CodeHub AI Agent (OpenAI + RAG)",
        "category": "Specialized",
        "tokens": "~1.4K tokens",
        "prompt": "You are an AI agent building LLM-powered apps on CodeHub.\n\n## Stack defaults\n\n- **API**: OpenAI gpt-4o-mini (7.1) — 15x cheaper than gpt-4o, 95% as capable\n- **Streaming**: SSE (7.6) — type-as-you-generate for chat UIs\n- **Memory**: TF-IDF bilingual (7.5) for free Thai+EN search, OR embeddings (7.4) for semantic\n- **RAG**: top-k=3-5 cosine (7.8) + prompt context, NOT whole doc dumps\n- **Function calling**: OpenAI tools API (7.7) with JSON schema + validation\n- **Safety**: OpenAI moderation endpoint (7.10) — free < 1M tokens/month\n\n## AI rules\n\n- **Always stream** (7.6) — type-as-you-generate UX > wait 8s\n- **Cost**: gpt-4o-mini ($0.15/M in) for 95% of tasks; reserve gpt-4o for hard reasoning\n- **Temperature**: 0 for factual/RAG, 0.7 for creative\n- **System prompt**: be specific, give examples (few-shot > instructions alone)\n- **Embeddings**: normalize vectors, use cosine similarity, IVF index > 100k docs\n- **RAG**: top-k=3-5, cross-encoder re-rank if precision critical\n- **Safety**: moderation endpoint before user-visible content\n\n## CodeHub AI references\n\n- memo-ai = chat with memory (TF-IDF + RAG)\n- mavis-assistant = function calling + streaming\n- quick-note = RAG over markdown notes\n- ai-data-analyzer = LLM picks chart type from CSV\n\nCite sub-phase IDs."
      },
      {
        "id": "codehub-deploy",
        "name": "CodeHub Deploy (ship.py + nginx + SSL)",
        "category": "Specialized",
        "tokens": "~900 tokens",
        "prompt": "You are an AI deploy agent on CodeHub.\n\n## Default deploy flow\n\n```bash\n# Edit locally\nDEPLOY_VPS_PASS=$YOUR_PASS python3 ship.py \\\n  --name <slug> --file index.html --title \"<title>\" --version 1.0\n```\n\nship.py (8.1) does:\n1. curl-verify local file\n2. paramiko SSH to VPS (NEVER inline password, always env)\n3. Upload to /mnt/16tb/codehub/apps/<slug>/\n4. POST /api/cache/refresh to update SQL repo\n5. Reload nginx\n\n## Per-app setup\n\n- **Static SPA** (1 HTML file): ship.py directly\n- **Multi-page**: ship each + add nginx `location ^~ /<slug>/` to config\n- **Backend** (FastAPI): write systemd service file + ship + restart\n- **PWA**: add manifest.json + service-worker.js to root\n\n## Rules\n\n- **NEVER inline $DEPLOY_VPS_PASS** — use env var (8.1, 8.4 gotcha)\n- **HTTPS first** — Let's Encrypt (8.3) certbot --nginx before public launch\n- **Backup 3-2-1** (8.8) — rsync to /mnt/16tb/backup/ daily\n- **Cache headers** — set in nginx not app (8.2)\n- **Mobile 44px touch** (2.7), WCAG 4.5:1 contrast (2.1)\n\n## CodeHub infra references\n\n- ship.py + nginx config = production setup\n- codehub-api systemd service\n- 73 deployed apps via same pipeline\n\nCite sub-phase IDs."
      }
    ],
    "decision_matrix": [
      {
        "id": "want-game-2d",
        "question": "I want to build a 2D game (platformer, RPG, shooter, tower defense)",
        "answer": "**Phaser 3** (3.2) for arcade/2D",
        "reasoning": "Phaser 3 has built-in Arcade + P2 + Matter physics, scene management, and is the de facto choice for 2D web games. Pattern used in street-fighter-thai, sky-ace, realm-of-heroes.",
        "sub_phase_ids": [
          "3.2",
          "4.1",
          "4.3",
          "4.4",
          "4.6",
          "4.8",
          "4.9",
          "10.5"
        ],
        "path_id": "build-2d-platformer",
        "example_app": "street-fighter-thai (Fighting), sky-ace (Shooter), realm-of-heroes (MMORPG)",
        "bundle_size": "~50KB Phaser + 200 lines game logic",
        "deploy": "ship.py + 1 HTML file, no build",
        "time_estimate": "1 week"
      },
      {
        "id": "want-game-3d",
        "question": "I want to build a 3D game (city builder, RPG, sim, FPS)",
        "answer": "**Babylon.js** (3.1) for 3D",
        "reasoning": "Babylon has PBR + Havok physics + GUI + WebXR in one bundle. thinInstance for 10000 objects. Used in stronghold-3d (city), sims-lite (life sim), fps-cops-vs-robbers (FPS), mars-colony (strategy).",
        "sub_phase_ids": [
          "3.1",
          "1.5",
          "1.9",
          "4.1",
          "4.4",
          "4.8",
          "4.9",
          "10.2"
        ],
        "path_id": "build-3d-city",
        "example_app": "stronghold-3d (City), sims-lite (Life Sim), fps-cops-vs-robbers (FPS)",
        "bundle_size": "~1.5MB Babylon + game logic",
        "deploy": "ship.py + Babylon loaded from CDN",
        "time_estimate": "2-3 weeks"
      },
      {
        "id": "want-simple-2d",
        "question": "I want a simple 2D game (under 200 entities, particles, animations)",
        "answer": "**Vanilla Canvas 2D** (3.3) + game loop (4.1)",
        "reasoning": "For < 200 entities, vanilla Canvas 2D is faster than Phaser. Zero dependencies. Used in noodle-shop (tycoon).",
        "sub_phase_ids": [
          "1.6",
          "3.3",
          "4.1"
        ],
        "path_id": "build-tycoon",
        "example_app": "noodle-shop (Restaurant Tycoon)",
        "bundle_size": "Zero deps + 300-500 lines",
        "deploy": "ship.py + 1 HTML file",
        "time_estimate": "3-5 days"
      },
      {
        "id": "want-mvp",
        "question": "I want to ship a working MVP today",
        "answer": "**HTML5 boilerplate** (1.1) + localStorage (1.4) + ship.py (8.1)",
        "reasoning": "No build step, no npm, no framework. Single file deploys in 1 command. Perfect for demo + internal tool + quick prototype.",
        "sub_phase_ids": [
          "1.1",
          "2.1",
          "2.2",
          "1.4",
          "1.10",
          "8.1"
        ],
        "path_id": "build-mvp",
        "example_app": "demo-coin-flip, click-counter (CodeHub starter apps)",
        "bundle_size": "Single 50KB HTML",
        "deploy": "ship.py — 30 seconds",
        "time_estimate": "4-8 hours"
      },
      {
        "id": "want-saas",
        "question": "I want to build a SaaS app (CRUD + auth + payments)",
        "answer": "**FastAPI** (9.2) + **SQLite/Postgres** (9.4/9.5) + **OAuth PKCE** (9.9)",
        "reasoning": "FastAPI gives you Pydantic validation + async + auto docs. SQLite for single-tenant / Postgres for multi-tenant. OAuth 2.0 PKCE for SPA. Pattern in medbook, dinebook, pos-grocery.",
        "sub_phase_ids": [
          "1.7",
          "5.4",
          "5.5",
          "5.6",
          "5.7",
          "9.1",
          "9.2",
          "9.3",
          "9.4",
          "9.6",
          "9.9",
          "9.10"
        ],
        "path_id": "build-crud-saas",
        "example_app": "medbook (Booking), pos-grocery (POS), dinebook (Restaurant)",
        "bundle_size": "~5MB FastAPI + SQLAlchemy + dependencies",
        "deploy": "systemd service + nginx reverse proxy",
        "time_estimate": "2-4 weeks"
      },
      {
        "id": "want-ai-chatbot",
        "question": "I want to build an AI chatbot or assistant",
        "answer": "**OpenAI gpt-4o-mini** (7.1) + **Streaming SSE** (7.6) + **RAG** (7.8)",
        "reasoning": "gpt-4o-mini is 15x cheaper than gpt-4o for 95% of use cases. Streaming reduces TTFB from 8s to 0.4s. RAG prevents hallucination with top-k=3-5 retrieval. Pattern in memo-ai, mavis-assistant.",
        "sub_phase_ids": [
          "1.7",
          "7.1",
          "7.4",
          "7.5",
          "7.6",
          "7.7",
          "7.8",
          "7.9",
          "7.10",
          "9.8"
        ],
        "path_id": "build-ai-chatbot",
        "example_app": "memo-ai (Chat with memory), mavis-assistant (Function calling AI)",
        "bundle_size": "OpenAI API + ~100KB frontend",
        "deploy": "Static SPA + API key in env",
        "time_estimate": "1-2 weeks"
      },
      {
        "id": "want-karaoke",
        "question": "I want to build a lyric video / karaoke studio",
        "answer": "**Web Audio** (1.5) + **Canvas animations** + **ffmpeg.wasm** (6.7)",
        "reasoning": "Web Audio API + FFT beat detection + Canvas 2D lyric overlay + ffmpeg.wasm for MP4 export. Pattern in karaoke-studio v1.1 (11 themes, beat detection, MP4 export).",
        "sub_phase_ids": [
          "1.5",
          "1.9",
          "6.1",
          "6.6",
          "6.7",
          "6.10"
        ],
        "path_id": "build-karaoke",
        "example_app": "karaoke-studio (11 themes + beat + MP4 export)",
        "bundle_size": "~30MB ffmpeg.wasm + ~50KB app",
        "deploy": "ship.py + CDN ffmpeg",
        "time_estimate": "2-3 weeks"
      },
      {
        "id": "want-image-tool",
        "question": "I want to build an image editor / filter app",
        "answer": "**Canvas 2D** (1.6) + **File API** (1.8) + **filters** (6.4)",
        "reasoning": "Canvas 2D for crop/filter + File API for upload/download + Vision API (7.3) for AI auto-tagging. Pattern in fitcheck, palette, cutout-studio.",
        "sub_phase_ids": [
          "1.6",
          "1.8",
          "6.4",
          "7.3",
          "5.8"
        ],
        "path_id": "build-image-tool",
        "example_app": "fitcheck (3D model viewer), palette (Color extraction)",
        "bundle_size": "~50KB app + Canvas API",
        "deploy": "ship.py",
        "time_estimate": "1-2 weeks"
      },
      {
        "id": "want-deploy-existing",
        "question": "I have a working app, need to deploy it to a custom domain",
        "answer": "**ship.py** (8.1) + **nginx** (8.2) + **Let's Encrypt** (8.3)",
        "reasoning": "ship.py is the entire deploy pipeline used by all 73 CodeHub apps. One command: edit → curl-verify → paramiko → API upload → cache refresh. Pattern deployed 73+ apps.",
        "sub_phase_ids": [
          "8.1",
          "8.2",
          "8.3",
          "8.4"
        ],
        "path_id": "deploy-static-app",
        "example_app": "73 apps via ship.py (karaoke-studio, stronghold-3d, etc.)",
        "bundle_size": "ship.py (~150 lines)",
        "deploy": "ship.py + nginx config",
        "time_estimate": "30 minutes"
      }
    ],
    "code_patterns": [
      {
        "id": "pattern-babylon-scene",
        "name": "Babylon.js Scene Bootstrap",
        "desc": "Full Babylon.js scene with camera, lighting, ground, player",
        "subs_used": [
          "3.1"
        ],
        "lines": 35,
        "code": "<!DOCTYPE html>\n<html><head><meta charset=\"UTF-8\"><title>Babylon Scene</title>\n<style>html,body{margin:0;overflow:hidden}canvas{width:100vw;height:100vh}</style>\n<script src=\"https://cdn.babylonjs.com/babylon.js\"></script>\n</head><body>\n<canvas id=\"r\"></canvas>\n<script>\nconst eng = new BABYLON.Engine(document.getElementById('r'), true);\nconst scene = new BABYLON.Scene(eng);\nscene.clearColor = new BABYLON.Color3(0.1, 0.12, 0.16);\n\n// Camera (orbit)\nconst cam = new BABYLON.ArcRotateCamera('c', Math.PI/2, Math.PI/3, 10, BABYLON.Vector3.Zero(), scene);\ncam.attachControl(document.getElementById('r'), true);\n\n// Light\nnew BABYLON.HemisphericLight('h', new BABYLON.Vector3(0,1,0), scene);\n\n// Ground\nconst ground = BABYLON.MeshBuilder.CreateGround('g', {width:10,height:10}, scene);\nground.material = new BABYLON.StandardMaterial('gm', scene);\nground.material.diffuseColor = new BABYLON.Color3(0.3, 0.5, 0.3);\n\n// Player box\nconst player = BABYLON.MeshBuilder.CreateBox('p', {size:1}, scene);\nplayer.position.y = 0.5;\n\n// WASD movement\nconst keys = {};\nwindow.addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);\nwindow.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);\nscene.registerBeforeRender(() => {\n  if (keys['w']) player.position.z -= 0.05;\n  if (keys['s']) player.position.z += 0.05;\n  if (keys['a']) player.position.x -= 0.05;\n  if (keys['d']) player.position.x += 0.05;\n});\n\neng.runRenderLoop(() => scene.render());\nwindow.addEventListener('resize', () => eng.resize());\n</script></body></html>"
      },
      {
        "id": "pattern-rest-api",
        "name": "FastAPI + SQLite + Auth",
        "desc": "REST API with FastAPI, SQLAlchemy, JWT auth, CRUD",
        "subs_used": [
          "9.2",
          "9.3",
          "9.4",
          "9.9"
        ],
        "lines": 45,
        "code": "# pip install fastapi uvicorn sqlalchemy pyjwt passlib python-multipart\nfrom fastapi import FastAPI, Depends, HTTPException\nfrom fastapi.security import HTTPBearer, HTTPAuthorizationCredentials\nfrom sqlalchemy import create_engine, Column, Integer, String, ForeignKey\nfrom sqlalchemy.orm import sessionmaker, declarative_base\nimport jwt, datetime\n\napp = FastAPI()\nengine = create_engine(\"sqlite:///app.db\")\nBase = declarative_base()\n\nclass User(Base):\n    __tablename__ = \"users\"\n    id = Column(Integer, primary_key=True)\n    email = Column(String, unique=True)\n    pwd_hash = Column(String)\n\nBase.metadata.create_all(engine)\nSECRET = \"change-me\"\n\ndef token_for(uid): return jwt.encode({\"uid\": uid, \"exp\": datetime.datetime.utcnow() + datetime.timedelta(days=7)}, SECRET, algorithm=\"HS256\")\ndef uid_from(token):\n    try: return jwt.decode(token, SECRET, algorithms=[\"HS256\"])[\"uid\"]\n    except: raise HTTPException(401)\n\n@app.post(\"/api/login\")\ndef login(email: str, pwd: str):\n    db = sessionmaker(bind=engine)()\n    u = db.query(User).filter_by(email=email).first()\n    if not u: raise HTTPException(404)\n    # check hash here\n    return {\"token\": token_for(u.id)}\n\n@app.get(\"/api/me\")\ndef me(c: HTTPAuthorizationCredentials = Depends(HTTPBearer())):\n    return {\"uid\": uid_from(c.credentials)}"
      },
      {
        "id": "pattern-pwa",
        "name": "PWA Setup (manifest + service worker)",
        "desc": "Make any web app installable + offline-capable",
        "subs_used": [
          "1.10"
        ],
        "lines": 30,
        "code": "// manifest.json\n{\n  \"name\": \"My App\",\n  \"short_name\": \"App\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#0a0a14\",\n  \"theme_color\": \"#ec4899\",\n  \"icons\": [\n    {\"src\": \"/icon-192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\"},\n    {\"src\": \"/icon-512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\"}\n  ]\n}\n\n// In index.html <head>:\n<link rel=\"manifest\" href=\"/manifest.json\">\n<meta name=\"theme-color\" content=\"#ec4899\">\n\n// /sw.js (separate file at root)\nconst V = \"v1\";\nconst ASSETS = [\"/\", \"/index.html\", \"/style.css\", \"/app.js\", \"/icon-192.png\"];\nself.addEventListener(\"install\", e => e.waitUntil(\n  caches.open(V).then(c => c.addAll(ASSETS))\n));\nself.addEventListener(\"fetch\", e => e.respondWith(\n  caches.match(e.request).then(r => r || fetch(e.request))\n));\nself.addEventListener(\"activate\", e => e.waitUntil(\n  caches.keys().then(keys => Promise.all(\n    keys.filter(k => !k.endsWith(V)).map(k => caches.delete(k))\n  ))\n));"
      },
      {
        "id": "pattern-ai-streaming",
        "name": "OpenAI Streaming Chat",
        "desc": "Streaming OpenAI chat with abort control + error handling",
        "subs_used": [
          "7.1",
          "7.6"
        ],
        "lines": 35,
        "code": "const chat = async (messages, onToken, signal) => {\n  const r = await fetch(\"https://api.openai.com/v1/chat/completions\", {\n    method: \"POST\",\n    headers: {\n      \"Authorization\": `Bearer ${OPENAI_KEY}`,\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      model: \"gpt-4o-mini\",\n      messages,\n      stream: true,\n      max_tokens: 1000,\n    }),\n    signal,\n  });\n  if (!r.ok) throw new Error(`HTTP ${r.status}`);\n  const reader = r.body.getReader();\n  const dec = new TextDecoder();\n  let buf = \"\";\n  while (true) {\n    const {done, value} = await reader.read();\n    if (done) break;\n    buf += dec.decode(value);\n    const lines = buf.split(\"\\n\");\n    buf = lines.pop();\n    for (const line of lines) {\n      if (line.startsWith(\"data: \") && line !== \"data: [DONE]\") {\n        const json = JSON.parse(line.slice(6));\n        const token = json.choices[0]?.delta?.content;\n        if (token) onToken(token);\n      }\n    }\n  }\n};\n\n// Usage:\nconst ctrl = new AbortController();\nchat([{role:\"user\", content:\"hi\"}], t => appendToUI(t), ctrl.signal);"
      },
      {
        "id": "pattern-localstorage-save",
        "name": "Versioned localStorage with Migration",
        "desc": "Save/load with auto-migration across versions",
        "subs_used": [
          "1.4"
        ],
        "lines": 35,
        "code": "const Store = (() => {\n  const KEY = \"app_v3\";\n  const MIGRATIONS = {\n    1: d => ({...d, achievements: d.achievements || []}),\n    2: d => ({...d, weather: d.weather || \"cloudy\"}),\n  };\n  let _timer = null;\n  return {\n    save(state) {\n      clearTimeout(_timer);\n      _timer = setTimeout(() => {\n        try {\n          localStorage.setItem(KEY, JSON.stringify({ver: 3, data: state, ts: Date.now()}));\n        } catch(e) {\n          if (e.name === \"QuotaExceededError\") console.warn(\"storage full\");\n        }\n      }, 300); // debounce 300ms\n    },\n    load() {\n      for (let i = 3; i >= 1; i--) {\n        try {\n          const raw = localStorage.getItem(`app_v${i}`);\n          if (!raw) continue;\n          let obj = JSON.parse(raw);\n          for (let v = obj.ver || 1; v <= 3; v++) if (MIGRATIONS[v]) obj.data = MIGRATIONS[v](obj.data);\n          return obj.data;\n        } catch { /* try older */ }\n      }\n      return null;\n    },\n  };\n})();"
      }
    ],
    "bootstrap": {
      "id": "bootstrap",
      "name": "One-Command Bootstrap",
      "desc": "Copy-paste this into any AI agent to give it full CodeHub knowledge",
      "tokens": "~2.5K context",
      "shell_command": "# Bash: fetch all context for an AI agent\necho \"=== Loading CodeHub Skills v3.2 context for AI ===\"\ncurl -s https://codehub.sj88ai.com/api/skill > /tmp/codehub_skills.json\necho \"Loaded $(( $(wc -c < /tmp/codehub_skills.json) / 1024 )) KB of sub-phases\"\ncurl -s https://codehub.sj88ai.com/api/skill | python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\nfor p in data['phases']:\n    print(f\\\"Phase {p['id']}: {p['name']} ({len(p['sub_phases'])} subs)\\\")\n\"",
      "python_command": "import json, urllib.request\n\n# Load all CodeHub skill context\ndata = json.loads(urllib.request.urlopen(\"https://codehub.sj88ai.com/api/skill\").read())\n\n# Index by id for fast lookup\nsub_by_id = {s[\"id\"]: s for p in data[\"phases\"] for s in p[\"sub_phases\"]}\n\ndef find_sub(q):\n    \"\"\"Search sub-phases by tag, title, or desc.\"\"\"\n    q = q.lower()\n    matches = [s for s in sub_by_id.values() if q in s[\"title\"].lower() or q in s.get(\"description\",\"\").lower() or any(q in t for t in s.get(\"tags\",[]))]\n    return matches[:5]\n\n# Example: \"what engine for 2D game?\"\nfor m in find_sub(\"2d game engine\"):\n    print(f\"{m['id']} {m['title']}: {m['description'][:80]}\")",
      "mcp_server_python": "# pip install mcp fastapi uvicorn\nfrom mcp.server import Server, types\nimport json, urllib.request\n\napp = Server(\"codehub-skills\")\n\n@app.tool(\"search_subphases\")\nasync def search_subphases(query: str) -> list:\n    \"\"\"Search CodeHub skill sub-phases by tag, title, or description.\"\"\"\n    data = json.loads(urllib.request.urlopen(\"https://codehub.sj88ai.com/api/skill\").read())\n    q = query.lower()\n    results = []\n    for p in data[\"phases\"]:\n        for s in p[\"sub_phases\"]:\n            score = 0\n            if q in s[\"title\"].lower(): score += 3\n            if q in s.get(\"description\",\"\").lower(): score += 2\n            if any(q in t for t in s.get(\"tags\",[])): score += 1\n            if score > 0:\n                results.append({\"id\": s[\"id\"], \"title\": s[\"title\"], \"phase\": p[\"name\"], \"score\": score})\n    return sorted(results, key=lambda r: -r[\"score\"])[:10]\n\n@app.tool(\"get_learning_path\")\nasync def get_learning_path(path_id: str) -> dict:\n    \"\"\"Get a learning path by id (e.g. 'build-3d-city', 'build-ai-chatbot').\"\"\"\n    # ... load from /skill_data_paths.py\n    pass\n\n# Run with: mcp install /path/to/this/file.py\n# Then: mcp connect codehub-skills",
      "ai_tool_connect": {
        "claude_desktop": "Edit ~/Library/Application Support/Claude/claude_desktop_config.json:\n```json\n{\n  \"mcpServers\": {\n    \"codehub\": {\n      \"command\": \"python3\",\n      \"args\": [\"-m\", \"codehub_mcp_server\"]\n    }\n  }\n}\n```",
        "cursor_ide": "Cursor Settings → MCP → Add new server:\n- Name: codehub\n- Command: python3 -m codehub_mcp_server",
        "vscode_continue": "Add to config.json:\n```json\n{\n  \"mcpServers\": [{\n    \"name\": \"codehub\",\n    \"command\": \"python3\",\n    \"args\": [\"-m\", \"codehub_mcp_server\"]\n  }]\n}\n```"
      }
    }
  },
  "learning_paths": [
    {
      "id": "build-3d-city",
      "title": "Build a 3D City / Builder Game",
      "desc": "Babylon.js grid + cell placement + level-up visuals + NPC villagers. Pattern used in stronghold-3d.",
      "steps": [
        "1.1",
        "1.6",
        "3.1",
        "1.5",
        "4.1",
        "4.4",
        "4.9",
        "10.2"
      ],
      "example_app": "stronghold-3d",
      "difficulty": "Advanced"
    },
    {
      "id": "build-2d-platformer",
      "title": "Build a 2D Platformer Game",
      "desc": "Phaser 3 sprites + physics + gravity + coyote time + variable jump. Pattern used in street-fighter-thai, sky-ace.",
      "steps": [
        "1.1",
        "3.2",
        "4.1",
        "4.6",
        "10.5"
      ],
      "example_app": "sky-ace",
      "difficulty": "Intermediate"
    },
    {
      "id": "build-tycoon",
      "title": "Build a Restaurant / Tycoon Game",
      "desc": "Customer queue + patience bar + tips + reputation + upgrade + balance. Pattern used in noodle-shop v1.1.",
      "steps": [
        "1.1",
        "1.6",
        "3.3",
        "4.1",
        "4.9",
        "10.1",
        "10.7"
      ],
      "example_app": "noodle-shop",
      "difficulty": "Intermediate"
    },
    {
      "id": "build-rpg",
      "title": "Build an RPG (NPCs + Quests + Dialogue)",
      "desc": "NPC behavior trees + dialogue tree + quest progress + inventory. Pattern in sims-lite, realm-of-heroes.",
      "steps": [
        "3.1",
        "4.4",
        "4.6",
        "4.7",
        "4.8",
        "4.9",
        "10.3"
      ],
      "example_app": "sims-lite",
      "difficulty": "Advanced"
    },
    {
      "id": "build-crud-saas",
      "title": "Build a CRUD SaaS App",
      "desc": "Form-builder + FastAPI + SQLite/Postgres + REST API + auth. Pattern in medbook, dinebook, pos-grocery.",
      "steps": [
        "1.1",
        "1.4",
        "1.7",
        "1.8",
        "5.4",
        "5.5",
        "5.6",
        "5.7",
        "9.1",
        "9.2",
        "9.4",
        "9.6",
        "9.10"
      ],
      "example_app": "medbook",
      "difficulty": "Intermediate"
    },
    {
      "id": "build-ai-chatbot",
      "title": "Build an AI Chatbot / Assistant",
      "desc": "OpenAI streaming + RAG + memory + function calling. Pattern in memo-ai, mavis-assistant.",
      "steps": [
        "1.7",
        "7.1",
        "7.6",
        "7.9",
        "7.4",
        "7.5",
        "7.8",
        "7.10",
        "9.8"
      ],
      "example_app": "memo-ai",
      "difficulty": "Intermediate"
    },
    {
      "id": "build-karaoke",
      "title": "Build a Karaoke / Lyric Video Studio",
      "desc": "Web Audio + FFT beat detection + canvas animation + MP4 export. Pattern in karaoke-studio v1.1.",
      "steps": [
        "1.9",
        "6.1",
        "6.6",
        "6.7",
        "6.10"
      ],
      "example_app": "karaoke-studio",
      "difficulty": "Advanced"
    },
    {
      "id": "build-image-tool",
      "title": "Build an Image Editor / Filter App",
      "desc": "Canvas-based crop + filter + sticker overlay. Pattern in fitcheck, palette, cutout-studio.",
      "steps": [
        "1.6",
        "1.8",
        "6.4",
        "7.3",
        "5.8"
      ],
      "example_app": "fitcheck",
      "difficulty": "Intermediate"
    },
    {
      "id": "deploy-static-app",
      "title": "Deploy a Static App to Custom Domain",
      "desc": "ship.py + nginx + Let's Encrypt + paramiko. The complete CI/CD pipeline.",
      "steps": [
        "8.1",
        "8.2",
        "8.3",
        "8.4",
        "8.6",
        "8.8",
        "8.10"
      ],
      "example_app": "ship.py for ALL 73 CodeHub apps",
      "difficulty": "Intermediate"
    },
    {
      "id": "build-react-saas",
      "title": "Build a Backend with FastAPI + Auth",
      "desc": "Production-grade REST API with JWT auth + rate limiting + Alembic migrations. Used by codehub-api.",
      "steps": [
        "9.1",
        "9.2",
        "9.3",
        "9.4",
        "9.5",
        "9.6",
        "9.9"
      ],
      "example_app": "codehub-api (FastAPI at /mnt/16tb/codehub/api)",
      "difficulty": "Advanced"
    },
    {
      "id": "build-mvp",
      "title": "Ship a Working MVP in 1 Day",
      "desc": "Minimal viable product using only HTML5 + localStorage + Fetch — no build, no npm, ship today.",
      "steps": [
        "1.1",
        "2.1",
        "2.2",
        "1.4",
        "1.7",
        "1.10",
        "8.1"
      ],
      "example_app": "demo-coin-flip, click-counter",
      "difficulty": "Beginner"
    },
    {
      "id": "build-tower-defense",
      "title": "Build a Tower Defense",
      "desc": "Grid + A* pathfinding + tower targeting + waves. Standard TD loop.",
      "steps": [
        "3.2",
        "4.1",
        "4.3",
        "10.4"
      ],
      "example_app": "(not yet on CodeHub — recipe only)",
      "difficulty": "Intermediate"
    }
  ],
  "glossary": [
    {
      "term": "Babylon.js",
      "def": "Microsoft-backed 3D engine for browsers (PBR, GUI, physics, WebXR). v8 = current. 1.5MB+ minified.",
      "see": "3.1"
    },
    {
      "term": "Phaser 3",
      "def": "Comprehensive 2D engine with Arcade/P2/Matter physics. TypeScript-supported. ~800KB.",
      "see": "3.2"
    },
    {
      "term": "WebSocket",
      "def": "Full-duplex TCP over HTTP for realtime push. Requires sticky session load balancer for scale.",
      "see": "3.9, 9.7"
    },
    {
      "term": "Server-Sent Events (SSE)",
      "def": "HTTP-native server→client streaming. Newline-delimited JSON. Auto-reconnect. One-way.",
      "see": "9.8"
    },
    {
      "term": "Promise.race / AbortController",
      "def": "Cancel pending fetch via AbortController when user navigates away — prevents zombie requests.",
      "see": "1.7"
    },
    {
      "term": "WASM",
      "def": "WebAssembly — near-native speed in browser. Babylon Havok physics, ffmpeg.wasm, sql.js use this.",
      "see": "3.1, 6.7"
    },
    {
      "term": "FFmpeg.wasm",
      "def": "FFmpeg compiled to WASM (~30MB). Used for in-browser MP4 transcoding without server.",
      "see": "6.7, 8.10"
    },
    {
      "term": "ThinInstance (Babylon)",
      "def": "Draw 10000 identical meshes in 1 draw call via thinInstanceAdd — vs 10000 separate draw calls. Critical for city builders.",
      "see": "3.1"
    },
    {
      "term": "ArcRotateCamera (Babylon)",
      "def": "Default Babylon orbit camera. alpha (azimuth) + beta (polar) + radius. alpha/beta in radians, NOT degrees (Math.PI = 180°).",
      "see": "3.1"
    },
    {
      "term": "Behavior Tree",
      "def": "Hierarchical AI: Selector (OR), Sequence (AND), Decorator. More expressive than FSM for 3+ actions.",
      "see": "4.4"
    },
    {
      "term": "FSM (Finite State Machine)",
      "def": "State ID → allowed transitions + pre-conditions. Simpler than BT but breaks with 3+ parallel states.",
      "see": "3.10"
    },
    {
      "term": "ECS (Entity-Component-System)",
      "def": "Composition over inheritance. Entity = ID, Component = data, System = logic. Cache-friendly.",
      "see": "4.2"
    },
    {
      "term": "A* (A-star)",
      "def": "Optimal shortest-path on grids. Uses heuristic (Manhattan/Euclidean). Open set + closed set.",
      "see": "4.3"
    },
    {
      "term": "JPS (Jump Point Search)",
      "def": "A* optimization. 2-10x faster on uniform-cost grids by skipping redundant neighbor expansion.",
      "see": "4.3"
    },
    {
      "term": "PWA (Progressive Web App)",
      "def": "App installable to home screen via manifest.json + service-worker.js. Offline-first.",
      "see": "1.10"
    },
    {
      "term": "CORS preflight",
      "def": "OPTIONS request browser fires before XHR with custom headers. Adds latency. Cache with Access-Control-Max-Age.",
      "see": "1.7"
    },
    {
      "term": "STUN/TURN",
      "def": "WebRTC helpers — STUN for NAT traversal, TURN for relay when symmetric NAT. Public: stun:stun.l.google.com:19302.",
      "see": "3.9"
    },
    {
      "term": "JWT (JSON Web Token)",
      "def": "Stateless auth token (header.payload.signature). HS256 (shared secret) or RS256 (public key). OAuth 2.0 uses Bearer JWT.",
      "see": "9.9"
    },
    {
      "term": "PKCE",
      "def": "Proof Key for Code Exchange — OAuth flow for SPAs without client_secret. code_verifier → code_challenge (SHA256).",
      "see": "9.9"
    },
    {
      "term": "ID token vs Access token",
      "def": "ID token = who you are (OIDC, JWT). Access token = what you can do (may not be JWT). Different lifetimes.",
      "see": "9.9"
    },
    {
      "term": "HSL (Hue Saturation Lightness)",
      "def": "Color format for easy theming. hsl(330 81% 60%) → shift hue to get theme variants. Better than hex for dark/light modes.",
      "see": "1.2, 2.1"
    },
    {
      "term": "WCAG contrast ratio",
      "def": "Accessibility — normal text needs 4.5:1, large text 3:1. Test via Chrome DevTools accessibility panel.",
      "see": "2.1"
    },
    {
      "term": "Touch target 44px",
      "def": "Apple HIG + Material guideline — buttons should be at least 44x44px for finger-friendly mobile UX.",
      "see": "2.7"
    },
    {
      "term": "prefers-reduced-motion",
      "def": "CSS media query for users who disabled animations — respect with @media to auto-disable transforms.",
      "see": "2.5"
    },
    {
      "term": "subtle-crypto",
      "def": "Web Crypto API for client-side encryption, HMAC, ECDSA. Web Crypto is more reliable than JS crypto libs.",
      "see": "1.4, 9.9"
    },
    {
      "term": "IndexedDB",
      "def": "Browser transactional DB. Async, MB-GB scale, indexes. Use over localStorage past 5MB.",
      "see": "1.4, 9.4"
    },
    {
      "term": "Stripe webhooks",
      "def": "Stripe POSTs to your backend on payment events. Verify via Stripe-Signature header + webhook secret.",
      "see": "10.9"
    },
    {
      "term": "BabylonGUI vs dat.gui",
      "def": "Babylon ships @babylonjs/gui built-in. dat.gui requires Three. UI overlays for debug controls.",
      "see": "3.1, 3.4"
    },
    {
      "term": "K6 / Artillery",
      "def": "HTTP load testing tools. K6 = Go (faster). Artillery = Node (friendlier). Use before launch.",
      "see": "8.7, 9.10"
    },
    {
      "term": "FTS5 (Full-Text Search)",
      "def": "SQLite built-in full-text index. Substring + MATCH queries, BM25 ranking. See codehub-api /api/search.",
      "see": "9.4"
    },
    {
      "term": "Ship.py pipeline",
      "def": "CodeHub’s 5-step deploy: edit → curl-verify → paramiko → API upload → /api/cache/refresh. Single command.",
      "see": "8.1"
    },
    {
      "term": "Multivariate Gaussian (vision)",
      "def": "Cutout Studio bg-remover uses ONNX model trained on multivariate Gaussian likelihood. Not Stable Diffusion.",
      "see": "7.3"
    },
    {
      "term": "FFT beat detection",
      "def": "Karaoke Studio computes FFT_SIZE=1024 windows, RMS peaks > max*0.55 AND > local_avg*1.3, skip < 0.25s apart.",
      "see": "6.7"
    },
    {
      "term": "HSL → hex conversion",
      "def": "hsl-to-hex: hsl in degrees, hex in 6-char RGB. CSS Color Module 4 (2023) supports color-mix() for derived colors.",
      "see": "2.1"
    },
    {
      "term": "OpenAI moderation free < 1M tokens/month",
      "def": "OpenAI moderation endpoint (om-moderation-latest) is free up to 1M tokens/month — use for safety filter before content reaches user.",
      "see": "7.10"
    },
    {
      "term": "RequestAnimationFrame (rAF)",
      "def": "Browser API for ~60fps rendering. Pause when tab inactive. Not setInterval!",
      "see": "1.6, 3.3, 4.1"
    },
    {
      "term": "Promise.all vs Promise.allSettled",
      "def": "Promise.all rejects on first error. Promise.allSettled waits for all regardless. Use allSettled for graceful degradation.",
      "see": "1.7, 7.4"
    }
  ],
  "phases": [
    {
      "id": 1,
      "name": "Foundations",
      "icon": "📦",
      "tagline": "Stack backbone — ต้องมีก่อนเริ่่มทุกโปรเชค",
      "sub_phases": [
        {
          "id": "1.1",
          "title": "HTML5 Boilerplate",
          "icon": "🌐",
          "description": "Single-file HTML (CSS+JS inline). No build step, no npm, CDN-only deps. Fastest path to a working app.",
          "when_to_use": "ท최ดด 1000 line; prototype; demo; internal tool; offline-able; static hosting",
          "when_not_to_use": "App > 50k lines; need TypeScript; need SSR/SEO; > 5 devs; npm ecosystem required",
          "examples": "karaoke-studio, noodle-shop, stronghold-3d, fitcheck, demo-coin-flip",
          "snippet": "<!DOCTYPE html><html lang=en><head><meta charset=UTF-8><meta name=viewport content=\"width=device-width,initial-scale=1\"><title>App</title><style>body{margin:0;background:#0a0a14;color:#fff;font:14px system-ui}</style></head><body><div id=app></div><script>document.getElementById('app').textContent='Hello v1';</script></body></html>",
          "snippet_full": "// Full SPA shell with module loading + global state + viewport meta\nconst App = (() => {\n  const state = { user: null, theme: 'dark' };\n  const listeners = new Set();\n  const set = (k, v) => { state[k] = v; listeners.forEach(fn => fn(state)); };\n  return {\n    state,\n    on: (fn) => listeners.add(fn),\n    init: () => {\n      document.documentElement.dataset.theme = state.theme;\n      document.getElementById('app').innerHTML = '<h1>Hello</h1>';\n    }\n  };\n})();\nApp.on(s => console.log('state:', s));\nApp.init();",
          "difficulty": "Beginner",
          "time_to_learn": "5 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Learn/HTML/Introduction_to_HTML",
          "pros": [
            "Loads in 80-200ms with no dependency graph; nginx serves index.html in one TCP roundtrip",
            "Works file:// → no install needed for offline demos / training / fieldwork",
            "CDN-only deps = zero supply-chain npm risk (no postinstall scripts to audit)",
            "Debug = what user sees; Chrome DevTools shows identical HTML/CSS/JS, no source-map confusion",
            "Works on 10-year-old browsers that dropped React 16+ (e.g. Smart TV firmware)"
          ],
          "cons": [
            "DOM lookups O(n²) past 5k elements — use canvas or virtualize for tables/lists",
            "No bundled minification = 2-5x larger payload vs Webpack/Vite; ~150KB vs ~30KB",
            "Global scope pollution; each <script> shares window unless using IIFE or modules",
            "Manual asset versioning required (add ?v=Date.now() to bust iOS Safari's 7-day cache)",
            "No first-class TS = IDE type hints weak; refactor across 10 files = manual find/replace"
          ],
          "gotchas": [
            "iOS Safari caches static HTML for 7 days even with Cache-Control: no-cache; append ?v=Date.now() to URL",
            "CSP blocks inline <script> unless you add 'unsafe-inline' or use external src=.js files",
            "type=module defers script loading — DOMContentLoaded fires before all modules imported",
            "CORS preflight (OPTIONS) fires on every XHR with custom headers; 5x latency hit per request",
            "Touch handlers need {passive:false} to call preventDefault() — default is passive:true in Chrome",
            "Async <script> executes after DOMContentLoaded without explicit await (use defer instead)",
            "Old Android (Stock browser) can choke on > 50 <script> tags; bundle to one file",
            "fetch() doesn't send cookies cross-origin without credentials: 'include' (default is omit)"
          ],
          "anti_patterns": [
            "Inline event handlers (onclick=) in HTML — use addEventListener with delegation for 100+ listeners",
            "Mixing inline <style> with external CSS — specificity wars and no cache benefit",
            "Using document.write() inside async code — wipes the entire DOM tree",
            "Loading jQuery to do querySelectorAll — 30KB for what querySelector does in 1 line"
          ],
          "next_steps": [
            "1.2",
            "1.3",
            "1.7"
          ],
          "tags": [
            "html",
            "vanilla",
            "boilerplate",
            "spa",
            "shell"
          ],
          "compare_with": [
            "Vite + React (5x more deps, faster DX)",
            "Next.js (SSR + routing overkill for 5-page apps)"
          ]
        },
        {
          "id": "1.2",
          "title": "CSS Variables & Design Tokens",
          "icon": "🎨",
          "description": ":root variables (--accent, --bg, --text) for theme switching via data-theme. 1-line theme change.",
          "when_to_use": "multi-theme; white-label; design system; > 5 reusable components",
          "when_not_to_use": "single static page < 5 styles; print stylesheet (use static colors)",
          "examples": "stronghold-3d, codehub, karaoke-studio, flowboard",
          "snippet": ":root { --accent:#ec4899; --bg:#0a0a14; --text:#f5f5f7; --radius:12px; } .btn { background:var(--accent); border-radius:var(--radius); }",
          "snippet_full": "/* Multi-theme system with semantic tokens + dark mode */\n:root {\n  --color-bg: hsl(240 10% 5%);\n  --color-bg-elevated: hsl(240 10% 8%);\n  --color-text: hsl(0 0% 96%);\n  --color-text-muted: hsl(0 0% 64%);\n  --color-accent: hsl(330 81% 60%);\n  --color-success: hsl(160 84% 39%);\n  --color-error: hsl(0 84% 60%);\n  --space-1: 4px; --space-2: 8px; --space-4: 16px;\n  --radius-md: 12px; --radius-pill: 9999px;\n}\n[data-theme='light'] {\n  --color-bg: hsl(0 0% 98%);\n  --color-text: hsl(240 10% 10%);\n}\n/* Inline script in <head> to prevent FOUC */\n<script>document.documentElement.dataset.theme = localStorage.getItem('theme') || 'dark';</script>",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties",
          "pros": [
            "Brand color change = 1 line of CSS (no JS, no rebuild, no React Context re-render)",
            "Cascade-aware: child components can override --accent without specificity war",
            "live-edit in Chrome DevTools updates entire UI immediately; no build step",
            "Theme persistence via single localStorage key + one documentElement.dataset write"
          ],
          "cons": [
            "IE 11 doesn't support (use PostCSS plugin if IE is required; rare in 2026)",
            "Specificity tied to element where var() is used — can't override in deep selectors without :where()",
            "Huge variable lists hurt readability — use namespaced vars like --color-text-primary, not --text1",
            "No compile-time checks — typo in var(--accnet) silently fails to var() fallback"
          ],
          "gotchas": [
            "var() needs a fallback for missing tokens: var(--accent, #ec4899) — prevents flash of unstyled",
            "HSL > hex for theming — hue/lightness adjustable per-theme: hsl(240 10% 5%) → hsl(0 0% 98%)",
            "CSS @property registers vars with type so calc() and animation work (CSS Properties API)",
            "Cascade inheritance only for inherited properties — background-color needs explicit inheritance",
            "Theme flash on page load — inline a <script> in <head> setting data-theme BEFORE render to avoid FOUC",
            "Tokens nested in @layer have lower specificity; check ordering if your tokens don't apply",
            "Color-mix() in CSS Color Module 4 (2023) gives you derived tokens: --accent-fade: color-mix(in srgb, var(--accent) 30%, transparent)"
          ],
          "anti_patterns": [
            "var() in @keyframes without @property — animations may not interpolate",
            "Using JS to mutate :root vars on every render — garbage collection churn, use class toggles",
            "Token names tied to values: --blue-500 — ties you to one brand; use --color-primary instead",
            "Mixing pixel and rem units in spacing tokens — visual rhythm broken across zoom levels"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "2.1",
            "2.2",
            "2.8"
          ],
          "tags": [
            "css",
            "tokens",
            "theme",
            "variables",
            "design-system"
          ],
          "compare_with": [
            "Sass variables (compile-time, no runtime theme switch)",
            "Tailwind theme (utility class explosion, harder to debug)"
          ]
        },
        {
          "id": "1.3",
          "title": "Vanilla JS Module Pattern (IIFE)",
          "icon": "🧩",
          "description": "IIFE returning public API. Encapsulates private vars without bundler. ESM alternative for legacy browsers.",
          "when_to_use": "single-file apps needing organization; legacy jQuery-era code; pre-ESM",
          "when_not_to_use": "modern ESM project; multiple files; tree-shaking needed",
          "examples": "stronghold-3d, flowboard, noodle-shop, sims-lite",
          "snippet": "const MyApp = (() => { const secret = 'hidden'; return { init(opts) { /* */ }, version:'1.0' }; })(); MyApp.init();",
          "snippet_full": "// Revealing Module Pattern with private state + public API\nconst GameState = (() => {\n  'use strict';\n  let _state = { score: 0, level: 1 };\n  const _listeners = new Set();\n  function _emit() { _listeners.forEach(fn => fn(_state)); }\n  return {\n    get: () => ({ ..._state }),\n    addScore: (n) => { _state.score += n; _emit(); },\n    on: (fn) => _listeners.add(fn),\n    reset: () => { _state = { score: 0, level: 1 }; _emit(); }\n  };\n})();\nGameState.on(s => console.log('score:', s.score));\nGameState.addScore(10); // logs: score: 10",
          "difficulty": "Intermediate",
          "time_to_learn": "15 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Glossary/IIFE",
          "pros": [
            "Encapsulation without bundler — private vars hidden from window scope",
            "Familiar pattern from jQuery era — readable for devs transitioning from jQuery plugins",
            "Single-file deploy — 1 HTTP request for all logic vs 50 for ESM modules"
          ],
          "cons": [
            "Static structure — can't be reloaded or hot-swapped without state loss",
            "No tree-shaking — entire IIFE loads even if you use 1 method",
            "Method names exposed on public API can't be minified to 1 letter (defeats size optimization)",
            "Hard to test in isolation — everything is one closure"
          ],
          "gotchas": [
            "Arrow function inherits lexical this — inside IIFE, this === window in non-strict, undefined in strict",
            "Private functions hidden from devtools — hard to inspect state mid-debug",
            "Use 'use strict' at top of IIFE to catch ReferenceError on assignment to undeclared vars",
            "Closures hold references — memory leaks if you store DOM nodes in long-lived IIFE",
            "Can't use import/export — use Revealing Module Pattern (assign all public methods to const obj = {...})",
            "Destructuring the public API binds references: const { init } = MyApp; — can't replace later"
          ],
          "anti_patterns": [
            "IIFE returning object literal when you need polymorphism — use class",
            "Storing DOM references in IIFE closure — leaks when DOM removed; use WeakMap",
            "Hiding async functions behind sync API — caller doesn't know to await",
            "Mixing CommonJS require() with IIFE — module systems fight each other"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "3.10",
            "4.1"
          ],
          "tags": [
            "js",
            "module",
            "iife",
            "closure",
            "pattern"
          ],
          "compare_with": [
            "ESM (modern, tree-shakable)",
            "CommonJS (Node.js only)",
            "AMD (deprecated)"
          ]
        },
        {
          "id": "1.4",
          "title": "localStorage Save/Load + Migration",
          "icon": "💾",
          "description": "JSON.stringify + version field. Fallback chain: v3 → v2 → v1. Foundation for offline-first apps.",
          "when_to_use": "game saves; form drafts; user preferences; offline-first apps",
          "when_not_to_use": "multi-device sync required; sensitive data; > 5MB; cross-tab real-time",
          "examples": "flowboard, stronghold-3d, noodle-shop, sims-lite, habit-streak",
          "snippet": "const KEY = 'app_v3'; const save = s => localStorage.setItem(KEY, JSON.stringify({ver:3,data:s})); const load = () => JSON.parse(localStorage.getItem(KEY) || localStorage.getItem('app_v2') || 'null');",
          "snippet_full": "// Production-grade localStorage with versioning, debounce, migration\nconst Store = (() => {\n  const KEY = 'app_v3';\n  const MIGRATIONS = {\n    1: (d) => ({ ...d, achievements: d.achievements || [] }),\n    2: (d) => ({ ...d, weather: d.weather || 'cloudy' })\n  };\n  let _timer = null;\n  return {\n    save(state) {\n      clearTimeout(_timer);\n      _timer = setTimeout(() => {\n        try { localStorage.setItem(KEY, JSON.stringify({ ver: 3, data: state, ts: Date.now() })); }\n        catch (e) { if (e.name === 'QuotaExceededError') console.warn('save failed: storage full'); }\n      }, 300);\n    },\n    load() {\n      for (let i = 3; i >= 1; i--) {\n        const raw = localStorage.getItem(`app_v${i}`);\n        if (!raw) continue;\n        try {\n          let obj = JSON.parse(raw);\n          for (let v = obj.ver || 1; v <= 3; v++) if (MIGRATIONS[v]) obj.data = MIGRATIONS[v](obj.data);\n          return obj.data;\n        } catch (e) { console.warn(`v${i} corrupted`); }\n      }\n      return null;\n    }\n  };\n})();",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage",
          "pros": [
            "Instant persistence — no async, no network; perfect for game saves and form drafts",
            "Zero latency reads — synchronous getItem() < 1ms vs IndexedDB async",
            "Free and abundant — 5-10MB per origin (Chrome), no auth, no server cost",
            "Survives reloads, browser restarts, OS updates — even when offline"
          ],
          "cons": [
            "5-10MB limit per origin (Chrome) — 5MB warning at 5MB, 0KB after QuotaExceededError",
            "NOT encrypted — user can open DevTools and read all saves; never store PII/passwords",
            "Not synced across devices — user on iPhone won't see iPad save",
            "iOS Private Browsing = cleared when tab closes (no warning, silent data loss)",
            "Synchronous — blocks main thread; writing 1MB JSON freezes UI 50-200ms"
          ],
          "gotchas": [
            "ALWAYS try/catch JSON.parse — corrupted data throws SyntaxError, uncaught = blank page",
            "Map, Set, Date, undefined, NaN, Infinity don't survive JSON.stringify — convert manually",
            "iOS Safari localStorage cleared in Private mode silently; detect via try/catch on setItem",
            "QuotaExceededError on save — clear old versions, then save again with retry",
            "Browser sync between tabs via 'storage' event — useful but different origin policies apply",
            "localStorage vs sessionStorage — lastCleared on tab close, but shared across same-tab windows",
            "Cache-Control headers don't affect localStorage — only affects HTTP cache"
          ],
          "anti_patterns": [
            "Saving on every change without debounce — 100ms of work per keystroke kills performance",
            "Storing Date objects directly — they serialize to strings, not Date instances on load",
            "Catching SyntaxError and silently returning null — user loses save without warning",
            "Migration code that REQUIRES a specific old format — if you skip v1, v3 loaders break"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "9.4",
            "4.10"
          ],
          "tags": [
            "storage",
            "save",
            "migration",
            "offline",
            "versioning"
          ],
          "compare_with": [
            "IndexedDB (async, MB-GB, structured queries)",
            "sessionStorage (per-tab, ephemeral)",
            "Cookies (HTTP-only, server-readable)"
          ]
        },
        {
          "id": "1.5",
          "title": "Web Audio API (Synthesized SFX)",
          "icon": "🔊",
          "description": "OscillatorNode + GainNode for procedural SFX without audio files. Tiny code, no CDN, real-time pitch.",
          "when_to_use": "เกุ่มต้องก춌 SFX < 100 distinct; load time < 1s; prototype; license-clean assets",
          "when_not_to_use": "music track needed; pro instruments; realistic sampled sounds",
          "examples": "stronghold-3d, street-fighter-thai, karaoke-studio, noodle-shop",
          "snippet": "let actx; const beep = (f=440,d=0.1,v=0.3) => { if(!actx) actx = new AudioContext(); const o=actx.createOscillator(),g=actx.createGain(); o.connect(g).connect(actx.destination); o.frequency.value=f; g.gain.setValueAtTime(0,actx.currentTime); g.gain.linearRampToValueAtTime(v,actx.currentTime+0.005); o.start(); o.stop(actx.currentTime+d); };",
          "snippet_full": "// Full game SFX system with envelope, sweep, polyphony, iOS unlock\nconst SFX = (() => {\n  let ctx = null;\n  const unlock = () => {\n    if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();\n    if (ctx.state === 'suspended') ctx.resume();\n  };\n  const play = (freq = 440, dur = 0.1, type = 'square', vol = 0.2) => {\n    if (!ctx) return;\n    const o = ctx.createOscillator();\n    const g = ctx.createGain();\n    o.type = type; o.frequency.value = freq;\n    o.connect(g).connect(ctx.destination);\n    const t = ctx.currentTime;\n    g.gain.setValueAtTime(0, t);\n    g.gain.linearRampToValueAtTime(vol, t + 0.005);\n    g.gain.exponentialRampToValueAtTime(0.001, t + dur);\n    o.start(t); o.stop(t + dur);\n  };\n  // Preset SFX\n  const jump = () => play(440, 0.15, 'square', 0.15);\n  const hit = () => { play(150, 0.05, 'sawtooth', 0.3); play(80, 0.1, 'square', 0.2); };\n  const coin = () => { play(880, 0.08); setTimeout(() => play(1320, 0.12), 80); };\n  document.addEventListener('click', unlock, { once: true });\n  return { play, jump, hit, coin, unlock };\n})();",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API",
          "pros": [
            "Zero file load — sounds generated on-demand, instant playback, no CDN needed",
            "Real-time pitch/rhythm control — sweep, envelope, LFO without resampling",
            "License-free — no copyrighted sample packs, no .mp3 / .wav files in your bundle",
            "Tiny code — 50 lines replaces 10MB of .mp3 files; perfect for game jam prototypes"
          ],
          "cons": [
            "Synthetic = not realistic — can't produce pro-quality instruments, voice, drums",
            "iOS Safari AudioContext starts SUSPENDED — must resume() inside a click/tap handler first",
            "Polyphony capped at ~32 simultaneous voices on mobile — explosion SFX stack up and clip",
            "iOS audio plays through earpiece (not speaker) until user has a session of 7s+ — silent phones!"
          ],
          "gotchas": [
            "AudioContext starts in 'suspended' state on iOS — resume() MUST be in user gesture handler (click)",
            "Always add envelope to prevent click/pop on note start: gain.linearRampToValueAtTime() from 0",
            "A4 = 440Hz, A5 = 880Hz — musical pitch uses A=440 reference; semitone = 2^(1/12) ratio",
            "createOscillator().connect(gain).connect(ctx.destination) — wrong connection = silent output",
            "Pre-create oscillators when sound is needed, .start() on trigger — don't create per-frame",
            "Webkit prefix still needed for older Safari: webkitAudioContext fallback",
            "Frequency below 20Hz is sub-bass; humans don't perceive direction — use stereo panning via StereoPannerNode"
          ],
          "anti_patterns": [
            "Creating AudioContext per sound — destroys Chrome's audio thread; one context per page",
            "Using .start(0) without envelope — causes loud click/pop on every note",
            "Setting osc.frequency.value = 0 — undefined behavior; use exponentialRampToValueAtTime",
            "Looping sample-rate mismatch — buffer source for sampled, oscillator for synth"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "6.1",
            "6.6"
          ],
          "tags": [
            "audio",
            "sfx",
            "synth",
            "game",
            "oscillator"
          ],
          "compare_with": [
            "Tone.js (heavier, music library, 100KB)",
            "Howler.js (file-based, 12KB)",
            "HTMLAudioElement (file-based, limited)"
          ]
        },
        {
          "id": "1.6",
          "title": "Canvas 2D Rendering",
          "icon": "🧵",
          "description": "Draw primitives + read pixels. ~60fps for ~10000 entities. Foundation for games, charts, image editors.",
          "when_to_use": "custom visualization; image editor; simple game; chart library",
          "when_not_to_use": "3D; > 10000 entities; rich text rendering; accessibility required",
          "examples": "noodle-shop, karaoke-studio, palette, sj88cute-frame-extractor, fitcheck",
          "snippet": "const ctx = canvas.getContext('2d'); const loop = () => { ctx.clearRect(0,0,w,h); particles.forEach(p => { ctx.fillStyle = `rgba(255,${p.life},0,${p.alpha})`; ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fill(); }); requestAnimationFrame(loop); }; loop();",
          "snippet_full": "// Production 2D game loop with HiDPI + fixed timestep + particle system\nconst Game = (() => {\n  const c = document.getElementById('game');\n  const ctx = c.getContext('2d', { alpha: false });\n  const dpr = window.devicePixelRatio || 1;\n  function resize() {\n    c.width = innerWidth * dpr; c.height = innerHeight * dpr;\n    c.style.width = innerWidth + 'px'; c.style.height = innerHeight + 'px';\n    ctx.scale(dpr, dpr);\n  }\n  resize(); addEventListener('resize', resize);\n  const particles = [];\n  let last = performance.now(); const DT = 1/60;\n  function update(dt) {\n    for (let i = particles.length - 1; i >= 0; i--) {\n      const p = particles[i];\n      p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 50;\n      if (p.life <= 0) particles.splice(i, 1);\n    }\n  }\n  function render() {\n    ctx.fillStyle = '#0a0a14'; ctx.fillRect(0, 0, innerWidth, innerHeight);\n    particles.forEach(p => {\n      ctx.fillStyle = `rgba(255, ${p.life * 2}, 0, ${p.life/100})`;\n      ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();\n    });\n  }\n  function frame(now) {\n    const dt = Math.min((now - last) / 1000, 0.25); last = now;\n    update(dt); render();\n    requestAnimationFrame(frame);\n  }\n  return { particles, start: () => requestAnimationFrame(frame) };\n})();\nGame.start();",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D",
          "pros": [
            "Native, no dependencies — every browser ships Canvas 2D since IE 9",
            "Pixel-level control — read pixels (getImageData) for image processing, color picking",
            "Image export — toBlob() converts to PNG/JPEG; canvas.toDataURL() for inline img",
            "60fps for 10k entities on desktop; 1k entities on mobile; predictable perf"
          ],
          "cons": [
            "clear+redraw every frame — no scene graph; you manage state yourself",
            "No native hit-testing — implement spatial index (R-tree, grid) for click detection",
            "Text rendering weak — subpixel, kerning, RTL all janky vs DOM; use DOM overlays",
            "Memory spike on large canvases — 4K canvas = 64MB GPU RAM"
          ],
          "gotchas": [
            "devicePixelRatio — canvas.width = displayWidth * dpr, then ctx.scale(dpr, dpr) for crispness on HiDPI",
            "ctx.save()/restore() stack — always pair them; leaking pushes is a common bug",
            "globalCompositeOperation='lighter' for glow/particles — add colors without darkening background",
            "ctx.measureText() before fillText — prevents layout thrash, lets you right-align text",
            "Image smoothing off for pixel art: ctx.imageSmoothingEnabled = false",
            "Touch events fired with same coordinates as mouse — use pointer events for unified handling",
            "OffscreenCanvas (2018+) lets you render in Web Worker — main thread stays at 60fps for UI"
          ],
          "anti_patterns": [
            "Allocating new objects per frame (new Path2D(), new Image) — garbage collection stutter",
            "Calling fillText without ctx.font set — default font is 10px sans-serif, often tiny",
            "Using canvas as the only UI — a11y nightmare, can't select text or use screen readers",
            "Drawing at non-integer coordinates — causes blurry text/edges; use Math.floor()"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "3.2",
            "3.3",
            "6.4",
            "4.1"
          ],
          "tags": [
            "canvas",
            "2d",
            "rendering",
            "game",
            "graphics"
          ],
          "compare_with": [
            "WebGL/WebGPU (3D, 10x more code)",
            "SVG (DOM-based, scales, slow > 1000 elements)",
            "PixiJS (WebGL wrapper, faster but +400KB)"
          ]
        },
        {
          "id": "1.7",
          "title": "Fetch API + JSON + Error Handling",
          "icon": "📡",
          "description": "Promise-based HTTP. Headers, body, params, 4xx/5xx, CORS, retry. Foundation for all REST APIs.",
          "when_to_use": "every FE app talking to REST API; config; analytics events; file uploads",
          "when_not_to_use": "WebSocket realtime; static content; one-time bundle load",
          "examples": "codehub, flowboard, memo-ai, mavis-assistant, ai-data-analyzer",
          "snippet": "const fetchJSON = async (url, opts={}) => { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), opts.timeout || 10000); try { const r = await fetch(url, {...opts, signal:ctrl.signal}); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.json(); } finally { clearTimeout(tid); } };",
          "snippet_full": "// Production-grade fetch wrapper with retry, timeout, auth, error mapping\nconst api = (() => {\n  const BASE = '';\n  const TOKEN_KEY = 'auth_token';\n  async function call(path, opts = {}) {\n    const url = `${BASE}${path}`;\n    const ctrl = new AbortController();\n    const tid = setTimeout(() => ctrl.abort(), opts.timeout || 15000);\n    const headers = { 'Content-Type': 'application/json', ...opts.headers };\n    const token = localStorage.getItem(TOKEN_KEY);\n    if (token) headers.Authorization = `Bearer ${token}`;\n    let attempt = 0; let lastErr;\n    while (attempt < (opts.retries || 3)) {\n      try {\n        const r = await fetch(url, { ...opts, headers, signal: ctrl.signal });\n        if (r.status === 429 || r.status >= 500) throw new Error(`HTTP ${r.status}`);\n        if (!r.ok) throw Object.assign(new Error(r.statusText), { status: r.status, body: await r.text() });\n        return r.status === 204 ? null : await r.json();\n      } catch (e) {\n        lastErr = e; attempt++;\n        if (attempt < (opts.retries || 3)) await new Promise(r => setTimeout(r, 200 * 2 ** attempt));\n      } finally { clearTimeout(tid); }\n    }\n    throw lastErr;\n  }\n  return { get: (p) => call(p), post: (p, b) => call(p, { method: 'POST', body: JSON.stringify(b) }) };\n})();\nconst projects = await api.get('/api/projects?limit=20');",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API",
          "pros": [
            "Native — no jQuery $.ajax needed; works in all modern browsers and Node 18+",
            "Streaming via ReadableStream — stream OpenAI SSE, large file downloads without memory spike",
            "AbortController for cancellation — user navigates away → cancel pending requests",
            "Auto JSON parsing with one .json() call; auto text with .text()"
          ],
          "cons": [
            "Does NOT throw on 4xx/5xx — you must check res.ok and throw manually; common bug",
            "Body can only be read once — calling res.json() twice = error; cache the parsed result",
            "No built-in retry — implement exponential backoff yourself; many libs do this badly",
            "No request timeout by default — if server hangs, fetch() hangs forever; use AbortController"
          ],
          "gotchas": [
            "AbortController for cancellation — setTimeout(ctrl.abort, 10000) for 10s timeout",
            "CORS preflight (OPTIONS) on custom headers — add 'Content-Type: application/json' triggers it",
            "Cache default is 'default' not 'no-store' — GET requests may return stale data; use cache: 'no-store'",
            "credentials: 'include' for cross-origin cookies — default is 'same-origin' (no cookies sent)",
            "POST body is FormData/Blob/JSON.stringify/URLSearchParams — each has different Content-Type",
            "fetch() rejects on network error, NOT on HTTP 4xx/5xx — always check res.ok first",
            "Response.json() throws on non-JSON — wrap in try/catch to fall back to .text()"
          ],
          "anti_patterns": [
            "fetch('/api/x').then(r => r.json()) without checking r.ok — silently swallows 500 errors",
            "Setting Content-Type manually for FormData — browser sets it with boundary; manual = broken",
            "Catching all errors and returning null — user sees nothing; log + show error UI",
            "Retry without backoff — 1000 concurrent retries when server recovers = thundering herd"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "9.1",
            "9.2",
            "9.6",
            "7.6"
          ],
          "tags": [
            "fetch",
            "api",
            "json",
            "http",
            "promise"
          ],
          "compare_with": [
            "Axios (40KB, interceptors, older API)",
            "ky (3KB, modern, retry built-in)",
            "XHR (legacy, sync mode, no streaming)"
          ]
        },
        {
          "id": "1.8",
          "title": "File API (Upload/Download/Drag)",
          "icon": "📁",
          "description": "Read file from input/drop, create Blob, download via invisible <a>. Foundation for editors, importers.",
          "when_to_use": "image/video editor; file converter; CSV import; drag-drop upload; offline tools",
          "when_not_to_use": "upload to server (use FormData + fetch); real-time sync (WebRTC)",
          "examples": "karaoke-studio, fitcheck, sj88cute-frame-extractor, cutout-studio, pos-grocery",
          "snippet": "const handleFile = async (file) => { if (file.type.startsWith('image/')) { const url = URL.createObjectURL(file); img.src = url; img.onload = () => URL.revokeObjectURL(url); } }; input.addEventListener('change', e => handleFile(e.target.files[0]));",
          "snippet_full": "// Full drag-drop + click-to-upload + preview + download\nconst FileHandler = (() => {\n  const dropZone = document.getElementById('drop');\n  const input = document.getElementById('file');\n  const preview = document.getElementById('preview');\n  let currentFile = null;\n  async function handle(file) {\n    if (!file) return;\n    if (file.size > 50 * 1024 * 1024) { alert('File too large (>50MB)'); return; }\n    currentFile = file;\n    const url = URL.createObjectURL(file);\n    if (file.type.startsWith('image/')) {\n      preview.src = url;\n      preview.onload = () => URL.revokeObjectURL(url);\n    } else if (file.type.startsWith('text/')) {\n      preview.textContent = await file.text();\n      URL.revokeObjectURL(url);\n    }\n  }\n  // Drag-drop\n  ['dragenter', 'dragover'].forEach(e => dropZone.addEventListener(e, ev => { ev.preventDefault(); dropZone.classList.add('hover'); }));\n  ['dragleave', 'drop'].forEach(e => dropZone.addEventListener(e, ev => { ev.preventDefault(); dropZone.classList.remove('hover'); }));\n  dropZone.addEventListener('drop', e => handle(e.dataTransfer.files[0]));\n  input.addEventListener('change', e => handle(e.target.files[0]));\n  function download() {\n    if (!currentFile) return;\n    const a = document.createElement('a');\n    a.href = URL.createObjectURL(currentFile);\n    a.download = currentFile.name;\n    a.click();\n    setTimeout(() => URL.revokeObjectURL(a.href), 100);\n  }\n  return { handle, download };\n})();",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/File_API",
          "pros": [
            "No server needed — process files 100% client-side; privacy guaranteed",
            "Instant preview — file -> blob URL -> <img> in 1ms, no upload roundtrip",
            "Drag-drop UX — native, no library; users expect it from desktop OS",
            "Read as text/buffer/stream — CSV.parse(text), binary parsing, streaming large files"
          ],
          "cons": [
            "RAM spike — file.arrayBuffer() loads entire file into memory; 100MB image = 100MB+ heap",
            "No chunk upload built-in — must slice manually: file.slice(start, end) for resume uploads",
            "Drag-drop on mobile = awkward — no hover state, no file path, only camera roll",
            "URL.createObjectURL leaks — must revokeObjectURL when done or memory grows over time"
          ],
          "gotchas": [
            "URL.revokeObjectURL() after img.onload — otherwise blob stays in memory until tab close",
            "Drag-and-drop requires preventDefault() on BOTH dragover AND drop events — missing one = broken",
            "file.type is unreliable — 'image/jpeg' on .jpg but empty for files without extension",
            "file.arrayBuffer() vs file.stream() — arrayBuffer for small files, stream for > 100MB",
            "file.text() decodes as UTF-8 — notepad-saved Thai files might be TIS-620, need TextDecoder",
            "<input type='file' accept='.jpg,.png'> — still allows ALL files via OS dialog; validate file.type",
            "Download via <a download='filename'> + URL.createObjectURL(blob) — don't append to body"
          ],
          "anti_patterns": [
            "Loading entire file as base64 dataURL — 33% larger, blocks main thread, no streaming",
            "Not revoking object URLs — memory leak per drag-drop; user opens 10 files = 10 blobs in RAM",
            "Trusting file.name for security — user can name malware.exe to myimage.jpg; check file.type",
            "Sync processing of large files — use Web Worker + OffscreenCanvas for > 5MB images"
          ],
          "prereqs": [
            "1.1",
            "1.7"
          ],
          "next_steps": [
            "6.4",
            "6.10",
            "6.3"
          ],
          "tags": [
            "file",
            "upload",
            "drag",
            "blob",
            "download"
          ],
          "compare_with": [
            "Dropzone.js (50KB, more features)",
            "Uppy (200KB, resumable uploads)",
            "FormData + server (when you need to upload)"
          ]
        },
        {
          "id": "1.9",
          "title": "MediaRecorder API",
          "icon": "⏺",
          "description": "Record MediaStream to webm blob. getUserMedia, getDisplayMedia, canvas.captureStream. Foundation for video editor + screen recorder.",
          "when_to_use": "video editor; screen recorder; karaoke export; webcam capture; audio transcription",
          "when_not_to_use": "real-time video call (WebRTC); long recording > 1h; production streaming (HLS/DASH)",
          "examples": "karaoke-studio, sj88-video-editor-pro, sj88cute-lut-preview, sj88cute-voice-isolator",
          "snippet": "async function record(canvas, audioEl) { const vid = canvas.captureStream(30); const actx = new AudioContext(); const src = actx.createMediaElementSource(audioEl); const dest = actx.createMediaStreamDestination(); src.connect(dest); src.connect(actx.destination); const combined = new MediaStream([...vid.getVideoTracks(), ...dest.stream.getAudioTracks()]); return new MediaRecorder(combined, { mimeType: 'video/webm; codecs=vp9' }); }",
          "snippet_full": "// Full screen recorder with system audio + mic + chunked storage\nconst Recorder = (() => {\n  let rec = null, chunks = [];\n  const pickCodec = () => {\n    if (MediaRecorder.isTypeSupported('video/mp4;codecs=avc1')) return 'video/mp4;codecs=avc1';\n    if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) return 'video/webm;codecs=vp9';\n    return 'video/webm';\n  };\n  async function start() {\n    chunks = [];\n    const screen = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });\n    const mic = await navigator.mediaDevices.getUserMedia({ audio: true });\n    const audioTracks = [...screen.getAudioTracks(), ...mic.getAudioTracks()];\n    const stream = new MediaStream([...screen.getVideoTracks(), ...audioTracks]);\n    rec = new MediaRecorder(stream, { mimeType: pickCodec(), videoBitsPerSecond: 5_000_000 });\n    rec.ondataavailable = e => e.data.size && chunks.push(e.data);\n    rec.start(1000); // 1s chunks\n  }\n  function stop() {\n    return new Promise(resolve => {\n      rec.onstop = () => resolve(new Blob(chunks, { type: rec.mimeType }));\n      rec.stop();\n      rec.stream.getTracks().forEach(t => t.stop());\n    });\n  }\n  function download(blob) {\n    const a = document.createElement('a');\n    a.href = URL.createObjectURL(blob);\n    a.download = `recording-${Date.now()}.${blob.type.includes('mp4') ? 'mp4' : 'webm'}`;\n    a.click();\n  }\n  return { start, stop, download };\n})();",
          "difficulty": "Advanced",
          "time_to_learn": "3 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder",
          "pros": [
            "Browser-native — no ffmpeg.wasm for simple WebM recording; smaller bundle",
            "GPU-accelerated — canvas.captureStream uses compositor; 4K@60fps on modern GPUs",
            "Multi-stream mix — audio + video + screen capture in 1 MediaStream; karaoke-studio uses this",
            "Codec choices — vp8/vp9/h264, opus/vorbis, depending on browser; pick by mimeType"
          ],
          "cons": [
            "WebM only on most browsers — Safari ships mp4 only since v14.1; mp4 is iOS-friendly",
            "Audio/video sync drift — mic + screen can drift 200ms+ over 5 min; resync in post",
            "RAM spike during long recordings — chunks pile up in JS array; stream to disk in worker",
            "No pause/resume API — must stop(), save, then start() a new recorder (joins are janky)"
          ],
          "gotchas": [
            "Safari 16+ supports video/mp4 mimeType — use feature detection: MediaRecorder.isTypeSupported('video/mp4; codecs=avc1')",
            "canvas.captureStream(30) for 30fps — without param, captures only when canvas changes (bandwidth saver)",
            "Multi-track via MediaStream — add tracks together: new MediaStream([...video.getTracks(), ...audio.getTracks()])",
            "dataavailable fires per timeslice — use {timeslice: 1000} for 1s chunks; otherwise fires only on stop",
            "Stop on last dataavailable — recorder.requestData() then recorder.stop() ensures all chunks saved",
            "getDisplayMedia({audio:true}) for system audio — Chrome only; Firefox/Safari don't support yet",
            "Camera LED always on — cannot disable via JS (security); UX requires user consent prompt"
          ],
          "anti_patterns": [
            "Recording to single blob — memory spike; use timeslice + chunks array",
            "Setting videoBitsPerSecond too high — 50Mbps on 720p = garbage quality; use 2-5 Mbps for screen",
            "Not awaiting all dataavailable events — stop() then immediately download() misses last chunk",
            "Recording with mic + no mute check — captures room noise; show visual feedback when recording"
          ],
          "prereqs": [
            "1.6",
            "1.8"
          ],
          "next_steps": [
            "6.3",
            "6.9",
            "6.7"
          ],
          "tags": [
            "recording",
            "video",
            "audio",
            "stream",
            "media"
          ],
          "compare_with": [
            "WebCodecs API (low-level, 10x more code, more control)",
            "WebRTC (peer-to-peer, real-time)",
            "ffmpeg.wasm (cross-codec, +30MB bundle)"
          ]
        },
        {
          "id": "1.10",
          "title": "Service Workers (PWA)",
          "icon": "📱",
          "description": "Background script proxying network. Offline, installable, push notifications, background sync.",
          "when_to_use": "every app user reuses; offline required; installable; push notifications",
          "when_not_to_use": "one-time page; debug-difficulty important; legacy browser support critical",
          "examples": "flowboard, quick-note, habit-streak, memo-ai, knowledge-garden",
          "snippet": "// /sw.js self.addEventListener('install', e => e.waitUntil(caches.open('v2').then(c => c.addAll(['/','/style.css','/app.js'])))); self.addEventListener('fetch', e => e.respondWith(caches.match(e.request).then(r => r || fetch(e.request))));",
          "snippet_full": "// Production SW with cache strategies, update flow, push handler\n// sw.js\nconst VERSION = 'v2';\nconst STATIC = ['/', '/index.html', '/style.css', '/app.js', '/icon-192.png', '/icon-512.png'];\nconst CACHE_STATIC = `static-${VERSION}`;\nconst CACHE_API = `api-${VERSION}`;\nself.addEventListener('install', e => {\n  e.waitUntil(caches.open(CACHE_STATIC).then(c => c.addAll(STATIC)));\n  self.skipWaiting();\n});\nself.addEventListener('activate', e => {\n  e.waitUntil(caches.keys().then(keys =>\n    Promise.all(keys.filter(k => !k.endsWith(VERSION)).map(k => caches.delete(k)))\n  ));\n  return self.clients.claim();\n});\nself.addEventListener('fetch', e => {\n  const url = new URL(e.request.url);\n  if (url.pathname.startsWith('/api/')) {\n    // Network-first for API with cache fallback\n    e.respondWith(fetch(e.request).then(r => {\n      const clone = r.clone();\n      caches.open(CACHE_API).then(c => c.put(e.request, clone));\n      return r;\n    }).catch(() => caches.match(e.request)));\n  } else {\n    // Cache-first for static\n    e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));\n  }\n});\nself.addEventListener('push', e => {\n  const data = e.data.json();\n  self.registration.showNotification(data.title, { body: data.body, icon: '/icon-192.png' });\n});",
          "difficulty": "Advanced",
          "time_to_learn": "1 day",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API",
          "pros": [
            "Offline — cached resources work without network; perfect for travel/commute apps",
            "Installable — user can add to home screen; feels like native app, no app store",
            "Push notifications — re-engage users even when tab is closed (needs server-side push)",
            "Background sync — queue actions offline, replay when network returns"
          ],
          "cons": [
            "Dev experience tough — cache invalidation is the hard part; one wrong strategy = stale forever",
            "No DOM access — fetch + postMessage only; can't update UI from SW directly",
            "HTTPS required (except localhost) — extra setup; certbot for production",
            "First load requires network — SW only intercepts on subsequent visits unless you precache"
          ],
          "gotchas": [
            "HTTPS required except localhost — use http://localhost for dev or self-signed for testing",
            "manifest.json + 192/512px icons required for installable — missing icon = no install prompt",
            "Change service-worker.js filename to force update — e.g. sw-v2.js; browser byte-checks content",
            "Cache-first for static assets, network-first for API, stale-while-revalidate for HTML — hybrid",
            "Update flow: skipWaiting() + clients.claim() to activate new SW immediately; otherwise old runs until all tabs close",
            "iOS Safari PWA support limited — no push, no background sync; cache works but no install badge",
            "SW scope is path-based — /sw.js only controls /pages/*; put SW at root for full control"
          ],
          "anti_patterns": [
            "Cache-first for API responses — user sees stale data; use network-first with cache fallback",
            "Versioning SW by content hash, not path — hash changes force update but path-based is simpler",
            "Precaching 100MB of assets — first load takes 30s; precache only critical (< 5MB)",
            "Using SW for non-network things (localStorage) — no access; use IndexedDB or postMessage"
          ],
          "prereqs": [
            "1.7"
          ],
          "next_steps": [
            "8.2",
            "1.4"
          ],
          "tags": [
            "pwa",
            "service-worker",
            "offline",
            "push",
            "cache"
          ],
          "compare_with": [
            "AppCache (deprecated 2016)",
            "Workbox (Google lib, 50KB, full toolbox)",
            "Native apps (App Store, no web discoverability)"
          ]
        }
      ]
    },
    {
      "id": 2,
      "name": "Design System",
      "icon": "🎨",
      "tagline": "Visual language for consistent UI",
      "sub_phases": [
        {
          "id": "2.1",
          "title": "Color Tokens (Semantic Palette)",
          "icon": "🎨",
          "description": "Map visual colors (gray-500, blue-600) to semantic roles (text-primary, surface-raised) so theme switching and white-labeling become data-attribute changes.",
          "when_to_use": "Multi-theme product; white-label SaaS; design system spanning > 1 app; you need dark mode; brand refresh every 6 months",
          "when_not_to_use": "Single-page landing with 5 elements; one-off marketing site; prototype that ships once and dies; no theming requirement",
          "examples": "codehub-homepage, flowboard, stronghold-3d, habit-streak",
          "snippet": ":root { --color-bg:hsl(240 10% 5%); --color-text-primary:hsl(0 0% 96%); --color-accent:hsl(330 81% 60%); }",
          "snippet_full": ":root {\\n  --color-bg: hsl(240 10% 5%);\\n  --color-surface: hsl(240 10% 9%);\\n  --color-text-primary: hsl(0 0% 96%);\\n  --color-text-muted: hsl(240 5% 65%);\\n  --color-accent: hsl(330 81% 60%);\\n  --color-success: hsl(142 71% 45%);\\n  --color-warning: hsl(38 92% 50%);\\n  --color-danger: hsl(0 84% 60%);\\n  --color-border: hsl(240 6% 20%);\\n}\\n[data-theme=\\\"light\\\"] {\\n  --color-bg: hsl(0 0% 98%);\\n  --color-surface: hsl(0 0% 100%);\\n  --color-text-primary: hsl(240 10% 10%);\\n  --color-text-muted: hsl(240 5% 40%);\\n}\\n:root { color-scheme: dark light; }\\n.btn { background: var(--color-accent); color: var(--color-text-primary); border: 1px solid var(--color-border); }",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/--*",
          "pros": [
            "Theme switch = 1-line CSS change (`data-theme=dark`) — zero JS, zero rebuild;",
            "Word 'primary' survives a brand refresh where 'blue' would force a 200-file rename;",
            "WCAG contrast checks happen against semantic roles, not raw values — 1 audit covers every theme;",
            "White-label fork from 100 → 1 codebase: client picks theme JSON at deploy time;",
            "Designers and devs speak the same vocabulary in Figma Variables + code (token name parity);",
            "Accessibility audits become mechanical: scan token→pair pairs, not 5000 hex literals."
          ],
          "cons": [
            "Indirection tax — juniors grep 'primary' and miss the actual value;",
            "Token overlap ('accent' vs 'brand') confuses teams if not enforced in lint;",
            "HSL/oklch math is required to derive shades from one base — wrong math = ugly ramps;",
            "Tailwind already does this — adopting tokens on top adds a layer you must maintain;",
            "Print/email fallbacks still need raw hex — tokens leak into non-browser contexts."
          ],
          "gotchas": [
            "Pure #000 on pure #fff hits WCAG AAA but feels harsh — use #0a0a14 / #f5f5f7 for softer contrast that still passes AA (4.5:1);",
            "HSL hue rotation for variant shades shifts color identity — use oklch or HSL with locked hue; rotate lightness only for a true ramp;",
            "Form controls (input, select, checkbox) ignore CSS background by default — explicit `accent-color` and `color-scheme: dark` are required;",
            "iOS Safari auto-applies blue/yellow tint to inputs in dark mode unless `color-scheme: dark` is set on `:root`;",
            "Images baked in light mode look like flashlights in dark — apply `filter: invert(1) hue-rotate(180deg)` to <img> or use SVG;",
            "Charts (Chart.js, D3) hardcode colors — read tokens via getComputedStyle, don't import CSS into JS;",
            "Transparent tokens (`--surface: hsl(... / 50%)`) need a separate `--surface-solid` for accessibility overlays or backgrounds stack visibly;",
            "Shadow tokens don't theme — shadows look wrong on dark bg unless `--shadow-color` is also a token (alpha-included)."
          ],
          "anti_patterns": [
            "Naming tokens by color: `--blue-500` instead of `--color-primary-500` — they become useless the moment brand changes;",
            "Defining 50 shades when the ramp needs 5 (50/100/200/500/900) — every shade is a future decision you forgot;",
            "Storing tokens in JS only — sync drift between Figma and code; use CSS custom props or Style Dictionary;",
            "Using hex when HSL/oklch would let you derive variants with one knob (lightness);",
            "Skipping the 'danger/warning/info/success' family because 'the app never shows errors' — it will, in week 3."
          ],
          "prereqs": [
            "1.2"
          ],
          "next_steps": [
            "2.2",
            "2.5",
            "2.8"
          ],
          "tags": [
            "color",
            "tokens",
            "wcag",
            "design-system",
            "theme",
            "สี",
            "ธีม"
          ],
          "compare_with": [
            "1.2 (raw CSS variables without semantic naming)",
            "Tailwind config (built-in but JS-bound)",
            "Style Dictionary (multi-platform export)"
          ]
        },
        {
          "id": "2.2",
          "title": "Typography Scale (Modular)",
          "icon": "🔤",
          "description": "Pick a ratio (1.250 / 1.333 / 1.414 / 1.5) and generate a type ramp xs→5xl with consistent visual rhythm — stops decision fatigue and survives redesigns.",
          "when_to_use": "UI with > 3 distinct text sizes; design system spanning multiple pages; need consistent hierarchy in marketing + product UI; brand guidelines exist",
          "when_not_to_use": "Single landing with hero + body + footer; print/PDF-only deliverable; pure icon UI with labels < 3 words",
          "examples": "stronghold-3d, karaoke-studio, codehub-homepage, knowledge-garden",
          "snippet": ":root { --text-xs:.75rem; --text-sm:.875rem; --text-base:1rem; --text-lg:1.25rem; --text-xl:1.5rem; --text-2xl:1.875rem; --text-3xl:2.25rem; --text-4xl:3rem; }",
          "snippet_full": ":root {\\n  /* ratio 1.250 — Major Third, base 1rem (16px) */\\n  --text-xs: 0.75rem;     /* 12px */\\n  --text-sm: 0.875rem;    /* 14px — captions only, never body */\\n  --text-base: 1rem;      /* 16px — body min for mobile */\\n  --text-lg: 1.25rem;     /* 20px */\\n  --text-xl: 1.563rem;    /* 25px */\\n  --text-2xl: 1.953rem;   /* 31px */\\n  --text-3xl: 2.441rem;   /* 39px */\\n  --text-4xl: 3.052rem;   /* 49px */\\n  --leading-tight: 1.15;  /* display */\\n  --leading-normal: 1.55; /* body */\\n  --tracking-tight: -0.02em;\\n}\\nh1 { font-size: var(--text-4xl); line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }\\nbody { font-size: var(--text-base); line-height: var(--leading-normal); }\\ninput, select, textarea { font-size: max(1rem, 16px); } /* block iOS zoom */",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/font-size",
          "pros": [
            "Visual hierarchy becomes automatic — designers can't accidentally pick a font-size that breaks the rhythm;",
            "Math-driven ratio means you can add a new size (text-6xl) by multiplying, not guessing;",
            "Tailwind/utility users get the ramp for free — text-sm/base/lg/xl/2xl matches the scale;",
            "Line-height couples to font-size (tight for display, loose for body) — readability stays correct at every step;",
            "Type tokens survive brand pivots: change ratio, regenerate ramp, ship in 30 min instead of 3 days;",
            "Accessibility audits key off font-size only — semantic tokens let a11y scripts verify 16px minimum automatically."
          ],
          "cons": [
            "1.25 (Major Third) feels tight at large sizes; 1.5 feels airy — one ratio doesn't serve display + body;",
            "Body must stay ≥ 16px or hit iOS auto-zoom on focus — ratios that start at 14px fail mobile;",
            "Variable fonts can collapse the ramp into one weight axis — adopting scales duplicates what's already there;",
            "Line-length (measure) is more important than size — a perfect scale with 200-char lines still fails reading tests;",
            "Asian/CJK fonts have wider footprints — Latin scale numbers look cramped on Thai/Chinese body text."
          ],
          "gotchas": [
            "Headings < 16px on mobile trigger iOS Safari auto-zoom on input focus — keep input text ≥ 16px even if h6 is smaller;",
            "rem scales with user browser font setting — a 1.5 ratio on a user-set 20px base ≠ the design; test with browser zoom 200%;",
            "font-family swap (FOUT) reflows text — reserve width with `size-adjust` + `ascent-override` @font-face descriptors;",
            "Variable font weight axis (wght) needs explicit `font-variation-settings: 'wght' 600` — `font-weight: 600` alone may not work;",
            "Letter-spacing tightens on display sizes, loosens on captions — apply per token (`--tracking-tight: -0.02em`);",
            "Korean/Thai have no ligatures — `font-feature-settings: 'liga' 1` enables complex shaping only where supported;",
            "Don't mix serif display + sans body without a reason — two families need 2× the work in variable font licensing;",
            "Tabular numerals (`font-variant-numeric: tabular-nums`) matter for prices, timers, tables — sans tables look jittery on resize."
          ],
          "anti_patterns": [
            "Using random pixel values (13px, 17px, 23px) — kills rhythm and confuses the next dev who has to extend it;",
            "Picking a scale ratio without testing display sizes — 1.25 makes text-6xl too small for hero headlines;",
            "Setting body < 16px on mobile — iOS Safari auto-zooms inputs and breaks layout;",
            "Mixing units (px + rem + em) in one scale — rem for everything is the 2024 default;",
            "Ignoring measure (line-length): 80 chars max for body, 30-40 for captions — perfect scale with bad measure still reads badly."
          ],
          "prereqs": [
            "1.2"
          ],
          "next_steps": [
            "2.4",
            "2.5"
          ],
          "tags": [
            "typography",
            "scale",
            "type",
            "type-ramp",
            "อักษร",
            "ฟอนต์",
            "modular"
          ],
          "compare_with": [
            "Tailwind text-* utilities (built-in 1.25 ramp)",
            "Type Scale (type-scale.com) for visual math",
            "Utopia.fyi (fluid clamp() generator)"
          ]
        },
        {
          "id": "2.3",
          "title": "Spacing Tokens (8pt Grid)",
          "icon": "📏",
          "description": "All margin/padding/gap values from a fixed scale (4/8/12/16/24/32/48/64/96) so every gap is divisible by 4 — pixel-snaps cleanly on retina and aligns across teams.",
          "when_to_use": "Multi-dev team; design system; pixel-perfect mockups in Figma; want pixel-perfect comparison to design files",
          "when_not_to_use": "Single-dev weekend prototype; canvas/3D game where units are world-space not screen-space; pixel-art where 1px = 1 unit",
          "examples": "codehub-homepage, stronghold-3d, flowboard",
          "snippet": ":root { --space-1:4px; --space-2:8px; --space-4:16px; --space-6:24px; --space-8:32px; --space-12:48px; --radius-md:12px; --radius-pill:9999px; }",
          "snippet_full": ":root {\\n  --space-0: 0;\\n  --space-1: 0.25rem;  /*  4px */\\n  --space-2: 0.5rem;   /*  8px */\\n  --space-3: 0.75rem;  /* 12px */\\n  --space-4: 1rem;     /* 16px */\\n  --space-6: 1.5rem;   /* 24px */\\n  --space-8: 2rem;     /* 32px */\\n  --space-12: 3rem;    /* 48px */\\n  --space-16: 4rem;    /* 64px */\\n  --radius-sm: 6px;\\n  --radius-md: 12px;\\n  --radius-lg: 20px;\\n  --radius-pill: 9999px;\\n}\\n.card { padding: var(--space-4); border-radius: var(--radius-md); }\\n.btn { padding: var(--space-2) var(--space-4); gap: var(--space-2); }\\n.stack > * + * { margin-top: var(--space-4); }",
          "difficulty": "Beginner",
          "time_to_learn": "15 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/padding",
          "pros": [
            "All values divisible by 4 → pixel-snaps to whole pixels on 1x, 2x, 3x displays (no fuzzy 1.5px gaps);",
            "Dev ↔ designer parity: Figma '8pt grid' plugin produces the exact same token names as CSS variables;",
            "Conversion to rem (16px base) becomes trivial: --space-4:1rem; --space-8:2rem; user-zoom respects scale;",
            "Visual rhythm reads as intentional, not accidental — every gap has a reason because the scale is finite;",
            "Tailwind's 4/8/16/24/32/48 ramp matches this 1:1 — migrating tokens to utilities is zero-work;",
            "Catches rogue 13px / 17px / 23px paddings in code review — lint rule `margin: ! \\d+(px)` rejects them."
          ],
          "cons": [
            "8px is too coarse for tight UI (button icon gap of 6px would round to 4 or 8 and feel wrong);",
            "Half-step tokens (--space-1: 4px, --space-2: 8px) still leak — some teams need 6px for icon-text baselines;",
            "Some components naturally break the grid (1px borders, 0.5px hairlines) — tokens don't replace those;",
            "Figma grids are 8pt but engineering reality includes iOS safe-area insets (34pt) that are off-grid;",
            "Adopting on top of an existing codebase means sed-replacing 1000+ literals — migration cost is real."
          ],
          "gotchas": [
            "Padding inside small buttons (< 32px tall) needs --space-2 (8px), not --space-4 (16px) — the scale serves size, not just visual rhythm;",
            "iOS safe-area-inset-* (notch, home indicator) returns 34pt / 21pt — off-grid by design; wrap in `env(safe-area-inset-bottom)` not raw token;",
            "border-radius follows its own scale (--radius-sm/md/lg/pill) — coupling it to spacing makes pills wrong (9999px ≠ 64);",
            "Stacked gaps with margin collapse: use `gap` on flex/grid (no collapse) instead of margin + padding pairs;",
            "Negative margins for overlapping layouts (`margin-left: -16px`) need negative tokens (`--space-neg-4: -1rem`) or raw values;",
            "Letter-spacing creates optical spacing — don't tighten scale to compensate for tracked text;",
            "Charts: bar gap ≠ card gap ≠ list-item gap — domain-specific scale (--chart-bar-gap: 4px) sometimes beats global;",
            "Vertical rhythm via `line-height: 1.5 * font-size` should hit grid — at 16px base, 24px line = --space-6, perfect alignment."
          ],
          "anti_patterns": [
            "Random 13/17/23 values — the whole point of the scale is to forbid these;",
            "One token per layout (`--hero-padding: 87px`) — bespoke values break the system;",
            "Mixing px and rem in one scale — pick rem (user-zoom-aware) and commit;",
            "Defining --space-3, --space-5, --space-7 to fill gaps — every gap is a decision you have to defend;",
            "Skipping radius/elevation tokens because 'spacing is enough' — those are separate scales that share the grid concept."
          ],
          "prereqs": [
            "1.2"
          ],
          "next_steps": [
            "2.4",
            "2.10"
          ],
          "tags": [
            "spacing",
            "grid",
            "8pt",
            "rhythm",
            "ระยะ",
            "กริด",
            "tokens"
          ],
          "compare_with": [
            "Tailwind 4/8/16/24/32 default scale",
            "Material Design 4dp/8dp grid",
            "Bootstrap 5 spacing utilities"
          ]
        },
        {
          "id": "2.4",
          "title": "Component Library",
          "icon": "🧱",
          "description": "Build Button, Card, Modal, Input, Toast, Chip, Tabs, Tooltip once with variants (size/state/elevation) and reuse — atoms become the team's shared vocabulary.",
          "when_to_use": "App > 3 pages; team ≥ 2 devs; any project that ships v2+; you find yourself copy-pasting the same button class",
          "when_not_to_use": "Single-page marketing site; one-shot landing page; prototype that lives < 1 week; you have < 10 components total",
          "examples": "codehub-homepage, flowboard, knowledge-garden, eventtix",
          "snippet": ".card { background:var(--color-surface); border-radius:var(--radius-lg); padding:var(--space-4); transition:transform .2s; } .card:hover { transform:translateY(-2px); }",
          "snippet_full": "/* Button component with variants via data-attribute */\\n.btn {\\n  display: inline-flex; align-items: center; gap: var(--space-2);\\n  padding: var(--space-2) var(--space-4);\\n  border: 1px solid transparent; border-radius: var(--radius-md);\\n  font: inherit; font-weight: 600; cursor: pointer;\\n  transition: background .15s, transform .1s;\\n}\\n.btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }\\n.btn[data-variant=\\\"primary\\\"] { background: var(--color-accent); color: white; }\\n.btn[data-variant=\\\"ghost\\\"] { background: transparent; color: var(--color-text-primary); }\\n.btn[data-size=\\\"sm\\\"] { padding: var(--space-1) var(--space-2); font-size: var(--text-sm); }\\n.btn[aria-busy=\\\"true\\\"] { opacity: .6; cursor: progress; }\\n/* Modal via native <dialog> */\\ndialog { border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-6); background: var(--color-surface); }\\ndialog::backdrop { background: rgb(0 0 0 / 60%); backdrop-filter: blur(4px); }",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog",
          "pros": [
            "Write button once, use 1000× — accessibility (focus ring, ARIA, keyboard) lives in one place, not 47 places;",
            "Visual regression tests (Percy/Chromatic) lock one Button.png — every change is reviewed against baseline;",
            "Onboarding new devs: teach 8 components, they ship features; don't teach 800 CSS rules;",
            "Bug fix in one component fixes every screen — saves 10× the work a fragmented codebase costs;",
            "Storybook/Histoire dev mode gives QA/designers a sandbox without engineering deploying;",
            "Theme/variant via data-attributes (data-variant=danger, data-size=sm) — JS reads CSS, not the other way around."
          ],
          "cons": [
            "Up-front cost: 4–8 hours per component is normal before any page uses it — feels unproductive;",
            "Over-abstraction: a 'universal' Form component that handles 12 use-cases becomes a 600-line monster nobody dares touch;",
            "Variant explosion: Button × 5 sizes × 5 variants × 3 states = 75 CSS permutations to maintain;",
            "Lock-in to choices made early (border vs shadow elevation) — pivoting is a 2-day rewrite, not a 1-hour swap;",
            "Component-only thinking misses composition — a Modal is fine but a Modal+Toast+Form might have layout bugs you never tested."
          ],
          "gotchas": [
            "Use native `<dialog>` for modals — gets focus trap, ESC-to-close, backdrop for free; `showModal()` + `::backdrop` CSS;",
            "Toast notifications need an aria-live region (`role=status` or `aria-live=polite`) — without it, screen readers miss them;",
            "Tabs use `role=tablist` + `role=tab` + `aria-selected` + arrow-key navigation — manual is 30 lines, library is 1 import;",
            "Inputs need explicit `<label for>` — placeholder-as-label fails accessibility audits and disappears on focus;",
            "Disabled buttons (`disabled`) lose focusability — for 'loading' states use `aria-busy=true` + visually disable but keep focusable;",
            "`<button>` vs `<a>`: use button for actions (no URL change), anchor for navigation (right-click → new tab works);",
            "Focus rings: never `outline: none` without replacement — `:focus-visible` ring with 2px solid + offset is the modern default;",
            "Shadow elevation: keep depth consistent — 1 layer of shadow + border beats 3 nested shadows that look muddy on retina."
          ],
          "anti_patterns": [
            "Component-per-pixel-figma: one component per mockup without extracting shared atoms (3 button styles become 3 components that should be 1 with variants);",
            "Wrapper-only components: `<Card><Card.Body><Card.Title>` when a plain `<div class=card>` with utility classes is enough;",
            "JS-driven everything: a button that needs JS to toggle a class is a `<details>` or checkbox under the hood;",
            "Inconsistent naming: Btn/Button/PrimaryButton in same codebase — pick one (Button is the React-y default) and lint;",
            "No prop API contract: component accepts 12 string props that should be 2 enums — over-flexibility is over-bug-surface."
          ],
          "prereqs": [
            "2.1",
            "2.3"
          ],
          "next_steps": [
            "2.5",
            "2.6"
          ],
          "tags": [
            "components",
            "library",
            "design-system",
            "ui",
            "atomic",
            "ชิ้นส่วน"
          ],
          "compare_with": [
            "Headless UI / Radix (unstyled accessible primitives)",
            "shadcn/ui (copy-paste component recipes)",
            "Storybook (visual dev environment for any lib)"
          ]
        },
        {
          "id": "2.5",
          "title": "Animation Library (Easing + Duration Tokens)",
          "icon": "✨",
          "description": "Tokenize easing curves (ease-out for enter, ease-in for exit) and durations (100/200/300/500ms) so every motion across the app feels consistent and respects prefers-reduced-motion.",
          "when_to_use": "UI feels static; want premium feel; need visual feedback on hover/click; modals/sheets/toasts in/out",
          "when_not_to_use": "Decoration only (bouncing logos that add no information); users with `prefers-reduced-motion: reduce`; data-dense dashboards where motion hides info; 60fps gameplay (use rAF, not CSS)",
          "examples": "karaoke-studio, stronghold-3d, eventtix, flowboard",
          "snippet": ":root { --ease-out:cubic-bezier(0.16,1,0.3,1); --duration-normal:250ms; } @media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; } }",
          "snippet_full": ":root {\\n  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);     /* enter — fast start, soft land */\\n  --ease-in: cubic-bezier(0.7, 0, 0.84, 0);      /* exit — slow start, fast leave */\\n  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* spatial movement */\\n  --duration-fast: 150ms;\\n  --duration-normal: 250ms;\\n  --duration-slow: 400ms;\\n}\\n@keyframes bounce-in {\\n  0%   { opacity: 0; transform: scale(0.92); }\\n  60%  { transform: scale(1.04); }\\n  100% { opacity: 1; transform: scale(1); }\\n}\\n.modal { animation: bounce-in var(--duration-normal) var(--ease-out) both; }\\n.btn:active { transform: scale(0.97); transition: transform 80ms var(--ease-in); }\\n@media (prefers-reduced-motion: reduce) {\\n  *, *::before, *::after {\\n    animation-duration: 0.01ms !important;\\n    transition-duration: 0.01ms !important;\\n    scroll-behavior: auto !important;\\n  }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/animation",
          "pros": [
            "GPU-accelerated transforms (translate/scale/rotate) + opacity hit 60fps even on mid-range Android — width/height/margin animations don't;",
            "Tokens for duration/easing mean every component speaks the same motion language — no awkward 173ms transitions;",
            "`prefers-reduced-motion` media query is 1 line to disable — a11y audit passes, vestibular-disorder users are safe;",
            "Easing > duration for perceived quality: same 250ms with `cubic-bezier(0.16,1,0.3,1)` feels 2× more premium than `ease`;",
            "Web Animations API (WAAPI) lets you pause/seek/reverse without state libraries — `element.animate(keyframes, options)`;",
            "Reduced motion fallback (duration: 0.01ms) doesn't lose state — opacity:0 element still appears, just instantly."
          ],
          "cons": [
            "Bouncy animations on primary CTAs delay action by 200ms — every delay is a UX tax on users in a hurry;",
            "backdrop-filter + transform combo on Safari can drop to 15fps — test on real iPhone, not just Mac Safari;",
            "Auto-playing animations on page load violate WCAG 2.2.2 — pause/play controls required for > 5s loops;",
            "CSS @keyframes can't be triggered programmatically without adding/removing classes — JS event → class → animation is brittle;",
            "Stagger animations (> 5 children with delays) pile up in compositor memory — laptops throttle after ~50 concurrent."
          ],
          "gotchas": [
            "Animate `transform` and `opacity` only — they run on the compositor; animating `top/left/width/height` triggers layout every frame;",
            "`will-change: transform` is a hint, not a guarantee — overuse creates new compositor layers that exhaust GPU memory (200MB+);",
            "`cubic-bezier(0.16, 1, 0.3, 1)` ('ease-out-expo') for enter, `cubic-bezier(0.7, 0, 0.84, 0)` ('ease-in-quart') for exit — symmetric ease looks mechanical;",
            "`prefers-reduced-motion: reduce` must set animation-duration to ~0.01ms (not 0 — animation events may not fire at 0);",
            "iOS Safari pauses CSS animations when tab is backgrounded — resume by listening to `visibilitychange` and re-applying class;",
            "Transform on SVG elements uses different origin than HTML — `transform-origin: center` works, but nested SVG transforms compound;",
            "Stagger via `transition-delay: calc(var(--i) * 50ms)` with `--i: 0..n` set inline — cleaner than nth-child arithmetic;",
            "Scroll-driven animations (CSS Scroll-Linked Animations, 2024+) require `animation-timeline: scroll()` — not yet in Safari stable."
          ],
          "anti_patterns": [
            "Animating `box-shadow` for hover effects — triggers paint; animate `background` color or use a pre-rendered shadow layer;",
            "Same duration for enter and exit (250ms in, 250ms out) — feels slower because user perceives exit first; use 200ms in / 150ms out;",
            "Long animation chains: button press → ripple → modal open → toast slide → page transition = 1.5s of motion before user sees content;",
            "Bounce/easing on text (letters wobble) — distracts from reading; reserve motion for spatial changes;",
            "No reduced-motion fallback — a vestibular-disorder user literally cannot use your app, and an a11y audit will fail."
          ],
          "prereqs": [
            "2.4"
          ],
          "tags": [
            "animation",
            "motion",
            "easing",
            "duration",
            "prefers-reduced-motion",
            "อนิเมชัน",
            "เคลื่อนไหว"
          ],
          "compare_with": [
            "Framer Motion / Motion One (JS-driven, larger)",
            "GSAP (timeline-based, expensive license)",
            "Pure CSS @keyframes (zero-dep, what we recommend)"
          ]
        },
        {
          "id": "2.6",
          "title": "Icon System (Emoji + SVG)",
          "icon": "🎯",
          "description": "Pick emoji for zero-byte speed or inline SVG for crisp controllable icons — never both in one UI; currentColor in SVG makes them themable with no extra classes.",
          "when_to_use": "Every UI; nav menus; status indicators; button labels; social/CTA icons",
          "when_not_to_use": "Brand-specific icons that match a system (Material Symbols, Phosphor, Heroicons) — those bring consistency you don't want to fight; pure emoji-only apps where OS variance is acceptable",
          "examples": "stronghold-3d, karaoke-studio, flowboard, mood-garden",
          "snippet": "<svg width=20 height=20 viewBox='0 0 24 24' fill='none' stroke=currentColor stroke-width=2><path d='M12 5v14M5 12l7 7 7-7'/></svg>",
          "snippet_full": "<!-- icons.svg — single sprite, fetch once and cache forever -->\\n<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" style=\\\"display:none\\\">\\n  <symbol id=\\\"i-home\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"none\\\" stroke=\\\"currentColor\\\" stroke-width=\\\"2\\\" stroke-linecap=\\\"round\\\" stroke-linejoin=\\\"round\\\">\\n    <path d=\\\"M3 12l9-9 9 9M5 10v10h14V10\\\"/>\\n  </symbol>\\n  <symbol id=\\\"i-search\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"none\\\" stroke=\\\"currentColor\\\" stroke-width=\\\"2\\\">\\n    <circle cx=\\\"11\\\" cy=\\\"11\\\" r=\\\"7\\\"/><path d=\\\"m21 21-4.3-4.3\\\"/>\\n  </symbol>\\n</svg>\\n<!-- usage: -->\\n<button class=\\\"btn\\\">\\n  <svg class=\\\"icon\\\" aria-hidden=\\\"true\\\"><use href=\\\"#i-search\\\"/></svg>\\n  <span>Search</span>\\n</button>\\n<style>.icon { width: 1.25em; height: 1.25em; }</style>",
          "difficulty": "Beginner",
          "time_to_learn": "15 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use",
          "pros": [
            "Emoji cost 0 bytes and inherit system font (no font-load blocking) — fastest possible icon;",
            "Inline SVG with `fill='currentColor'` or `stroke='currentColor'` inherits text color — theme switching just works;",
            "SVG scales to any size without raster artifacts; emoji at 96px+ looks pixely on some OS;",
            "SVG `<path>` data is text — grep-able, version-controllable, optimizable via SVGO;",
            "Single SVG sprite (1 HTTP request) reused via `<svg><use href=#icon-home>` beats 50 icon requests;",
            "Stroke-width consistency (1.5 / 2) across an icon family reads as designed-by-one-hand; mixing weights looks amateur."
          ],
          "cons": [
            "Emoji render differently across OS — 🍑 on iOS ≠ Windows ≠ Android (color emoji vs text); brand suffers;",
            "Inline SVG bloats HTML — 30 icons inline = 30KB in DOM (sprite + use is 30KB once, then 100B per use);",
            "Emoji in <input> or <button> text aligns weirdly — `vertical-align: -0.1em` fixes most cases;",
            "Lucide/Phosphor add 50–200KB — for 10 icons, hand-rolled inline SVG is smaller;",
            "Emoji accessibility: screen readers read '🎉' as 'party popper' which is rarely the intent — wrap with `<span aria-label>`."
          ],
          "gotchas": [
            "Apple Color Emoji vs Segoe UI Emoji vs Noto Color Emoji — same codepoint renders 3 different glyphs; test on each;",
            "SVG `width=20 height=20` attributes are CSS-overridable but the SVG won't resize if it has a viewBox without preserveAspectRatio tweak;",
            "`<svg>` inside `<button>` is fine, but the SVG must have `aria-hidden=true` so screen readers don't announce 'image';",
            "Stroke alignment: 24×24 grid with 2px stroke at center means visual weight differs from 2px stroke on 16×16; scale icons together;",
            "Some emoji have hidden variation selectors (️ U+FE0F) that change text → emoji presentation — copy-paste from emoji keyboards, not codepoints;",
            "Animated SVG icons (`<animate>` element inside SVG) trigger compositor differently than CSS — sometimes jankier on iOS;",
            "Icon font ligatures (`:before { content: '\\e001' }`) lose to inline SVG on accessibility — no text fallback if font fails;",
            "`<use href='#icon'>` requires CORS-friendly URLs for cross-origin SVGs — same-origin sprites work without headers."
          ],
          "anti_patterns": [
            "Mixing emoji + SVG in the same UI (🎵 for play but SVG for pause) — visual chaos; pick one and commit;",
            "Icon font (FontAwesome) for 5 icons when inline SVG is 1KB total — 80KB of font for 5 glyphs is wasteful;",
            "Random stroke widths (1px outline icon next to 3px filled icon) — pick a weight (1.5 / 2) and apply via stroke-width CSS var;",
            "Color-locking icons (`fill='#000'`) — theming breaks; use `fill='currentColor'` and let CSS cascade;",
            "Emoji-as-button: `👍` to 'like' is OS-dependent and screenshotted differently by every user — design a button, not an emoji vote."
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "2.4"
          ],
          "tags": [
            "icons",
            "svg",
            "emoji",
            "sprite",
            "ไอคอน"
          ],
          "compare_with": [
            "Lucide (1500+ SVG icons, tree-shakeable)",
            "Phosphor Icons (6 weights, flexible family)",
            "Heroicons (Tailwind-made, MIT)",
            "Material Symbols (Google, variable axes)"
          ]
        },
        {
          "id": "2.7",
          "title": "Responsive Breakpoints (Mobile-first)",
          "icon": "📱",
          "description": "Define min-width breakpoints (640/768/1024/1280) and write styles for mobile, then layer on `min-width` overrides — desktop styles inherit unless contradicted.",
          "when_to_use": "Public-facing app; mobile traffic > 30%; SaaS that signs up users on phones; any B2C product",
          "when_not_to_use": "Internal tool desktop-only; admin dashboard restricted to ops staff on workstations; kiosk/embedded UI",
          "examples": "karaoke-studio, codehub-homepage, flowboard, meal-plan",
          "snippet": ".grid { display:grid; gap:var(--space-4); grid-template-columns:1fr; } @media (min-width:640px) { .grid { grid-template-columns:repeat(2,1fr); } } @media (min-width:1024px) { .grid { grid-template-columns:repeat(4,1fr); } }",
          "snippet_full": "/* Mobile-first: base = 1 column phone, scale up */\\n.grid {\\n  display: grid;\\n  gap: var(--space-4);\\n  grid-template-columns: 1fr;\\n  padding: var(--space-4);\\n}\\n@media (min-width: 640px)  { .grid { grid-template-columns: repeat(2, 1fr); padding: var(--space-6); } }\\n@media (min-width: 1024px) { .grid { grid-template-columns: repeat(4, 1fr); padding: var(--space-8); } }\\n/* Touch-friendly targets */\\n.btn { min-height: 44px; min-width: 44px; padding: var(--space-3) var(--space-4); }\\n/* Hover only where hover exists */\\n@media (hover: hover) {\\n  .card:hover { transform: translateY(-2px); }\\n}\\n/* Viewport meta — required */\\n/* <meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1, viewport-fit=cover\\\"> */\\n.safe-top    { padding-top:    env(safe-area-inset-top); }\\n.safe-bottom { padding-bottom: env(safe-area-inset-bottom); }",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries",
          "pros": [
            "Mobile-first forces performance discipline — base CSS is what phones download; desktop gets progressive enhancement;",
            "min-width queries are easier to reason about: 'at 768px AND UP, show 2 columns' beats 'at < 1024px, show 1 column';",
            "Tailwind/Bootstrap defaults match this (sm/md/lg/xl/2xl) — no custom breakpoint debate;",
            "Container queries (2023+) extend this to components — `.card { @container (min-width: 400px) { flex-direction: row } }`;",
            "One CSS file works across 320px iPhone SE → 4K monitor — no UA sniffing, no separate m.example.com;",
            "Touch targets ≥ 44×44 px (Apple HIG) become a checklist item at mobile breakpoint, not an afterthought."
          ],
          "cons": [
            "min-width cascades can hide desktop bugs — dev laptop at 1280px sees both, but a 1366px user sees overflow;",
            "Tablet portrait (768px) is awkward — too narrow for desktop, too wide for phone layout;",
            "Container queries need polyfill on iOS 15 (works in 16+) — feature-detect with @supports;",
            "Hover-only interactions (`:hover` reveals menu) break on touch — must pair with `:focus-within` or click;",
            "Bandwidth cost: base mobile CSS is small, but if you ship desktop images with `display:none` on mobile, they still download."
          ],
          "gotchas": [
            "Viewport meta tag: `<meta name=viewport content='width=device-width, initial-scale=1'>` — without it, mobile browsers render at 980px and zoom out;",
            "iOS Safari 100vh = URL bar height + bottom bar — use `100dvh` (dynamic viewport) or `min-height: calc(100vh - env(safe-area-inset-bottom))`;",
            "Touch target size: 44×44 px minimum (Apple) / 48×48 dp (Android) — buttons that LOOK 32px but have larger padding still fail if click area is small;",
            "`<input>` font-size < 16px triggers iOS auto-zoom on focus — even on a desktop viewport in mobile Safari;",
            "Landscape phone (568px height) breaks 100vh layouts — test both orientations, not just portrait;",
            "Landscape iPad split-view (320px / 1024px) needs container queries, not viewport media queries;",
            "Hover states on touch devices get 'sticky' (tap shows hover, doesn't dismiss until tap elsewhere) — `@media (hover: hover)` gates them;",
            "Save-data header (`@media (prefers-reduced-data: reduce)`) lets users opt out of heavy assets — serve 1× images instead of 2×;",
            "1px CSS ≠ 1 device pixel — retina is 3× or 4×; use `image-set()` or `srcset` for bitmap assets."
          ],
          "anti_patterns": [
            "Desktop-first (max-width) queries — reverses the cascade and adds cognitive load;",
            "Custom breakpoints that don't match Tailwind/Bootstrap (sm=620 instead of 640) — engineers will guess wrong;",
            "Hiding content with `display:none` instead of `loading=lazy` + responsive images — wastes bandwidth on phones;",
            "Designing for one device (iPhone 14 Pro) — test on cheapest Android in your analytics top-5 countries;",
            "Forgetting landscape phone — 568px tall × 100vh header + footer + content = 0 scrollable pixels."
          ],
          "prereqs": [
            "2.3"
          ],
          "next_steps": [
            "2.10"
          ],
          "tags": [
            "responsive",
            "mobile",
            "breakpoints",
            "media-queries",
            "mobile-first",
            "มือถือ",
            "เบรกพอยต์"
          ],
          "compare_with": [
            "Tailwind sm/md/lg/xl/2xl (utility-driven)",
            "Bootstrap breakpoints (5 standard steps)",
            "Container Queries (component-level, 2023+)"
          ]
        },
        {
          "id": "2.8",
          "title": "Dark Mode (prefers-color-scheme + Manual Toggle)",
          "icon": "🌙",
          "description": "Read system preference via `prefers-color-scheme`, let user override via toggle, persist choice in localStorage — three sources of truth must be reconciled in order: explicit > saved > system.",
          "when_to_use": "Long-session apps (editor, dashboard, chat); users read in bed; OLED phones (battery savings); professional tool users expect it",
          "when_not_to_use": "Brand-mandatory colors (banking trust colors, medical UI); print-primary deliverables; one-shot landing",
          "examples": "flowboard, stronghold-3d, codehub-homepage, memo-ai",
          "snippet": "@media (prefers-color-scheme:dark) { :root { --bg:#0a0a14; --text:#f5f5f7; } } [data-theme=dark] { --bg:#0a0a14; --text:#f5f5f7; } const setTheme=t=>{document.documentElement.dataset.theme=t;localStorage.setItem('theme',t);};",
          "snippet_full": "/* === Inline script in <head> to prevent flash of wrong theme === */\\n<script>\\n  (function() {\\n    const saved = localStorage.getItem('theme');\\n    const sys = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\\n    const theme = saved || sys;\\n    document.documentElement.dataset.theme = theme;\\n  })();\\n</script>\\n\\n/* === CSS === */\\n:root { color-scheme: light dark; --bg: hsl(0 0% 98%); --text: hsl(240 10% 10%); }\\n@media (prefers-color-scheme: dark) {\\n  :root { --bg: hsl(240 10% 5%); --text: hsl(0 0% 96%); }\\n}\\n[data-theme=\\\"dark\\\"]  { --bg: hsl(240 10% 5%); --text: hsl(0 0% 96%); }\\n[data-theme=\\\"light\\\"] { --bg: hsl(0 0% 98%); --text: hsl(240 10% 10%); }\\n\\n/* === Toggle handler === */\\nconst setTheme = t => {\\n  document.documentElement.dataset.theme = t;\\n  localStorage.setItem('theme', t);\\n  window.dispatchEvent(new CustomEvent('themechange', { detail: t }));\\n};\\n// listen for OS theme change\\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {\\n  if (!localStorage.getItem('theme')) setTheme(e.matches ? 'dark' : 'light');\\n});",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme",
          "pros": [
            "OLED phones save 30–60% battery on true-black UI — measurable drain reduction;",
            "Reduces eye strain in low-light reading — users stay in app 25% longer (medium-published 2019 study);",
            "`prefers-color-scheme` respects OS-level accessibility settings — honors user choice automatically;",
            "Manual override + localStorage = 3 layers (system, saved, explicit) — UI always shows what user picked;",
            "Implementation is mostly CSS — `@media (prefers-color-scheme: dark) { :root { ... } }` works in all modern browsers;",
            "Dark mode often reveals bad contrast — building it forces an a11y audit you should have done anyway."
          ],
          "cons": [
            "Every color must be defined twice (light + dark) — 2× design work, 2× QA surface, 2× screenshot tests;",
            "Chart libraries (Chart.js, D3) hardcode colors — read CSS vars at chart creation, but redraw on theme change is non-trivial;",
            "Images baked in light mode look wrong in dark — need `prefers-color-scheme` swap, filter inversion, or two assets;",
            "Form controls have OS-specific styles — `color-scheme: dark` is needed to make selects/inputs render dark on iOS;",
            "Pure #000 / pure #fff fail the 'looks right' test — true design requires off-black (#0a0a14) and off-white (#f5f5f7)."
          ],
          "gotchas": [
            "`color-scheme: dark light` on `:root` makes form controls, scrollbars, and `<input type=date>` pick up dark — without it, iOS Safari shows white inputs on dark bg;",
            "Pure `#000` looks like a hole — use `hsl(240 10% 5%)` or `#0a0a14`; pure `#fff` is harsh on eyes — `#f5f5f7` is friendlier;",
            "Shadows are invisible on dark — switch to borders or colored glow (`box-shadow: 0 0 0 1px rgba(255,255,255,.1)`) for elevation in dark mode;",
            "Images: `<picture>` with `<source media=(prefers-color-scheme: dark) srcset=dark.png>` swaps assets; CSS `filter: invert(1) hue-rotate(180deg)` is hackier but 0 extra HTTP;",
            "System preference can change at runtime (OS theme switch) — listen with `matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...)`;",
            "Persist explicit user choice in localStorage with key `theme`; resolve order: localStorage > system > fallback light;",
            "Flash of wrong theme (FOWT) on page load — inline a tiny `<script>` in `<head>` that sets `data-theme` before CSS parses;",
            "Charts using canvas need to be redrawn when theme changes — listen for theme-change custom event and call chart.update();",
            "Code blocks (syntax highlighting) need a separate dark theme — Shiki/Prism both ship light + dark, switch via attribute selector."
          ],
          "anti_patterns": [
            "Auto-detect only (no manual toggle) — users on shared computers can't override; respect their preference;",
            "Using pure #000 — looks like a void; use off-black for depth;",
            "Two completely different palettes (light = blue, dark = orange) — defeats the purpose of theming; keep semantic roles consistent;",
            "Forgetting to test form controls — iOS Safari defaults to white inputs on dark bg unless `color-scheme: dark`;",
            "Persisting in sessionStorage — survives the session, vanishes on tab close; localStorage is the right tier for theme."
          ],
          "prereqs": [
            "1.2",
            "2.1"
          ],
          "tags": [
            "dark-mode",
            "theme",
            "prefers-color-scheme",
            "oled",
            "โหมดมืด",
            "ธีม"
          ],
          "compare_with": [
            "next-themes (React)",
            "Theme UI (JS-driven theming)",
            "Manual CSS-only (what we recommend for vanilla projects)"
          ]
        },
        {
          "id": "2.9",
          "title": "Glassmorphism (Frosted Glass Overlays)",
          "icon": "💎",
          "description": "Use `backdrop-filter: blur()` + semi-transparent background + 1px white border for premium frosted-glass panels that hint at content behind — a Material/iOS design staple.",
          "when_to_use": "Overlays on busy backgrounds (3D scene, video, photo); iOS-style sheets/modals; hero sections with content underneath; premium product positioning",
          "when_not_to_use": "Print-friendly pages; form-heavy input flows where contrast matters for legibility; low-end devices (Android Go, 4-year-old phones); text-heavy content where readability > style",
          "examples": "stronghold-3d, karaoke-studio, sj88-video-editor-pro, eventtix",
          "snippet": ".glass { background:rgba(255,255,255,.1); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); border:1px solid rgba(255,255,255,.2); }",
          "snippet_full": ".glass {\\n  background: rgba(255, 255, 255, 0.08);\\n  backdrop-filter: blur(12px) saturate(180%);\\n  -webkit-backdrop-filter: blur(12px) saturate(180%);\\n  border: 1px solid rgba(255, 255, 255, 0.18);\\n  border-radius: var(--radius-lg);\\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);\\n}\\n.glass-dark {\\n  background: rgba(10, 10, 20, 0.55);\\n  color: hsl(0 0% 96%);\\n}\\n/* Low-end / accessibility fallback */\\n@media (prefers-reduced-transparency: reduce) {\\n  .glass {\\n    backdrop-filter: none;\\n    -webkit-backdrop-filter: none;\\n    background: var(--color-surface);\\n  }\\n}\\n/* Safe-area aware sheet */\\n.sheet {\\n  padding: var(--space-6);\\n  padding-bottom: max(var(--space-6), env(safe-area-inset-bottom));\\n}",
          "difficulty": "Beginner",
          "time_to_learn": "20 min",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter",
          "pros": [
            "Implies depth/hierarchy without literal shadows — UI reads as layered, modern, intentional;",
            "`backdrop-filter: blur()` runs on GPU (compositor layer) — keeps main thread free for app logic;",
            "Border highlight (1px rgba(255,255,255,.18)) sells the glass illusion more than blur radius does;",
            "Native iOS / macOS use this exact pattern — users have trained visual literacy;",
            "Content behind tells users 'there's more here' — better than opaque modals that trap focus;",
            "Pairs naturally with dark mode (10% white over dark = subtle, premium feel)."
          ],
          "cons": [
            "Performance: `backdrop-filter` triggers a new compositor layer per element — 20 glass panels = 20 layers, GPU memory spikes;",
            "Low-end Android (pre-2020 Mali GPUs) can drop to 5–10 fps when 5+ glass elements are visible;",
            "Contrast nightmare: white-on-light-photo text disappears; always pair with text-shadow or text on a separate dark band;",
            "Safari needs `-webkit-backdrop-filter` AND modern syntax — older Safari (pre-18) renders wrong;",
            "Print styles: `backdrop-filter` is invisible in PDF/print — explicit fallback bg color required for `@media print`."
          ],
          "gotchas": [
            "Always declare `-webkit-backdrop-filter` before `backdrop-filter` — Safari < 18 ignores unprefixed; `-moz` was never needed;",
            "Blur radius sweet spot is 8–16px; > 24px looks fake (gaussian stack overflows) and tanks perf;",
            "Background opacity must be 5–15% on dark bg, 40–60% on light bg — too transparent = invisible, too opaque = not glass;",
            "Add `border: 1px solid rgba(255,255,255,.18)` — without it, glass edge looks ragged against varying backgrounds;",
            "iOS notch + safe-area: glass sheet must include `padding-bottom: env(safe-area-inset-bottom)` or home indicator overlaps;",
            "`backdrop-filter` doesn't blur elements ABOVE the glass — only those below in stacking order;",
            "Detect low-end devices: `@media (prefers-reduced-transparency: reduce) { .glass { backdrop-filter: none; background: var(--color-surface); } }` (Safari);",
            "Inside a transformed parent (`transform: translateZ(0)`), backdrop-filter can fail entirely — promote to its own layer;"
          ],
          "anti_patterns": [
            "Glass over glass (nested panels) — multiple compositor layers stack and slow to single-digit fps;",
            "Glass containing dense text without a contrast layer — a11y fails on WCAG 1.4.3 (4.5:1);",
            "Blur radius 40+ — looks like an Instagram filter, not a design choice;",
            "Mixing glass with flat opaque panels in same UI — visual inconsistency, looks accidental;",
            "Applying glass to entire body bg — wipes GPU memory on first paint, scroll stutters for 3 seconds."
          ],
          "prereqs": [
            "2.4"
          ],
          "tags": [
            "glass",
            "glassmorphism",
            "blur",
            "backdrop-filter",
            "frosted",
            "กระจกฝ้า"
          ],
          "compare_with": [
            "Solid card with shadow (cheaper, more accessible)",
            "Frosted image (manual gaussian via filter:blur)",
            "Native iOS UIVisualEffectView (Apple-only)"
          ]
        },
        {
          "id": "2.10",
          "title": "Layout Primitives (Grid, Flex, Stack, Cluster)",
          "icon": "📐",
          "description": "Compose layouts from 4 named primitives — Grid (2D), Flex (1D), Stack (vertical w/ consistent gap), Cluster (horizontal wrap) — instead of inventing per-component magic numbers.",
          "when_to_use": "Every layout; any time you're tempted to write `position: absolute` to align two things; you want RTL support for free",
          "when_not_to_use": "Single absolutely-positioned element (1-off); canvas/WebGL scenes where layout is in world-space; print stylesheet",
          "examples": "codehub-homepage, flowboard, knowledge-garden, eventtix",
          "snippet": ".grid { display:grid; gap:var(--space-4); grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); } .stack > * + * { margin-top: var(--space-4); }",
          "snippet_full": "/* === Grid: 2D responsive layout, no media queries === */\\n.grid {\\n  display: grid;\\n  gap: var(--space-4);\\n  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\\n}\\n.grid-3 { grid-template-columns: repeat(3, 1fr); }\\n\\n/* === Flex: 1D horizontal/vertical alignment === */\\n.row    { display: flex; gap: var(--space-4); align-items: center; }\\n.row-end { display: flex; gap: var(--space-4); justify-content: flex-end; }\\n\\n/* === Stack: vertical rhythm with consistent gap === */\\n.stack > * + * { margin-top: var(--space-4); }\\n.stack-sm > * + * { margin-top: var(--space-2); }\\n.stack-lg > * + * { margin-top: var(--space-8); }\\n\\n/* === Cluster: wrap-on-overflow horizontal === */\\n.cluster {\\n  display: flex; flex-wrap: wrap; gap: var(--space-3);\\n  align-items: center; justify-content: flex-start;\\n}\\n\\n/* === Aspect-ratio: 16:9 video, 1:1 avatar, 4:3 card === */\\n.media-16x9 { aspect-ratio: 16 / 9; width: 100%; object-fit: cover; }\\n.avatar      { aspect-ratio: 1; border-radius: 50%; }\\n\\n/* === Container queries: component-responsive, not viewport === */\\n.card-container { container-type: inline-size; }\\n@container (min-width: 400px) {\\n  .card { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-4); }\\n}",
          "difficulty": "Beginner",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout",
          "pros": [
            "Grid for 2D, Flex for 1D — picking the right one upfront saves hours of `justify-content: center` guessing;",
            "`grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` is responsive without a single media query — 1 to N columns automatically;",
            "Stack primitive (`> * + * { margin-top: var(--gap) }`) gives vertical rhythm with zero magic numbers;",
            "Container queries (`@container`) make components responsive to their parent, not viewport — cards in sidebars vs main column both work;",
            "Grid + flex are RTL-ready by default (`direction: rtl` flips everything) — no `transform: scaleX(-1)` hacks;",
            "aspect-ratio (2021+) eliminates padding-bottom hack for 16:9 / 1:1 / 4:3 containers — `aspect-ratio: 16/9;` and done."
          ],
          "cons": [
            "Grid + flex syntax has a learning curve — `place-items: center` vs `justify-items + align-items` confuses new devs;",
            "Container queries (2023+) need fallback for older Safari — `@supports (container-type: inline-size)` gates the rule;",
            "Subgrid (Firefox-first, partial Chrome/Safari 2024+) isn't universal — nested grids can't share tracks reliably yet;",
            "Stack via `> * + * { margin-top }` doesn't work with pseudo-elements that already have margin — manual override needed;",
            "IE 11 doesn't support grid at all — irrelevant for modern apps, but legacy enterprise clients will break."
          ],
          "gotchas": [
            "`grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` collapses empty tracks — use `auto-fill` if you want fixed columns even when empty;",
            "Flex `gap` (2021+) replaces margin-between-children — but `gap` on flexbox needs Chromium 84+, Safari 14.1+;",
            "`place-items: center` is shorthand for `align-items + justify-items` — saves 14 chars, reads better;",
            "Container queries need `container-type: inline-size` on parent + `@container (min-width: 400px)` on child; without the type declaration, nothing works;",
            "Stack via `> * + * { margin-top }` only applies between adjacent children — pseudo-elements like `::before` still get the margin if they exist;",
            "`min-height: 100dvh` (dynamic viewport) handles mobile URL-bar collapse better than `100vh` — supported in iOS Safari 15.4+;",
            "`aspect-ratio` overrides `width`/`height` if both are set — set width:100% and aspect-ratio, not width+height;",
            "Negative margins on grid items can leak outside the container — use `overflow: hidden` on parent or grid gaps > 0;",
            "`place-content: center` centers the entire grid within its container (different from `place-items` which centers items within their cells)."
          ],
          "anti_patterns": [
            "Float-based layouts (legacy `float: left`) — never; flex/grid subsume every float use case;",
            "`position: absolute` for centering — flex `place-items: center` is 1 line and respects content size;",
            "Nested flex inside flex inside flex (> 4 deep) — refactor to grid or extract a primitive;",
            "Magic numbers (`margin-top: 17px` 'to make it line up') — every magic number is a future bug;",
            "Setting both `width` and `height` on an image inside flex — use `object-fit: cover` and let aspect-ratio handle the container."
          ],
          "prereqs": [
            "2.3"
          ],
          "tags": [
            "layout",
            "grid",
            "flex",
            "container-queries",
            "stack",
            "เลย์เอาต์"
          ],
          "compare_with": [
            "Flexbox (1D only)",
            "CSS Grid (2D, what we use)",
            "Container Queries (component-responsive)",
            "Subgrid (Firefox-first, partial)",
            "CSS `display: contents` (deprecated, accessibility risk)"
          ]
        }
      ]
    },
    {
      "id": 3,
      "name": "Game Engines",
      "icon": "🎮",
      "tagline": "Pick the right engine, save 50%+ time",
      "sub_phases": [
        {
          "id": "3.1",
          "title": "Babylon.js (Full-Stack 3D Engine)",
          "icon": "🧊",
          "description": "Full 3D engine — PBR + Havok physics + particles + GUI + WebXR — 1.5MB dep replaces 4 Three addons.",
          "when_to_use": "3D city builder (stronghold-3d); NPC sim (sims-lite); FPS (fps-cops-vs-robbers); MMORPG (realm-of-heroes); Mars strategy (mars-colony); idle RPG (sj88-rpg-idle-3d)",
          "when_not_to_use": "Tight 3D model viewer <300KB → use Three.js (3.4); pure 2D → use Phaser (3.2); shader-only particle system → use raw WebGL (3.7)",
          "examples": "stronghold-3d, sims-lite, mars-colony, realm-of-heroes, fps-cops-vs-robbers",
          "snippet": "const engine = new BABYLON.Engine(canvas, true); const scene = new BABYLON.Scene(engine); scene.createDefaultCameraOrLight(); BABYLON.MeshBuilder.CreateBox('b',{size:1},scene); engine.runRenderLoop(()=>scene.render());",
          "snippet_full": "// Babylon 8 — Havok physics + thinInstance grid (stronghold-3d pattern)\\nimport { Engine, Scene, HavokPlugin, Vector3, MeshBuilder, Matrix, PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';\\nimport HavokPhysics from '@babylonjs/havok';\\nconst havok = await HavokPhysics();\\nconst engine = new Engine(canvas, true, { stencil: true });\\nconst scene = new Scene(engine);\\nscene.enablePhysics(new Vector3(0, -9.81, 0), new HavokPlugin(true, havok));\\nconst ground = MeshBuilder.CreateGround('g', { width: 100, height: 100 }, scene);\\nnew PhysicsAggregate(ground, PhysicsShapeType.BOX, { mass: 0 }, scene);\\n// 100 crates in 1 draw call\\nconst crate = MeshBuilder.CreateBox('crate', { size: 1 }, scene);\\nconst matrices = new Float32Array(16 * 100);\\nconst buf = new Float32Array(100 * 16);\\nfor (let i = 0; i < 100; i++) {\\n  Matrix.TranslationToRef(i % 10, 5, Math.floor(i / 10), crate.thinInstanceAdd ? Matrix.Identity() : Matrix.Identity());\\n  Matrix.Translation(i % 10, 5, Math.floor(i / 10)).copyToArray(buf, i * 16);\\n}\\ncrate.thinInstanceSetBuffer('matrix', buf, 16);\\ncrate.thinInstanceRefreshBoundingInfo();\\nwindow.addEventListener('resize', () => engine.resize());\\nengine.runRenderLoop(() => scene.render());",
          "difficulty": "Advanced",
          "time_to_learn": "1 week",
          "docs_url": "https://doc.babylonjs.com/",
          "pros": [
            "PBR + Havok + particle editor + GUI + post-FX in 1.5MB → zero glue vs Three where you'd compose 3 addons",
            "Playground at playground.babylonjs.com = live-edit IDE → share bug-repro URL in 30s (no JSFiddle setup)",
            "thinInstanceAdd draws 10000 identical cubes in 1 draw call → stronghold-3d tiles 100x100 grid at 60fps",
            "WebXR first-class — fps-cops-vs-robbers ships VR mode in 50 lines vs Three's @three/xr + manual session glue",
            "ArcRotateCamera + Inspector baked-in → no need to wire OrbitControls or dat.gui separately",
            "v8 (2024) ships Havok IK for character animation → no three-stdlib AnimationMixer ladder"
          ],
          "cons": [
            "1.5MB+ minified ≈ 10x Three → mobile first-paint hurts if not code-split per route",
            "API surface ~5x Three → ramp-up 1 week vs 3 days; niche questions get fewer SO answers",
            "PBR + shadows = thermal throttling on iPhone 11 / low-end Android → must swap to StandardMaterial",
            "Inspector = +200KB; forgetting tree-shake ships it in prod and slows boot 300ms",
            "BabylonGUI 2D ≠ DOM → no screen-reader / IME for Thai-Arabic; mix HTML overlay for inputs",
            "Havok v2 = WASM 3MB lazy-loaded → first physics step shows 100ms hitch unless preloaded"
          ],
          "gotchas": [
            "freezeWorldMatrix() after placing static meshes — without it Babylon recomputes world transforms each frame costing 5-15ms on 1000-mesh scenes (stronghold-3d lesson)",
            "thinInstanceAdd (NOT createInstance) for > 100 identical objects — instance API spawns N draw calls, thin packs into 1 buffer + matrix refresh",
            "ArcRotateCamera alpha/beta are radians; setting alpha=Math.PI flips 180° not 360° — use lerp(t,0,1) for smooth tween",
            "PBR materials need .env cubemap — without it metallic surfaces render black; load via CubeTexture from .dds",
            "Havok WASM needs await HavokPhysics() before first PhysicsAggregate — otherwise physics silently no-ops",
            "PointerEvent fires on both canvas AND DOM HUD → wire scene.onPointerObservable to avoid double-input in realm-of-heroes",
            "Calling scene.render() manually inside runRenderLoop = double-render → 30fps instead of 60fps; let engine own the loop",
            "iOS Safari drops WebGL context after 30min background → listen to engine.onContextLostObservable and rebuild scene"
          ],
          "anti_patterns": [
            "DON'T `import * as BABYLON` in TS — use named imports `import { Engine, Scene } from '@babylonjs/core'` tree-shakes ~30%",
            "DON'T allocate Mesh per bullet — pool 50 meshes; stronghold-3d reuses them across 10k spawns",
            "DON'T run physics in JS thread for > 200 bodies — enable Havok WASM (sims-lite NPC crowds)",
            "DON'T use StandardMaterial outdoor — PBR + IBL only; Standard gives plastic look in sunlight",
            "DON'T skip scene.skipPointerMovePicking = true with 1000+ pickables — picking = 5ms hit-test per frame"
          ],
          "prereqs": [
            "1.1",
            "1.6"
          ],
          "next_steps": [
            "3.10",
            "4.1",
            "4.4"
          ],
          "tags": [
            "3d",
            "babylon",
            "pbr",
            "webxr",
            "havok",
            "เกม 3d",
            "engine"
          ],
          "compare_with": [
            "3.4 Three.js (smaller, no physics)",
            "3.7 WebGL (raw GPU)",
            "3.8 WebGPU (next-gen)"
          ]
        },
        {
          "id": "3.2",
          "title": "Phaser 3 (2D Game Engine)",
          "icon": "🥊",
          "description": "Comprehensive 2D engine — sprites, Arcade/P2/Matter physics, scene lifecycle, animations, tilemaps, sound — 800KB.",
          "when_to_use": "2D fighter (street-fighter-thai); 2D shooter (sky-ace); tower defense; platformer; RPG (realm-of-heroes top-down); mobile canvas game",
          "when_not_to_use": "Pure 3D → use Babylon (3.1); < 10 entities with no physics → use Vanilla Canvas (3.3); > 1000 sprites needing max FPS → use PixiJS (3.6)",
          "examples": "street-fighter-thai, sky-ace, realm-of-heroes",
          "snippet": "class MainScene extends Phaser.Scene { create(){ this.player=this.physics.add.sprite(100,450,'p').setBounce(.2); this.cursors=this.input.keyboard.createCursorKeys(); } update(){ this.player.setVelocity(0); if(this.cursors.left.isDown) this.player.setVelocityX(-200); if(this.cursors.up.isDown&&this.player.body.touching.down) this.player.setVelocityY(-400); } }",
          "snippet_full": "// Phaser 3 — fighter skeleton (street-fighter-thai pattern)\\nclass BattleScene extends Phaser.Scene {\\n  constructor() { super('Battle'); }\\n  preload() { this.load.atlas('fighter', 'assets/fighter.png', 'assets/fighter.json'); }\\n  create() {\\n    this.fighter = this.physics.add.sprite(400, 500, 'fighter', 'idle/01').setBounce(0.15);\\n    this.fighter.setCollideWorldBounds(true);\\n    this.cursors = this.input.keyboard.createCursorKeys();\\n    this.attackKey = this.input.keyboard.addKey('SPACE');\\n    this.anims.create({ key: 'punch', frames: this.anims.generateFrameNames('fighter', { prefix: 'punch/', end: 6, zeroPad: 2 }), frameRate: 18, repeat: 0 });\\n    this.attackKey.on('down', () => { if (!this.fighter.anims.isPlaying) { this.fighter.anims.play('punch'); this.fighter.setVelocityX(this.fighter.flipX ? 600 : -600); } });\\n  }\\n  update(_, dt) {\\n    this.fighter.setVelocityX(0);\\n    if (this.cursors.left.isDown) { this.fighter.setVelocityX(-260); this.fighter.flipX = true; }\\n    else if (this.cursors.right.isDown) { this.fighter.setVelocityX(260); this.fighter.flipX = false; }\\n    if (this.cursors.up.isDown && this.fighter.body.touching.down) this.fighter.setVelocityY(-520);\\n  }\\n}\\nnew Phaser.Game({ type: Phaser.AUTO, parent: 'game', scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }, physics: { default: 'arcade', arcade: { gravity: { y: 900 } } }, scene: [BattleScene] });",
          "difficulty": "Intermediate",
          "time_to_learn": "3 days",
          "docs_url": "https://phaser.io/docs",
          "pros": [
            "3 physics engines (Arcade/P2/Matter) ship built-in → Arcade for platformers, Matter for ragdoll — no addon hunt",
            "Scene lifecycle (init/preload/create/update) forces clean state separation → realm-of-heroes uses MenuScene/GameScene/UIScene stack",
            "First-class TypeScript definitions → IDE autocomplete vs Phaser 2 JS-only — saves 1 hour per session",
            "WebGL + Canvas renderers auto-fallback → iOS Safari 14 with WebGL bug falls back to Canvas without code change",
            "Animation editor exports JSON → designer-friendly; Phaser parses sprite-sheet atlas (TexturePacker / Aseprite) in 3 lines",
            "Camera effects (shake/flash/fade) + Tween manager built-in → no GSAP dep for game juice"
          ],
          "cons": [
            "800KB minified ≈ 8x PixiJS → hurts mobile LCP; use Phaser MINIMAL build to drop to 400KB",
            "API has 3 layers (Game/Scene/GameObject) → junior dev writes boilerplate; Arc/sprite confusion",
            "Arcade physics is AABB-only → circles/rotated bodies need P2 or Matter (3x CPU cost)",
            "No built-in 9-slice — must use third-party plugin or roll your own (Pixel-Game dev curse)",
            "Matter.js has 200KB weight — sky-ace ships Matter only for ragdoll debris, Arcade for gameplay",
            "No first-class module system before v3.60 — code-split per scene requires import map or bundler hacks"
          ],
          "gotchas": [
            "Call this.physics.add.collider(player, layer) AFTER physics.world.enable() in create() — setup order matters; sky-ace silent fall-through if reversed",
            "Scene shutdown must call this.events.off() on listeners → otherwise memory leak + double-fire after scene restart",
            "Scale.FIT + parent centered div causes 1px black bar on iOS notch — use Scale.RESIZE + safe-area-inset CSS",
            "TexturePacker atlas needs fixedSize:false + padding:2 → otherwise Phaser bleed artifacts in sky-ace tileset",
            "Phaser.Loader.LOCKING = false in create() OR preload() blocks forever on slow CDN — set timeout: 15000",
            "iOS Safari blocks AudioContext until user gesture → street-fighter-thai unlocks sound on first tap in MainScene.create",
            "Matter debug graphics = +20% CPU — enable only in dev (Phaser config `physics.matter.debug: false` in prod)",
            "Tilemap collision misses if tileset imported with wrong pixel scale — match Tiled's tilewidth to texture frame size exactly"
          ],
          "anti_patterns": [
            "DON'T update in render() callback — use update(time, delta) for frame-time-independent physics (street-fighter-thai rule)",
            "DON'T load assets in create() — use preload() + Loader; otherwise first-frame flickers with missing textures",
            "DON'T put DOM <button> over Phaser canvas for menu — use Phaser text objects OR full DOM scene with Phaser.Game destroyed",
            "DON'T allocate new Phaser.Game per scene transition — single Game instance with scene.start() is 5x faster",
            "DON'T use Matter for platformer — Arcade AABB is 10x faster and 95% sufficient (sky-ace precedent)"
          ],
          "prereqs": [
            "1.1",
            "1.6"
          ],
          "next_steps": [
            "3.10",
            "4.1",
            "4.6"
          ],
          "tags": [
            "2d",
            "phaser",
            "sprites",
            "physics",
            "เกม 2d",
            "tilemap",
            "engine"
          ],
          "compare_with": [
            "3.3 Vanilla Canvas (no dep)",
            "3.6 PixiJS (render-only, faster)",
            "3.5 Kaboom (simpler)"
          ]
        },
        {
          "id": "3.3",
          "title": "Vanilla Canvas 2D (Zero-Dep 2D)",
          "icon": "📦",
          "description": "Pure Canvas 2D API + manual game loop. 0KB dep. Best for <200 entities with simple physics (tycoon, particles, UI tools).",
          "when_to_use": "Tycoon (noodle-shop customer queue); particle effects; data viz; simple click-game < 200 entities; tools (palette color picker); tile-map RPG under 50x50",
          "when_not_to_use": "> 200 entities with collisions → use Phaser (3.2); need physics engine → use Phaser Arcade; performance 60fps with 1000+ sprites → use PixiJS (3.6)",
          "examples": "noodle-shop, demo-coin-flip, palette",
          "snippet": "const ctx = canvas.getContext('2d'); const loop = () => { ctx.clearRect(0,0,w,h); entities.forEach(e => { e.x += e.vx; e.y += e.vy; ctx.fillRect(e.x, e.y, e.w, e.h); }); requestAnimationFrame(loop); }; loop();",
          "snippet_full": "// Vanilla Canvas 2D — tycoon grid with dirty-rect (noodle-shop pattern)\\nconst canvas = document.getElementById('game');\\nconst ctx = canvas.getContext('2d', { alpha: false });\\nfunction resize() { const dpr = devicePixelRatio || 1; canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr; canvas.style.width = innerWidth + 'px'; canvas.style.height = innerHeight + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }\\nresize(); addEventListener('resize', resize);\\nconst customers = []; const grid = Array.from({ length: 8 }, () => Array(8).fill(null));\\nlet last = performance.now();\\nfunction tick(now) {\\n  const dt = Math.min((now - last) / 1000, 0.05); last = now;\\n  ctx.clearRect(0, 0, innerWidth, innerHeight);\\n  // grid background cached in offscreen, blit once\\n  for (const c of customers) { c.x += c.vx * dt; c.timer -= dt; if (c.timer <= 0) customers.splice(customers.indexOf(c), 1); }\\n  for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) if (grid[y][x]) { ctx.fillStyle = grid[y][x].color; ctx.fillRect(x * 60, y * 60, 58, 58); }\\n  for (const c of customers) { ctx.fillStyle = '#ec4899'; ctx.beginPath(); ctx.arc(c.x, c.y, 18, 0, Math.PI * 2); ctx.fill(); }\\n  requestAnimationFrame(tick);\\n}\\nsetInterval(() => { customers.push({ x: 0, y: Math.random() * innerHeight, vx: 80, timer: 8 }); }, 1500);\\nrequestAnimationFrame(tick);",
          "difficulty": "Intermediate",
          "time_to_learn": "2 days",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D",
          "pros": [
            "0KB dep → loads in <50ms on slow 3G; ideal for CodeHub static landing widgets",
            "Full control of draw order/blend modes/transforms — no engine abstraction tax",
            "Debug = what user sees; no source-map or framework error noise in console (noodle-shop dev speed)",
            "Works file:// → offline / Codepen / jsFiddle without install — pasta-shop demo runs from USB",
            "Canvas 2D matured in all browsers — even iOS Safari 13+ supports Path2D, OffscreenCanvas",
            "No vendor lock-in; HTML/CSS overlay for HUD = free a11y + IME + screen-reader for Thai"
          ],
          "cons": [
            "DOM-free a11y → screen readers miss canvas content; need offscreen <canvas> mirrored to DOM (noodle-shop tax)",
            "No scene mgmt → 1000-line games become spaghetti; manual update/render dispatch",
            "Canvas 2D < WebGL perf at > 500 draw calls/frame; drawImage with rotation = 0.3ms each on mid-mobile",
            "Manual asset versioning; cache bust with ?v=Date.now() to defeat iOS Safari 7-day HTML cache",
            "No spatial index — collision check O(n²) past 200 entities; need quadtree or grid broadphase",
            "devicePixelRatio must scale canvas resolution — without it retina = blurry (most common bug)"
          ],
          "gotchas": [
            "Set canvas.width = w * devicePixelRatio AND ctx.scale(dpr, dpr) — otherwise retina blurry",
            "requestAnimationFrame NOT setInterval — rAF pauses when tab backgrounded (saves battery + matches display Hz)",
            "Clear with ctx.clearRect(0,0,w,h) NOT ctx.fillRect(white) — clearRect is 3x faster on iOS Safari",
            "ctx.save()/restore() pairing inside a draw call must balance — otherwise transform stack corrupts (noodle-shop lesson)",
            "ctx.measureText() returns cached width — call once at boot for HUD text, not every frame (tycoon dashboard)",
            "Touch events need {passive:false} to call e.preventDefault() → without it scroll fights drag",
            "OffscreenCanvas.transferToImageBitmap() for worker rendering — Chrome only but 10x faster for image processing",
            "Hit-test for clicks: track mouse pos + button-down + entity list — never use canvas's hit-region API (Safari buggy)"
          ],
          "anti_patterns": [
            "DON'T draw all entities every frame without dirty-rect tracking — re-use offscreen canvas for static layers (noodle-shop kitchen tiles)",
            "DON'T put game state in DOM <input> hidden fields — use plain JS object + localStorage; DOM read/write is 10x slower per frame",
            "DON'T call canvas.toDataURL() per frame — it stalls 50ms; only call on save event",
            "DON'T use ctx.drawImage with imageElement cached wrong — pre-decode via img.decode() promise before first draw",
            "DON'T ignore ResizeObserver → canvas keeps stale size on phone rotate; obs + resize handler fixes 90% of mobile reports"
          ],
          "prereqs": [
            "1.6"
          ],
          "next_steps": [
            "4.1",
            "3.10"
          ],
          "tags": [
            "canvas",
            "2d",
            "vanilla",
            "no-dep",
            "วาดเขียน",
            "game-loop",
            "tycoon"
          ],
          "compare_with": [
            "3.2 Phaser (scenes + physics)",
            "3.6 PixiJS (WebGL perf)",
            "3.5 Kaboom (component)"
          ]
        },
        {
          "id": "3.4",
          "title": "Three.js (Lightweight 3D Renderer)",
          "icon": "🌐",
          "description": "Smallest mainstream 3D renderer (~150KB) — declarative scene graph + WebGL/WebGPU backends — focused on rendering, NOT game features.",
          "when_to_use": "3D model viewer (fitcheck — clothes try-on); product configurator; data viz (3D bar chart); AR/VR preview; minimalist portfolio 3D; CSS3DRenderer hybrid UI",
          "when_not_to_use": "Heavy game needing physics/GUI/particles bundled → use Babylon (3.1) instead; pixel-art 2D → use Phaser; shader-only particle math → raw WebGL (3.7)",
          "examples": "fitcheck",
          "snippet": "const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, w/h, 0.1, 1000); const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial({color:0xec4899})); scene.add(mesh); scene.add(new THREE.DirectionalLight(0xffffff,1)); function tick(){ mesh.rotation.x+=.01; renderer.render(scene,camera); requestAnimationFrame(tick); }",
          "snippet_full": "// Three.js r160 — fitcheck GLTF viewer pattern\\nimport * as THREE from 'three';\\nimport { OrbitControls } from 'three/addons/controls/OrbitControls.js';\\nimport { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';\\nimport { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';\\nconst renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });\\nrenderer.setPixelRatio(Math.min(devicePixelRatio, 2));\\nrenderer.outputColorSpace = THREE.SRGBColorSpace;\\nrenderer.toneMapping = THREE.ACESFilmicToneMapping;\\nconst scene = new THREE.Scene();\\nscene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1));\\nconst camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);\\ncamera.position.set(0, 1.6, 3);\\nconst controls = new OrbitControls(camera, renderer.domElement);\\ncontrols.enableDamping = true;\\nconst draco = new DRACOLoader().setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/');\\nnew GLTFLoader().setDRACOLoader(draco).load('model.glb', (gltf) => {\\n  scene.add(gltf.scene);\\n  controls.target.copy(gltf.scene.position);\\n});\\nfunction tick() { controls.update(); renderer.render(scene, camera); requestAnimationFrame(tick); }\\naddEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); });\\ntick();",
          "difficulty": "Intermediate",
          "time_to_learn": "3 days",
          "docs_url": "https://threejs.org/docs/",
          "pros": [
            "~150KB core (~10x smaller than Babylon) → perfect for fitcheck embed + product configurator first-paint",
            "Declarative scene graph → `scene.add(mesh)` reads like document.appendChild; junior-friendly",
            "Massive ecosystem: @three/xr, three-stdlib (OrbitControls, GLTFLoader, EffectComposer) — Babylon equivalent needs custom code",
            "r150+ ships WebGPURenderer flag — switch to GPU when navigator.gpu exists, fallback WebGL automatically (fitcheck pattern)",
            "BufferGeometry is the only API (Geometry deprecated in r125) — flat Float32Array is cache-friendly and serializable",
            "Examples at threejs.org cover 90% of use cases — copy-paste + tweak faster than Babylon docs"
          ],
          "cons": [
            "No physics, no GUI, no particles in core → must compose 3+ addons (Cannon-es + lil-gui + tsparticles) = 400KB total",
            "Materials are thin wrappers over GLSL — PBR with env map = 20 lines setup; Babylon is 3 lines",
            "WebXR requires @three/xr with manual session.requestSession + frame loop glue — Babylon does it in 1 helper",
            "Inspector = external dep (three-inspect) not built-in — debugging prod scene = print transforms to console",
            "Mobile post-processing is brutal — EffectComposer + Bloom tanks to 20fps on iPhone 11; bake into textures",
            "TypeScript types are community-maintained — some addon .d.ts files lag releases by weeks"
          ],
          "gotchas": [
            "renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) — without cap, retina = 4x GPU fillrate = 15fps mobile",
            "Use BufferGeometry NOT Geometry — Geometry was removed in r125; migrate legacy code immediately",
            "renderer.outputColorSpace = THREE.SRGBColorSpace (r152+) — without it PBR textures wash out / too bright (fitcheck bug)",
            "renderer.toneMapping = THREE.ACESFilmicToneMapping for PBR — default linear looks chalky",
            "GLTFLoader needs DRACO + KTX2 loaders configured OR .glb inflates 10x — fitcheck preloads DRACO wasm",
            "Dispose resources explicitly: geometry.dispose(), material.dispose(), texture.dispose() — otherwise GPU mem leaks 5MB/min",
            "WebGLRenderer + ShaderMaterial: don't forget to set uniforms.needsUpdate = true — silent no-op is common",
            "Resize handler must update camera.aspect + renderer.setSize — otherwise stretched on rotate"
          ],
          "anti_patterns": [
            "DON'T use MeshBasicMaterial for outdoor scene — no lighting; use MeshStandardMaterial or MeshPhysicalMaterial (fitcheck rule)",
            "DON'T allocate new Vector3() inside render loop — reuse pool of 8-10 vectors; GC pauses cause 5-10ms hitches",
            "DON'T load multiple GLTFs sequentially — use GLTFLoader.loadAsync in parallel Promise.all (fitcheck boots 6 models in 2s not 12s)",
            "DON'T trust Euler rotation order — default 'XYZ' vs 'YXZ' gives gimbal-lock; set explicitly: rotation.order = 'YXZ' for FPS-style cameras",
            "DON'T skip OrbitControls.update() in render loop — damping breaks when not called each frame (fitcheck lesson)"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "3.1",
            "3.7",
            "6.4"
          ],
          "tags": [
            "3d",
            "three",
            "gltf",
            "webgl",
            "model-viewer",
            "สามมิติ"
          ],
          "compare_with": [
            "3.1 Babylon (batteries-included)",
            "3.7 WebGL (raw)",
            "3.8 WebGPU (next-gen)"
          ]
        },
        {
          "id": "3.5",
          "title": "Kaboom.js / Kaplay (Arcade 2D)",
          "icon": "🕹️",
          "description": "~80KB component-based arcade 2D — add([rect(), pos(), body()]) chain syntax — game-jam-grade simplicity.",
          "when_to_use": "Game jam 48h prototypes; tiny arcade (Pong/Snake/Mario-clone under 200 LOC); kids' coding lesson (codekids-thai); quick landing-page game",
          "when_not_to_use": "Production-grade RPG with save system → use Phaser (3.2); > 200 entities → use PixiJS (3.6); commercial launch with SLA → avoid Kaboom team churn risk",
          "examples": "codekids-thai",
          "snippet": "kaplay(); scene('main', () => { add([rect(50,50), pos(50,50), color(0,255,0), body(), area()]); const p = add([rect(40,40), pos(100,300), color(0,200,255), body(), 'player']); keyDown('left', () => p.move(-200, 0)); });",
          "snippet_full": "// Kaplay 3000+ — Pong in 30 LOC (game-jam pattern)\\nimport kaplay from 'kaplay';\\nconst k = kaplay({ width: 800, height: 600, background: [0, 0, 0] });\\nk.scene('main', () => {\\n  k.add([k.rect(800, 20), k.pos(0, 0), k.area(), 'wall']);\\n  k.add([k.rect(800, 20), k.pos(0, 580), k.area(), 'wall']);\\n  const ball = k.add([k.rect(20, 20), k.pos(390, 290), k.area(), k.body({ isStatic: false }), 'ball']);\\n  ball.onUpdate(() => { if (ball.pos.x < 0 || ball.pos.x > 780) k.burp(); });\\n  const p1 = k.add([k.rect(20, 100), k.pos(30, 250), k.area(), k.body({ isStatic: true }), 'paddle']);\\n  const p2 = k.add([k.rect(20, 100), k.pos(750, 250), k.area(), k.body({ isStatic: true }), 'paddle']);\\n  k.onKeyDown('w', () => p1.move(0, -300)); k.onKeyDown('s', () => p1.move(0, 300));\\n  k.onKeyDown('up', () => p2.move(0, -300)); k.onKeyDown('down', () => p2.move(0, 300));\\n  ball.onCollide('paddle', (p) => { if (p.pos.x < 400) ball.dir = k.vec2(1, k.randi(-1, 2)); else ball.dir = k.vec2(-1, k.randi(-1, 2)); });\\n  ball.onCollide('wall', () => k.add([k.text('POINT!'), k.pos(400, 300), k.lifespan(1, { fade: 0.5 })]));\\n  k.wait(1, () => ball.dir = k.vec2(1, 0));\\n});\\nk.go('main');",
          "difficulty": "Beginner",
          "time_to_learn": "4 hours",
          "docs_url": "https://kaplayjs.com/",
          "pros": [
            "~80KB minified ≈ 1/10 Phaser → instant load on mobile 3G; perfect for codekids-thai lesson embed",
            "Component chain syntax: add([rect(50,50), pos(50,50), color(0,255,0), body(), area(), 'player']) reads like a sentence",
            "Built-in scene() with key/state pattern → 10-line platformer prototype vs 100 in Phaser",
            "Zero-config input: keyDown('left', () => ...) → keyboard + touch auto-detected (codekids tablets)",
            "Built-in onUpdate/onDraw lifecycle → no manual requestAnimationFrame management",
            "Re-exported as `kaplay` since 2024 — old `kaboom` still works via shim but migrate to kaplay()"
          ],
          "cons": [
            "Kaplay (formerly Kaboom) had 2 API breaking changes in 18 months → upgrade tax every quarter",
            "No first-class save system → must roll localStorage wrapper; no migration framework",
            "Performance modest past 100 entities — no spatial hash, no GPU batching beyond default sprite atlas",
            "TypeScript support is partial — many component types are `any`; IDE autocomplete weak (codekids friction)",
            "Community is shrinking vs Phaser — fewer StackOverflow answers, fewer plugins, fewer devs hiring for it",
            "No mobile build pipeline (Cordova/Capacitor) maintained — fine for web, dead for native"
          ],
          "gotchas": [
            "Use `import kaplay from 'kaplay'` NOT `kaboom` — Kaboom renamed 2024, old package is deprecated",
            "Component order matters in add([...]) — body() before area() throws 'area not attached'",
            "loadSprite('player', 'p.png') must happen inside a scene's onLoad — not at module top-level",
            "onUpdate() callback receives deltaTime but it's already dt-multiplied in v3000+ — don't multiply again (silent 2x speed)",
            "Touch input on iPad needs touchToMouse polyfill OR scene('main', () => { onTouchStart(... ) }) — auto-detect misses some",
            "Pos components store x/y as numbers — for big worlds use camPos()/camScale() for camera, don't translate every entity",
            "outline() component needs a parent color() — without it outline default color is black on black = invisible",
            "debug.inspect = true in dev shows entity tree but adds 5% CPU — disable in prod"
          ],
          "anti_patterns": [
            "DON'T use Kaboom for anything beyond 2 weeks of dev — the API churn + community decay will burn you at v3 update (codekids avoided)",
            "DON'T nest scenes 3+ deep — Kaplay's scene stack uses global state; bugs at depth 3+ are untraceable",
            "DON'T store save state in kaboom's getData()/setData() — those are session-scoped; use localStorage wrapper",
            "DON'T use kaboom() global init in module — import kaplay and call const k = kaplay({...}) for testability",
            "DON'T mix Kaboom + Phaser — they fight over canvas/context; pick one"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "3.2",
            "3.3"
          ],
          "tags": [
            "2d",
            "arcade",
            "kaplay",
            "kaboom",
            "prototype",
            "เกมง่าย",
            "game-jam"
          ],
          "compare_with": [
            "3.2 Phaser (production)",
            "3.3 Vanilla Canvas (no dep)",
            "3.6 PixiJS (render-only)"
          ]
        },
        {
          "id": "3.6",
          "title": "PixiJS (Fastest 2D WebGL Renderer)",
          "icon": "🖼️",
          "description": "WebGL-accelerated 2D renderer — fastest sprite batcher in JS — filter library + mesh/particle — render-only (no physics).",
          "when_to_use": "Performance-critical 2D > 1000 sprites (particle storm, map editor, character creator); isometric MMORPG (realm-of-heroes zoom-out); data-viz dashboard with 5k nodes",
          "when_not_to_use": "Need built-in physics → use Phaser (3.2); arcade < 50 entities → use Kaboom (3.5); pure 2D UI < 200 nodes → use Canvas 2D (3.3)",
          "examples": "realm-of-heroes",
          "snippet": "const app = new PIXI.Application(); await app.init({width:800,height:600,antialias:true}); const sprite = PIXI.Sprite.from('p.png'); sprite.x=400; sprite.y=300; app.stage.addChild(sprite); document.body.appendChild(app.canvas);",
          "snippet_full": "// PixiJS v8 — 5000 sprite particle storm (realm-of-heroes effect)\\nimport { Application, Sprite, Texture, Container } from 'pixi.js';\\nconst app = new Application();\\nawait app.init({ width: window.innerWidth, height: window.innerHeight, antialias: true, backgroundAlpha: 0 });\\ndocument.body.appendChild(app.canvas);\\nconst tex = await Texture.from('https://codehub.sj88ai.com/static/sparkle.png');\\nconst particles = new Container();\\napp.stage.addChild(particles);\\nconst sprites = [];\\nfor (let i = 0; i < 5000; i++) {\\n  const s = new Sprite(tex);\\n  s.anchor.set(0.5);\\n  s.x = Math.random() * app.screen.width;\\n  s.y = Math.random() * app.screen.height;\\n  s.tint = Math.random() * 0xffffff;\\n  s.alpha = Math.random();\\n  s.scale.set(0.2 + Math.random() * 0.4);\\n  s.vx = (Math.random() - 0.5) * 2;\\n  s.vy = (Math.random() - 0.5) * 2;\\n  sprites.push(s);\\n  particles.addChild(s);\\n}\\napp.ticker.add((ticker) => {\\n  const dt = ticker.deltaMS / 16.67;\\n  for (const s of sprites) { s.x += s.vx * dt; s.y += s.vy * dt; if (s.x < 0 || s.x > app.screen.width) s.vx *= -1; if (s.y < 0 || s.y > app.screen.height) s.vy *= -1; }\\n});\\nwindow.addEventListener('beforeunload', () => app.destroy(true, { children: true }));",
          "difficulty": "Intermediate",
          "time_to_learn": "2 days",
          "docs_url": "https://pixijs.com/docs",
          "pros": [
            "WebGL sprite batching = 5000 sprites at 60fps on mobile; Phaser Canvas renderer tops at ~500",
            "Filter library: Blur/ColorMatrix/Displacement built-in — particle glow / CRT effect in 3 lines",
            "v8 async init: `await app.init({...})` — clearer than v7 callback chain; tree-shake friendly ESM",
            "ParticleContainer for > 100 sprites of same texture — 1 draw call regardless of count (5x faster)",
            "Mesh + NineSlicePlane for procedural UI — saves Photoshop round-trips in pixel-perfect HUDs",
            "Renderer-agnostic: WebGL, WebGPU (v8 alpha), Canvas — future-proof your 2D investment"
          ],
          "cons": [
            "Render-only — no scene management, no physics, no input handling (must add EventEmitter separately)",
            "~400KB minified → bigger than Phaser MINIMAL but faster; trade size for FPS",
            "v8 API is async (await app.init) — migration from v7 sync API = lots of await refactors",
            "No game loop helper — must wire your own ticker (gsap, raf, or own loop); Phaser ships one",
            "Filter pipeline = 1 GPU pass per filter; stacking 3+ filters tanks to 20fps on iPhone 11",
            "Texture atlas must be in specific format (Pixi v8 prefers Spine-style JSON, not TexturePacker default)"
          ],
          "gotchas": [
            "v8 (2024) is async — `await app.init({width,height})` then `document.body.appendChild(app.canvas)`; v7 sync is GONE",
            "Use ParticleContainer (not Container) for > 100 sprites of same base texture — single draw call vs 100",
            "app.canvas has 2x devicePixelRatio already — set CSS width/height in half-pixel units to avoid blur",
            "Filters stack = each filter = 1 framebuffer ping-pong; cache filter result in RenderTexture if static",
            "Texture.from('url') caches by URL — clear with Texture.removeFromCache on memory warning",
            "InteractionManager (v7) → FederatedEvents (v8) — on('pointerdown') API changed; migration = search/replace",
            "Bundle size: pixi.js (full) is 400KB; @pixi/sprite + @pixi/app is 200KB — choose per use case",
            "WebGL context loss on Android Chrome tab background → listen to app.renderer.on('contextlost') and rebuild textures"
          ],
          "anti_patterns": [
            "DON'T put 10000 individual Sprites in a Container — wrap in ParticleContainer or batch by texture for 1 draw call",
            "DON'T mix PIXI.Text + HTML <input> in same layout — different sizing model; use Pixi v8 Text or full DOM",
            "DON'T apply Filter to entire stage — apply only to specific sprites; full-stage filter is GPU bottleneck",
            "DON'T resize canvas via CSS only — must also call app.renderer.resize(w,h) to update buffer (v8) or canvas.width/height (v7)",
            "DON'T forget app.destroy() on unmount — Pixi leaks WebGL context + GPU buffers; React useEffect cleanup mandatory"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "3.2",
            "3.7"
          ],
          "tags": [
            "2d",
            "pixijs",
            "webgl",
            "sprite",
            "particle",
            "render-only",
            "เร็ว"
          ],
          "compare_with": [
            "3.2 Phaser (physics included)",
            "3.3 Vanilla Canvas (no dep)",
            "3.5 Kaboom (simpler)"
          ]
        },
        {
          "id": "3.7",
          "title": "WebGL / WebGL2 (Custom GLSL Shaders)",
          "icon": "🎆",
          "description": "Raw GPU API — hand-write GLSL vertex/fragment shaders — max performance for particle storms, fluid sim, post-FX — no engine.",
          "when_to_use": "10k+ particles with physics-on-GPU; custom fluid/water/fire shader; post-process (bloom/DoF/SSR); audio visualizer; shadertoy-style art",
          "when_not_to_use": "Generic 3D scene → use Three.js (3.4) or Babylon (3.1); need physics+scene-graph → don't reinvent; 2D → Canvas 2D or PixiJS",
          "examples": "sims-lite, stronghold-3d",
          "snippet": "const gl = canvas.getContext('webgl2'); const prog = gl.createProgram(); const vs = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vs, '#version 300 es\\nin vec2 a; void main(){gl_Position=vec4(a,0,1);}'); gl.compileShader(vs);",
          "snippet_full": "// WebGL2 — fullscreen quad with fragment shader (ShaderToy pattern)\\nconst canvas = document.getElementById('c');\\nconst gl = canvas.getContext('webgl2', { antialias: true });\\ncanvas.width = innerWidth * devicePixelRatio;\\ncanvas.height = innerHeight * devicePixelRatio;\\nfunction compile(type, src) {\\n  const sh = gl.createShader(type);\\n  gl.shaderSource(sh, src);\\n  gl.compileShader(sh);\\n  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(sh)); throw new Error('shader fail'); }\\n  return sh;\\n}\\nconst vs = compile(gl.VERTEX_SHADER, `#version 300 es\\nin vec2 a; void main(){gl_Position=vec4(a,0,1);}`);\\nconst fs = compile(gl.FRAGMENT_SHADER, `#version 300 es\\nprecision highp float;\\nuniform vec2 uRes;\\nuniform float uTime;\\nout vec4 o;\\nvoid main(){ vec2 uv = (gl_FragCoord.xy*2.0 - uRes)/min(uRes.x,uRes.y); float t = uTime*0.5; float d = length(uv - 0.5*vec2(cos(t),sin(t))); o = vec4(0.5+0.5*cos(d*8.0+vec3(0,2,4)+t), 1.0); }`);\\nconst prog = gl.createProgram();\\ngl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); gl.useProgram(prog);\\nconst buf = gl.createBuffer();\\ngl.bindBuffer(gl.ARRAY_BUFFER, buf);\\ngl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);\\nconst loc = gl.getAttribLocation(prog, 'a');\\ngl.enableVertexAttribArray(loc);\\ngl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);\\nconst uRes = gl.getUniformLocation(prog, 'uRes');\\nconst uT = gl.getUniformLocation(prog, 'uTime');\\ncanvas.addEventListener('webglcontextlost', e => { e.preventDefault(); console.warn('context lost'); });\\nfunction tick(t) { gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uT, t/1000); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); requestAnimationFrame(tick); }\\nrequestAnimationFrame(tick);",
          "difficulty": "Advanced",
          "time_to_learn": "2 weeks",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API",
          "pros": [
            "GPU compute (WebGL2 transform feedback / WebGPU compute) — 100k particles updated in <1ms on RTX 2060",
            "Full creative control — fragment shaders can paint anything: raymarching, signed-distance fields, NPR stylization",
            "WebGL 2 in 96% browsers (2024) — VS WebGPU 18%; WebGL2 is today's safe low-level target",
            "GLSL ES 3.00 vs WebGL1 1.00 — uniform blocks, instanced drawing, texelFetch — modern features available",
            "ShaderToy migration is 1:1 — copy any .glsl shader, paste into <script id='vs'>, compile, render to fullscreen quad",
            "Three.js ShaderMaterial = on-ramp: use Three's renderer + camera + raw ShaderMaterial → keep conveniences, drop into GLSL when needed"
          ],
          "cons": [
            "GLSL is a real language — uniforms, varyings, attributes, precision qualifiers — 2-week ramp for non-graphics dev",
            "Debugging is brutal — no breakpoints; print uniforms as color and read pixels; Spector.js / RenderDoc are external",
            "Context loss handling mandatory — Android Chrome tab-switch drops context; must rebuild all shaders",
            "No scene graph — you build VAO/VBO/uniform setup; 500 LOC for what Three does in 5",
            "Mobile GPU varies wildly — Adreno 640 vs Apple A14 = 10x perf gap; must profile on real devices",
            "WebGL doesn't multithread — heavy CPU physics + WebGL render = jank; move physics to worker (sims-lite pattern)"
          ],
          "gotchas": [
            "WebGL2 = 96% browsers, WebGL1 = 99% — for new code use WebGL2 (`canvas.getContext('webgl2')`) but feature-detect fallback (Three.js does this)",
            "Context loss: listen `canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); rebuild(); })` — must preventDefault to recover",
            "Precision qualifiers matter: mediump in fragment shader is fast but 16-bit float; highp required for positions, lowp for color tints",
            "Don't allocate textures per frame — pool or use single dynamic texture with texSubImage2D updates",
            "Uniform location lookups are slow — `gl.getUniformLocation(p, 'u')` once at boot, cache the location; per-frame lookup = 5ms tax",
            "Instanced drawing (gl.drawArraysInstanced) — render 10000 cubes in 1 draw call instead of 10000; Three's instancedMesh wraps this",
            "Use float textures (EXT_color_buffer_float) for post-process ping-pong — required for HDR bloom in WebGL2",
            "Shader compile is async — call `gl.getShaderParameter(s, gl.COMPILE_STATUS)` and check `gl.getShaderInfoLog(s)` for errors; silent fail = black screen"
          ],
          "anti_patterns": [
            "DON'T write vertex shader from scratch for triangle mesh — Three.js BufferGeometry + ShaderMaterial gives you the boilerplate; raw WebGL only when at GPU limits",
            "DON'T use `vec3` for colors in fragment shader — use `vec4` with alpha for blending; vec3 alpha defaults are wrong",
            "DON'T skip `gl.flush()` after multiple draw calls if reading pixels back — driver may reorder; explicit barrier for readPixels",
            "DON'T enable blending if you don't need it — `gl.disable(gl.BLEND)` saves 30% GPU on opaque-only scenes",
            "DON'T forget to set `gl.viewport(0, 0, canvas.width, canvas.height)` on resize — without it renders stretch on HiDPI"
          ],
          "prereqs": [
            "1.6"
          ],
          "next_steps": [
            "3.1",
            "3.4",
            "3.8"
          ],
          "tags": [
            "webgl",
            "shader",
            "glsl",
            "gpu",
            "particle",
            "เรนเดอร์"
          ],
          "compare_with": [
            "3.4 Three.js (engine wrapper)",
            "3.8 WebGPU (next-gen)",
            "3.1 Babylon (full game)"
          ]
        },
        {
          "id": "3.8",
          "title": "WebGPU (Next-Gen GPU API)",
          "icon": "⚡",
          "description": "WebGL successor — compute shaders on web, render pipelines, multi-thread via async — 2024 stable in Chrome/Edge, partial in Safari/Firefox.",
          "when_to_use": "Compute-heavy: ML inference in browser; raytracing preview; physics-on-GPU (1M particles); large terrain LOD streaming; shader-heavy NFT art",
          "when_not_to_use": "Mainstream shipping game (Safari/Firefox support still partial in 2024) — fallback to WebGL or detect via navigator.gpu; mobile low-end = no GPU adapter",
          "snippet": "const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); const module = device.createShaderModule({ code: '@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4<f32> { return vec4<f32>(0,0,0,1); }' });",
          "snippet_full": "// WebGPU — compute-shader 100k particle sim (next-gen pattern)\\nif (!navigator.gpu) { console.warn('WebGPU unavailable, fallback'); return; }\\nconst adapter = await navigator.gpu.requestAdapter();\\nconst device = await adapter.requestDevice();\\nconst canvas = document.getElementById('c');\\nconst ctx = canvas.getContext('webgpu');\\nconst format = navigator.gpu.getPreferredCanvasFormat();\\nctx.configure({ device, format, alphaMode: 'premultiplied' });\\nconst module = device.createShaderModule({\\n  code: `struct Particle { pos: vec2<f32>, vel: vec2<f32> };\\n@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;\\n@compute @workgroup_size(64) fn update(@builtin(global_invocation_id) gid: vec3<u32>) {\\n  let i = gid.x; if (i >= arrayLength(&particles)) { return; }\\n  particles[i].pos = particles[i].pos + particles[i].vel * 0.016;\\n  if (particles[i].pos.x > 1.0 || particles[i].pos.x < -1.0) { particles[i].vel.x = -particles[i].vel.x; }\\n  if (particles[i].pos.y > 1.0 || particles[i].pos.y < -1.0) { particles[i].vel.y = -particles[i].vel.y; }\\n}`\\n});\\nconst N = 100000;\\nconst particleData = new Float32Array(N * 4);\\nfor (let i = 0; i < N * 4; i += 4) { particleData[i] = (Math.random() - 0.5) * 2; particleData[i+1] = (Math.random() - 0.5) * 2; particleData[i+2] = (Math.random() - 0.5) * 0.02; particleData[i+3] = (Math.random() - 0.5) * 0.02; }\\nconst buf = device.createBuffer({ size: N * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });\\ndevice.queue.writeBuffer(buf, 0, particleData);\\nconst pipeline = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'update' } });\\nconst bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: buf } }] });\\nasync function tick() {\\n  const enc = device.createCommandEncoder();\\n  const pass = enc.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(N / 64)); pass.end();\\n  device.queue.submit([enc.finish()]);\\n  await device.queue.onSubmittedWorkDone();\\n  requestAnimationFrame(tick);\\n}\\ntick();",
          "difficulty": "Advanced",
          "time_to_learn": "3 weeks",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API",
          "pros": [
            "Compute shaders = real GPGPU on web — 1M particle sim at 60fps on M1 MacBook; WebGL needs transform feedback dance",
            "Render pipeline objects pre-compiled — switching shader = 0ms vs WebGL's re-link cost (5-15ms)",
            "Bind groups = explicit resource layout — GPU drivers don't have to guess; faster + safer",
            "Async by design — adapter/device init returns Promise; no more blocking gl.flush() in worker threads",
            "WGSL = real typed language — no GLSL precision footguns; tooling (naga) validates at compile",
            "Three.js r150+ has WebGPURenderer (experimental) — try with `await renderer.init()` and same scene graph as WebGL"
          ],
          "cons": [
            "Browser support 2024: Chrome 113+/Edge ✓; Safari TP ✓; Firefox behind flag → production = double-maintain WebGL fallback",
            "API surface 3x WebGL — bind groups, pipeline layouts, queue, command encoder — steep ramp 3+ weeks",
            "WGSL still has churn (v0.10 in 2023 → v0.13 in 2024) — port code between versions; no v1.0 yet",
            "Compute shader debugging = Spector.js only; no shader-level breakpoints; black screen = infinite loop risk",
            "Mobile GPU drivers less mature than WebGL — Adreno/Mali sometimes return wrong results; ship CPU fallback",
            "Documentation still sparse vs WebGL — Mozilla MDN WebGPU pages added 2024 but community answers 10x fewer than WebGL"
          ],
          "gotchas": [
            "Feature-detect: `if (!navigator.gpu) return fallbackToWebGL()` — never assume; on iOS Safari 17.4+ TP only",
            "requestAdapter() can return null on low-end Android — check, then requestDevice(); null device = no GPU",
            "Bind groups must match @group(N) in WGSL exactly — order matters; mismatch = validation error + silent fail",
            "Queue writes are batched — multiple writeBuffer + 1 submit() = best perf; per-frame submit = driver overload",
            "Texture format matters: 'bgra8unorm' on Apple Silicon vs 'rgba8unorm' on AMD — query preferredCanvasFormat",
            "Compute shader workgroup size is a multiple of 64 — pass @workgroup_size(64); use numthreads(64,1,1) in HLSL-translated WGSL",
            "Async pipeline creation = first frame after compile is slow (100ms+) — precompile at scene init or loading screen",
            "Lost device (adapter too hot) — listen to device.lost.then(info => recreateDevice(info)); WebGL doesn't have this lifecycle"
          ],
          "anti_patterns": [
            "DON'T assume WebGPU available — always ship WebGL fallback (or Babylon/Three handles it); iOS users = Safari = broken in 2024",
            "DON'T create one big bind group for whole scene — split per logical resource (camera, lights, materials); bind groups are cheap",
            "DON'T submit command encoder per object — batch all draws into 1 encoder pass; submit() = GPU sync barrier",
            "DON'T use WGSL f32 for indices — use u32; f32 index buffer = validation error + driver panic",
            "DON'T load compute shader from string at runtime — precompile to .wgsl binary at build step; runtime parse = 200ms hitch"
          ],
          "prereqs": [
            "3.7"
          ],
          "next_steps": [
            "3.1"
          ],
          "tags": [
            "webgpu",
            "compute",
            "wgsl",
            "gpu",
            "next-gen",
            "เร็ว"
          ],
          "compare_with": [
            "3.7 WebGL (universal)",
            "3.1 Babylon (engines WebGPU flag)"
          ]
        },
        {
          "id": "3.9",
          "title": "WebSocket Multiplayer (Realtime 2-Way)",
          "icon": "🌐",
          "description": "Bidirectional TCP-over-HTTP socket — server required — low-latency realtime state sync for multiplayer games, collab editors, chat.",
          "when_to_use": "MMORPG (realm-of-heroes); realtime collab whiteboard; co-op tycoon; trading card game; spectator chat; live leaderboard; turn-based battle server-authoritative",
          "when_not_to_use": "One-shot request → use fetch/AJAX; < 5 players no realtime need → use long-poll; P2P voice/video → use WebRTC; broadcast 100k clients → use SSE or MQTT",
          "examples": "realm-of-heroes",
          "snippet": "class GameSocket { connect() { this.ws = new WebSocket(this.url); this.ws.onclose = () => setTimeout(() => this.connect(), Math.min(30000, this.reconnectDelay *= 2)); this.ws.onmessage = e => this.handle(JSON.parse(e.data)); } send(msg) { this.ws.send(JSON.stringify({...msg, seq: ++this.seq})); } }",
          "snippet_full": "// WebSocket — battle-tested reconnecting client (realm-of-heroes pattern)\\nclass GameSocket {\\n  constructor(url, token) { this.url = url; this.token = token; this.seq = 0; this.lastSeq = 0; this.queue = []; this.handlers = new Map(); }\\n  on(type, fn) { (this.handlers.get(type) || this.handlers.set(type, []).get(type)).push(fn); }\\n  connect() {\\n    this.ws = new WebSocket(`${this.url}?token=${this.token}`);\\n    this.ws.onopen = () => { this.attempt = 0; this.flush(); this.heartbeat = setInterval(() => this.ws.readyState === 1 && this.ws.send('ping'), 25000); };\\n    this.ws.onmessage = (e) => { if (e.data === 'pong') return; const msg = JSON.parse(e.data); if (msg.seq <= this.lastSeq) return; this.lastSeq = msg.seq; (this.handlers.get(msg.type) || []).forEach(fn => fn(msg)); };\\n    this.ws.onclose = () => { clearInterval(this.heartbeat); this.attempt = (this.attempt || 0) + 1; setTimeout(() => this.connect(), Math.min(30000, 500 * 2 ** this.attempt)); };\\n    this.ws.onerror = () => this.ws.close();\\n  }\\n  send(msg) { const payload = JSON.stringify({ ...msg, seq: ++this.seq }); this.ws.readyState === 1 ? this.ws.send(payload) : this.queue.push(payload); }\\n  flush() { while (this.queue.length) this.ws.send(this.queue.shift()); }\\n}\\nconst sock = new GameSocket('wss://realm.sj88ai.com/ws', localStorage.token);\\nsock.on('player_move', m => updatePlayer(m.id, m.x, m.y));\\nsock.on('chat', m => addChatLine(m.user, m.text));\\nsock.connect();",
          "difficulty": "Advanced",
          "time_to_learn": "3 days",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket",
          "pros": [
            "Full-duplex persistent connection — server can push anytime (chat message, monster spawn); HTTP request/response can't",
            "Sub-100ms latency on same-region — feels instant for click-to-action in realm-of-heroes PvP",
            "Single TCP socket multiplexes many logical channels (combat/chat/inventory) — one auth, many features",
            "Standard browser API — no library needed for basic client; battle-tested for 20+ years",
            "WebSocket Secure (wss://) = same TLS as HTTPS — works through corporate proxies that block UDP",
            "Server can be Node.js / Python / Go / Elixir — pick your stack; spec is implementation-agnostic"
          ],
          "cons": [
            "Server required — minimum Node ws library OR paid service (Pusher/Ably); DevOps cost",
            "State sync is YOUR problem — engine doesn't auto-reconcile; client A says 'I moved', client B says 'I'm at', who wins = server arbitration",
            "Connection drops require reconnect + state resync — handle 3G subway tunnels gracefully (mobile game rule)",
            "No built-in auth — token in first message, validate server-side; never trust client messages (cheating)",
            "WebSocket frames have overhead — for chat text, SSE may be simpler; for binary video, WebRTC",
            "Scale: 10k concurrent sockets per server is easy; 100k needs Redis pub/sub + sticky sessions (CF/Traefik)"
          ],
          "gotchas": [
            "Reconnect with exponential backoff: `setTimeout(reconnect, Math.min(30000, 1000 * 2**attempt))` — never re-attempt fixed-interval (DOSes server when it comes back up)",
            "Heartbeat ping/pong every 25s — many proxies (Cloudflare, AWS ALB) drop idle connections at 30-60s; pong frame keeps them alive",
            "JSON.stringify every message is 10x slower than binary — for high-frequency updates (60fps positions) use MessagePack or Protobuf",
            "Send a `seq` number per outbound message — server echos; client drops out-of-order packets; otherwise race conditions on state",
            "On reconnect, send `lastSeq` to server; server replays missed events; OR full state-snapshot if `lastSeq < server.bufferStart`",
            "WebRTC for true P2P (no server) is different API — use for voice; WebSocket for game state; don't mix",
            "iOS Safari 14 background → WS closes after 30s — listen for visibilitychange + force reconnect on visible",
            "Don't put WebSocket in main bundle — code-split via dynamic import(); saves 30KB initial JS for users who never join multiplayer"
          ],
          "anti_patterns": [
            "DON'T trust client positions — server must validate move speed/collision; otherwise realm-of-heroes PvP = teleport hack",
            "DON'T broadcast all messages to all players — use rooms/channels; realm-of-heroes area-based subscribe = 90% bandwidth saved",
            "DON'T do heavy computation on every WS message — batch process every 16ms (60fps) tick; per-message handler = CPU spike",
            "DON'T forget to handle 'binary' message type — ws.onmessage event has .data as Blob or ArrayBuffer; decode based on typeof",
            "DON'T use ws.send() in render loop without throttling — at 60fps you flood socket; aggregate 50ms snapshots"
          ],
          "prereqs": [
            "1.7",
            "9.7"
          ],
          "next_steps": [
            "9.2"
          ],
          "tags": [
            "websocket",
            "multiplayer",
            "realtime",
            "network",
            "เน็ตเวิร์ค",
            "socket"
          ],
          "compare_with": [
            "9.7 Fetch/AJAX (one-shot)",
            "WebRTC (P2P)",
            "SSE (server-push only)"
          ]
        },
        {
          "id": "3.10",
          "title": "Game State Machine (FSM)",
          "icon": "🔄",
          "description": "Finite State Machine — entity has 1 state at a time (IDLE/WALK/ATTACK) — guards on transitions prevent invalid combos (e.g., JUMP→JUMP without landing).",
          "when_to_use": "Entity with > 3 behaviors (NPC villager, enemy AI, player combat); UI flow (loading→menu→game→pause→gameover); boss phase manager; AI reactions; matchmaking states",
          "when_not_to_use": "Static puzzle (no state changes); single-action entities (bullet = one lifetime); parallel behaviors → use Hierarchical FSM or Behavior Tree (4.4)",
          "examples": "stronghold-3d, street-fighter-thai, sims-lite",
          "snippet": "const STATES = {IDLE:'idle',WALK:'walk',ATTACK:'attack'}; const T = {idle:{walk:()=>true}, walk:{idle:()=>Math.random()<.1, attack:m=>m.targetInRange}}; class FSM { setState(next) { /* guard + onEnter + onExit */ } }",
          "snippet_full": "// FSM — NPC villager (stronghold-3d pattern)\\nconst STATES = { IDLE: 'idle', WANDER: 'wander', WORK: 'work', SLEEP: 'sleep' };\\nconst TRANSITIONS = {\\n  idle: { wander: () => true },\\n  wander: { idle: (v) => v.energy < 20 || v.arrived, work: (v) => v.energy > 50 && v.shift === 'day', sleep: (v) => v.time === 'night' },\\n  work: { idle: (v) => v.taskDone, sleep: (v) => v.time === 'night' },\\n  sleep: { idle: (v) => v.time === 'day' && v.energy > 80 },\\n};\\nclass FSM {\\n  constructor(entity) { this.entity = entity; this.current = STATES.IDLE; this.handlers = new Map(); }\\n  on(state, evt, fn) { const k = `${state}:${evt}`; if (!this.handlers.has(k)) this.handlers.set(k, []); this.handlers.get(k).push(fn); }\\n  setState(next, ctx = {}) {\\n    if (next === this.current) return;\\n    const guard = TRANSITIONS[this.current]?.[next];\\n    if (guard && !guard(this.entity)) return;\\n    const exitHandlers = this.handlers.get(`${this.current}:exit`) || [];\\n    exitHandlers.forEach(fn => fn(this.entity, ctx));\\n    const prev = this.current; this.current = next;\\n    const enterHandlers = this.handlers.get(`${next}:enter`) || [];\\n    enterHandlers.forEach(fn => fn(this.entity, ctx, prev));\\n  }\\n}\\nconst v = { energy: 50, time: 'day', shift: 'day', arrived: false, taskDone: false };\\nconst fsm = new FSM(v);\\nfsm.on('wander', 'enter', (v) => { v.target = randomNearbyTile(); });\\nfsm.on('work', 'enter', (v) => { v.job = pickJob(v); });\\nfsm.on('sleep', 'enter', (v) => { v.bed = findBed(v); });\\nfsm.setState('wander'); // enters wander\\nfsm.setState('work'); // guard: energy>50 && shift=day → ok\\nfsm.setState('attack'); // guard fails: no transition from 'work' → rejected",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://en.wikipedia.org/wiki/Finite-state_machine",
          "pros": [
            "States + transitions = visual diagram → stronghold-3d villagers: IDLE→WANDER→WORK→SLEEP all in one PNG",
            "Guards prevent invalid combos (JUMP only when onGround) — bug class 'attack while dead' impossible by design",
            "onEnter/onExit hooks = clean lifecycle — play anim once on ATTACK enter, stop sound on DEATH exit",
            "Trivial to debug — log state transitions; replay shows exactly when AI broke (sims-lite NPC debugging)",
            "Maps directly to Unity Mecanim / Unreal StateTree — port to native engines is 1:1",
            "String state IDs survive JSON save/load — int enums break if you reorder (street-fighter-thai rule)"
          ],
          "cons": [
            "Parallel states need Hierarchical FSM (HFSM) — basic FSM can't express 'WALK while CARRYING while HUNGRY' simultaneously",
            "Boilerplate per state — 5 states × {enter, exit, update, transition} = 20 funcs; behavior tree collapses some",
            "Transitions can grow — 5x5 matrix = 25 cells; many are 'never'; still need to declare to fail-safe",
            "No memory of past states — 'just attacked 3 times in a row' needs state counter on top of FSM",
            "String state IDs typo-prone — `setState('ATTAK')` silently fails if not guarded by Set check",
            "Doesn't model goal-directed AI well — 'go find food' needs BT planner on top; FSM is reactive not proactive"
          ],
          "gotchas": [
            "Use string IDs not numeric enums — `STATES.IDLE = 'idle'`. Numeric enums reorder when JSON load gives wrong value (street-fighter-thai lesson)",
            "Guard functions return boolean: `{ idle: { walk: () => true, attack: m => m.inRange && m.stamina > 0 } }` — guards checked in order",
            "Map<state, transitions> not object literal — Map preserves insertion order across browsers; {} order is spec-but-implementation-leaky",
            "Pre-condition check inside guard, NOT inside setState — setState should be dumb; guards do the validation",
            "onExit called BEFORE onEnter of new state — order matters for anim cleanup (sims-lite: stop walk anim before start attack anim)",
            "Don't transition to same state — `if (next === this.current) return`; otherwise onEnter fires twice causing anim restart glitch",
            "State machine for UI screens is global singleton, not per-entity — `class GameState { state = 'menu' }` lives at root",
            "Save/load: serialize current state ID + counters; on resume, force onEnter to re-init timers (don't trust restored timer values)"
          ],
          "anti_patterns": [
            "DON'T use numeric enums for state — `STATES.IDLE = 0`; reorder JSON = wrong state. Use string literals",
            "DON'T mutate current state directly — always go through setState(next); otherwise onEnter/onExit skipped, anim desync",
            "DON'T put data inside state object — state ID is just a string; counters/inventory live in entity data (separation of concerns)",
            "DON'T nest 5+ levels of FSM — flatten with sub-states or switch to Behavior Tree; deep nesting = spaghetti",
            "DON'T forget to handle 'interrupted' transitions — if AI was WALK and you set to ATTACK, the WALK onExit must reset pathfinding"
          ],
          "prereqs": [
            "1.3"
          ],
          "next_steps": [
            "4.4",
            "4.7"
          ],
          "tags": [
            "fsm",
            "state",
            "ai",
            "entity",
            "state-machine",
            "สถานะ"
          ],
          "compare_with": [
            "4.4 Behavior Tree (more expressive)",
            "4.2 ECS (data-oriented)",
            "1.3 Module Pattern (state holder)"
          ]
        }
      ]
    },
    {
      "id": 4,
      "name": "Game Patterns",
      "icon": "🏛️",
      "tagline": "Patterns that make games addictive",
      "sub_phases": [
        {
          "id": "4.1",
          "title": "Game Loop (60fps + Delta Time)",
          "icon": "⏱️",
          "description": "Update with dt seconds (NOT per-frame constants). Decouple sim Hz from render Hz via fixed-step accumulator.",
          "when_to_use": "ทุกเกมที่มี animation/movement/physics | Every game with animation, movement or physics; sims with time scale",
          "when_not_to_use": "Static page ไม่มี animation; < 10 entities; pure UI ที่ใช้ CSS transitions อย่างเดียว",
          "examples": "stronghold-3d, noodle-shop, sims-lite, street-fighter-thai",
          "snippet": "const DT=1/60;let acc=0,last=performance.now();function tick(){const now=performance.now();const dt=Math.min((now-last)/1000,0.25);last=now;acc+=dt*timeScale;while(acc>=DT){update(DT);acc-=DT;}render(acc/DT);requestAnimationFrame(tick)}",
          "snippet_full": "const DT = 1/60; // 60Hz fixed physics step\\nlet acc = 0, last = performance.now(), timeScale = 1;\\nfunction tick() {\\n  const now = performance.now();\\n  // Clamp dt: tab background return can be 5+ seconds\\n  const realDt = Math.min((now - last) / 1000, 0.25);\\n  last = now;\\n  // Slow-mo / pause: scale realDt input only, NOT the DT reference\\n  acc += realDt * timeScale;\\n  // Fixed-step physics: deterministic regardless of render Hz\\n  while (acc >= DT) { update(DT); acc -= DT; }\\n  // Interpolation factor: smooth render between physics ticks\\n  render(acc / DT);\\n  requestAnimationFrame(tick);\\n}\\nrequestAnimationFrame(tick); // bootstrap",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://gafferongames.com/post/fix_your_timestep/",
          "pros": [
            "Frame-rate independent — same physics outcome on 60Hz phone and 144Hz monitor because dt normalizes time",
            "Decoupled sim/render Hz — physics ticks at fixed 60Hz while render interpolates at any monitor refresh",
            "Pause/slow-mo trivial — multiply DT by 0 (pause) or 0.5 (slow-mo); no special code paths needed",
            "Deterministic replays — fixed DT + seeded RNG = byte-identical playback for netcode or sharing",
            "Tab-background safe — clamp dt to 0.25s prevents physics explosion when user returns from background tab"
          ],
          "cons": [
            "Accumulator math non-obvious — beginners copy-paste without understanding why spiral-of-death happens",
            "Multiple update calls per render wastes CPU on slow devices — 144Hz render + 60Hz sim = 2.4x physics calls",
            "Float drift over long sessions — hours 12+ see sub-millisecond desync, breaks frame-perfect games",
            "Time scaling breaks input if DT not normalized — character moves slower when paused mid-jump",
            "Interpolation adds 1-frame visual lag — visible at 30Hz render; competitive fighting games skip interpolation"
          ],
          "gotchas": [
            "ALWAYS clamp dt to ~0.25s — tab background return can deliver 5+ seconds dt = physics blow-up on first tick",
            "requestAnimationFrame pauses when tab hidden — never trust frame count for time, always use performance.now()",
            "Fixed timestep (Glenn Fiedler's pattern) prevents non-determinism across framerates — without it, replays differ",
            "Accumulator pattern: `while (acc >= DT) { update(DT); acc -= DT; }` — if overshoot, queue leftover for next frame",
            "Decimal time (ms) accumulates float error over 24h — use modular snapshots or reset delta periodically",
            "Audio scheduling must use AudioContext.currentTime not rAF — clock skew between audio and game thread possible",
            "Slow-mo: scale BOTH the realDt input AND the DT reference, else physics desyncs from rendered position",
            "First frame dt = 0 — guard with `if (last === 0) last = now` or physics does nothing on frame 1"
          ],
          "anti_patterns": [
            "Update everything per-frame without dt — character speed differs wildly on 60Hz vs 144Hz monitors",
            "Use setInterval instead of requestAnimationFrame — drifts, runs in background tabs, can't pause cleanly",
            "Clamp dt to 'make it look right' instead of fixing physics step — hides bug, breaks determinism",
            "Skip interpolation when rendering at fixed Hz — visible stutter every 16ms on 30Hz devices",
            "Trust wall-clock for game time — Date.now() jumps on NTP sync; use performance.now() monotonic clock"
          ],
          "next_steps": [
            "4.2",
            "4.3",
            "4.4",
            "4.5"
          ],
          "tags": [
            "loop",
            "fps",
            "delta-time",
            "fixed-timestep",
            "game-loop",
            "เกมลูป",
            "เฟรมเรท"
          ],
          "compare_with": [
            "4.2 ECS (system tick order matters)",
            "1.4 localStorage (debounce save on dt accumulation)"
          ]
        },
        {
          "id": "4.2",
          "title": "Entity-Component System (ECS)",
          "icon": "🧩",
          "description": "Entity (just an ID) + Component (pure data) + System (pure logic). Compose behaviors by mixing components.",
          "when_to_use": "Game engine, buff/debuff systems, particle systems, simulation > 100 entities | เกมที่ entity หลายร้อย+ และต้องการ mix behaviors แบบ runtime",
          "when_not_to_use": "< 100 entities with stable class hierarchy; one-off scripts; tiny prototypes where class inheritance is clearer",
          "examples": "stronghold-3d, sims-lite, mars-colony, fps-cops-vs-robbers",
          "snippet": "class World{create(){return this.nextId++}add(id,t,d){(this.comps[t]=this.comps[t]||new Map).set(id,d)}tick(dt){this.systems.forEach(s=>s(this,dt))}} const w=new World();const p=w.create();w.add(p,'Pos',{x:0,y:0});w.add(p,'Vel',{dx:10,dy:0});w.systems.push((w,dt)=>{for(const[id,pos]of w.comps.Pos||[]){const v=w.comps.Vel?.get(id);if(v)pos.x+=v.dx*dt}});",
          "snippet_full": "class World {\\n  constructor() {\\n    this.nextId = 1;\\n    this.comps = new Map();   // type -> Map<id, data>\\n    this.systems = [];         // (world, dt) => void, ordered pipeline\\n  }\\n  create() { return this.nextId++; }\\n  add(id, type, data) {\\n    if (!this.comps.has(type)) this.comps.set(type, new Map());\\n    this.comps.get(type).set(id, data);\\n    return id;\\n  }\\n  get(type, id) { return this.comps.get(type)?.get(id); }\\n  // Explicit pipeline order — no implicit dependency guessing\\n  tick(dt) { for (const sys of this.systems) sys(this, dt); }\\n}\\nconst w = new World();\\nconst player = w.create();\\nw.add(player, 'Pos',    { x: 0, y: 0 });\\nw.add(player, 'Vel',    { dx: 10, dy: 0 });\\nw.add(player, 'Health', { hp: 100 });\\n// Move system: iterates only entities with BOTH Pos and Vel\\nw.systems.push((w, dt) => {\\n  for (const [id, pos] of w.comps.get('Pos') || []) {\\n    const vel = w.get('Vel', id);\\n    if (vel) { pos.x += vel.dx * dt; pos.y += vel.dy * dt; }\\n  }\\n});\\n// Render system: depends on Pos only\\nw.systems.push((w) => { for (const [id, pos] of w.comps.get('Pos') || []) draw(id, pos); });\\nw.tick(1/60);",
          "difficulty": "Advanced",
          "time_to_learn": "3 days",
          "pros": [
            "Composition > inheritance — add Burnable component to ANY entity (player, NPC, tree) without class hierarchy change",
            "Cache-friendly SoA storage — components in contiguous TypedArrays, CPU prefetch works, 10-100x faster than OOP",
            "Parallel systems — pure-function systems run on Web Workers without locks (Unity DOTS proves this at scale)",
            "Hot-swap behaviors — add/remove Shielded component at runtime, no recompile, designer-friendly inspector",
            "Reuse across archetypes — same Move system iterates player and enemy, no code duplication",
            "Deterministic for netcode — systems read all components, no method dispatch variance across machines"
          ],
          "cons": [
            "Boilerplate — every behavior = component type + system function + iteration code, 3 places to edit per change",
            "Debug trail scattered — entity 'the player' is 5 entries across component maps, not one object to inspect",
            "Premature for small games — < 100 entities: just use class instances, easier to grep, faster to ship",
            "Query complexity — 'find all entities with Position AND Velocity' requires iteration, not free",
            "Refactor cost — moving data between components breaks every save file that referenced the old structure",
            "Event passing awkward — entity A needs to talk to entity B across system boundaries, no direct method call"
          ],
          "gotchas": [
            "Components MUST be pure data — never put methods on component objects (Unity DOTS rule, breaks archetype SoA)",
            "Systems MUST be pure logic — read components, write components, no side effects outside assigned components",
            "Entity ID is opaque token — UUID or int, never reuse after delete (stale references in saves silently corrupt)",
            "Sparse set vs archetype storage — sparse flexible, archetype cache-fast (10-100x); Unity DOTS uses archetypes",
            "Don't store entity references in components — store IDs; entity refs break serialization and create cycles",
            "System execution order matters — Physics → Movement → Animation → Render → UI; explicit pipeline, not implicit",
            "Serialization = list components per entity — `[{id, comps: [Pos, Vel, Health]}]` round-trips through JSON",
            "Archetype churn is expensive — adding/removing a component moves entity to new archetype group; batch changes"
          ],
          "anti_patterns": [
            "Inheritance 'GameObject extends Entity' — defeats composition; go back to OOP if you need this",
            "Systems that touch DOM directly — side effects across ticks = race conditions, debug hell",
            "Components holding references to other entities — breaks serialization, creates GC cycles, refactor nightmare",
            "Method on component object — turns component into mini-class, defeats data-oriented design and SoA caching",
            "Single mega-system doing everything — splits physics/render coupling into god-object, hard to parallelize"
          ],
          "prereqs": [
            "4.1"
          ],
          "tags": [
            "ecs",
            "data-oriented",
            "composition",
            "entity-component",
            "architecture",
            "สถาปัตยกรรมเกม"
          ],
          "compare_with": [
            "OOP inheritance (simpler for <100 entities)",
            "4.4 Behavior Trees (orthogonal; can be a system)"
          ]
        },
        {
          "id": "4.3",
          "title": "Pathfinding A*",
          "icon": "🗺️",
          "description": "Shortest path on grid via open/closed sets + admissible heuristic. Optimal when heuristic never overestimates.",
          "when_to_use": "NPC path, tower defense creep path, RTS unit move, tile-based game AI | เกมที่ entity เดินบน grid และต้องการ shortest path",
          "when_not_to_use": "Continuous world with smooth movement (use NavMesh), < 10 entities (BFS suffices), realtime 1000+ units (use flow fields)",
          "examples": "stronghold-3d, noodle-shop, sims-lite, mars-colony, street-fighter-thai",
          "snippet": "function aStar(g,s,goal){const h=(x,y)=>Math.abs(x-goal.x)+Math.abs(y-goal.y);const open=[{x:s.x,y:s.y,g:0,f:h(s.x,s.y)}];const closed=new Set();while(open.length){let bi=0;for(let i=1;i<open.length;i++)if(open[i].f<open[bi].f)bi=i;const c=open.splice(bi,1)[0];if(c.x===goal.x&&c.y===goal.y)return c;closed.add(c.x+','+c.y);for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){const nx=c.x+dx,ny=c.y+dy;if(g[ny]?.[nx])continue;if(closed.has(nx+','+ny))continue;open.push({x:nx,y:ny,g:c.g+1,f:c.g+1+h(nx,ny),p:c})}}return null}",
          "snippet_full": "// Min-heap: openSet stays O(log n) vs Array.shift O(n)\\nclass MinHeap {\\n  constructor() { this.h = []; }\\n  push(x) { this.h.push(x); this._up(this.h.length - 1); }\\n  pop() {\\n    const top = this.h[0]; const last = this.h.pop();\\n    if (this.h.length) { this.h[0] = last; this._down(0); }\\n    return top;\\n  }\\n  _up(i) { while (i > 0) { const p = (i-1) >> 1; if (this.h[p].f <= this.h[i].f) break; [this.h[p], this.h[i]] = [this.h[i], this.h[p]]; i = p; } }\\n  _down(i) { const n = this.h.length; while (true) { let s = i; const l = 2*i+1, r = 2*i+2; if (l<n && this.h[l].f<this.h[s].f) s=l; if (r<n && this.h[r].f<this.h[s].f) s=r; if (s===i) break; [this.h[s], this.h[i]] = [this.h[i], this.h[s]]; i = s; } }\\n}\\nfunction aStar(grid, start, goal) {\\n  // Octile heuristic: 8-dir with diagonal cost = sqrt(2)\\n  const D = 1, D2 = Math.SQRT2;\\n  const h = (x, y) => { const dx = Math.abs(x - goal.x), dy = Math.abs(y - goal.y); return D * (dx + dy) + (D2 - 2*D) * Math.min(dx, dy); };\\n  const open = new MinHeap();\\n  const gScore = new Map();\\n  const key = (n) => n.x + ',' + n.y;\\n  gScore.set(key(start), 0);\\n  open.push({ x: start.x, y: start.y, g: 0, f: h(start.x, start.y), parent: null });\\n  const dirs = [[1,0,D],[-1,0,D],[0,1,D],[0,-1,D],[1,1,D2],[1,-1,D2],[-1,1,D2],[-1,-1,D2]];\\n  while (open.h.length) {\\n    const cur = open.pop();\\n    if (cur.x === goal.x && cur.y === goal.y) {\\n      const path = []; let n = cur;\\n      while (n) { path.unshift({ x: n.x, y: n.y }); n = n.parent; }\\n      return path;\\n    }\\n    for (const [dx, dy, cost] of dirs) {\\n      const nx = cur.x + dx, ny = cur.y + dy;\\n      if (nx < 0 || ny < 0 || ny >= grid.length || nx >= grid[0].length) continue;\\n      if (grid[ny][nx] === 1) continue; // wall\\n      const tentG = cur.g + cost;\\n      const k = nx + ',' + ny;\\n      // Only update if this path is BETTER than any previous\\n      if (tentG < (gScore.get(k) ?? Infinity)) {\\n        gScore.set(k, tentG);\\n        open.push({ x: nx, y: ny, g: tentG, f: tentG + h(nx, ny), parent: cur });\\n      }\\n    }\\n  }\\n  return null; // unreachable\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 day",
          "docs_url": "https://en.wikipedia.org/wiki/A*_search_algorithm",
          "pros": [
            "Optimal with admissible heuristic — Manhattan 4-dir, Octile 8-dir; never returns longer path than true optimum",
            "Composable heuristics — swap Manhattan→Euclidean→Octile based on movement cost model without algorithm change",
            "JPS (Jump Point Search) — 2-10x faster by skipping symmetric neighbors, same optimality, same API",
            "Binary heap priority queue — openSet stays O(log n) per insert/extract vs naive array O(n)",
            "Cache-able per query — re-plan only on grid change, not every move; flow field pre-compute for 1000+ units",
            "Tie-breaking for speed — prefer goal-direction when f-scores tie, 2-3x speedup without losing optimality"
          ],
          "cons": [
            "Memory O(n²) worst case — 1000x1000 grid = 1M nodes × 100B = 100MB; chunk grid or use hierarchical A*",
            "Re-planning on dynamic obstacles expensive — D* Lite handles but adds complexity; consider flow fields for static",
            "Not smooth on grid — paths have 8-way zigzag, need string-pulling / funnel algorithm post-process",
            "CPU spike on first plan — pre-compute flow fields for 1000+ unit RTS games (Tom Clancy's EndWar pattern)",
            "Heuristic tuning is project-specific — wrong heuristic = non-optimal path or 10x slower search",
            "Tile cost changes break admissibility — cost=2 tile + Manhattan = overestimates goal, returns non-optimal"
          ],
          "gotchas": [
            "Manhattan for 4-dir movement — Octile `(D*(dx+dy) + (D2-2D)*min(dx,dy))` for 8-dir where D2=√2",
            "Binary heap (min-heap) for openSet — `Array.shift()` is O(n), heap extract is O(log n); 100x difference at 10k nodes",
            "f = g + h; g = actual cost so far; h = estimated cost to goal; h must NEVER overestimate (admissible)",
            "Closed set ≠ 'visited' — it's 'we found OPTIMAL g for this node'; revisit if better g found later",
            "Tie-breaking: prefer nodes with smaller h when f ties — pulls search toward goal, 2-3x speedup",
            "Path reconstruction via parent pointers — null parent = start; chain back to start to get path array",
            "Diagonal corner-cutting forbidden when EITHER adjacent cardinal is blocked — prevents squeezing through walls",
            "Reset closed set between queries — reusing across queries with different goals = suboptimal paths"
          ],
          "anti_patterns": [
            "Store node object in openSet without hashing — O(n²) linear scan per pop, kills performance at 1000+ nodes",
            "Use heuristic = 0 — becomes Dijkstra, 5-10x slower than A* with good heuristic",
            "Forget to rebuild path via parent pointers — silent null returns, NPC stands still with no error",
            "Re-plan full A* every frame — use path cache, invalidate only on map change; 100x perf improvement",
            "Misuse Euclidean on grid — overestimates for 8-dir movement, returns SUBOPTIMAL path (silent bug)"
          ],
          "prereqs": [
            "4.1"
          ],
          "next_steps": [
            "4.4",
            "4.5"
          ],
          "tags": [
            "pathfinding",
            "ai",
            "a-star",
            "graph",
            "algorithm",
            "pathfinding",
            "ai-pathfinding"
          ],
          "compare_with": [
            "Dijkstra (slower but always optimal)",
            "JPS Jump Point Search (10x faster)",
            "Flow fields (better for 1000+ units)"
          ]
        },
        {
          "id": "4.4",
          "title": "Behavior Trees (NPC AI)",
          "icon": "🌳",
          "description": "Hierarchical nodes: Selector (OR), Sequence (AND), Decorator. Designer-editable visual tree for NPC decisions.",
          "when_to_use": "NPC AI หลาย actions, Boss AI phases, Squad tactics, complex reactive behavior | Boss/เควส NPC ที่มีหลาย state",
          "when_not_to_use": "< 3 actions (FSM simpler), single-script AI, simple state machine suffices, performance-critical tick-every-frame for 10k NPCs",
          "examples": "stronghold-3d, sims-lite, mars-colony, street-fighter-thai",
          "snippet": "class Selector{tick(c){for(const ch of this.ch){const r=ch.tick(c);if(r==='success'||r==='running')return r}return 'failure'}} class Sequence{tick(c){for(const ch of this.ch){const r=ch.tick(c);if(r==='failure'||r==='running')return r}return 'success'}} const bt=new Selector([new Sequence([new Cond(c=>c.hp<30),new Action(c=>{c.state='flee';return 'success'})]),new Action(c=>{c.state='work';return 'success'})]);",
          "snippet_full": "// 3-state result: Success / Failure / Running\\nconst S = 'success', F = 'failure', R = 'running';\\n// Composite OR: try children until one succeeds\\nclass Selector {\\n  constructor(children) { this.children = children; }\\n  tick(ctx) {\\n    for (const c of this.children) {\\n      const r = c.tick(ctx);\\n      if (r === S || r === R) return r; // short-circuit\\n    }\\n    return F;\\n  }\\n}\\n// Composite AND: all children must succeed\\nclass Sequence {\\n  constructor(children) { this.children = children; }\\n  tick(ctx) {\\n    for (const c of this.children) {\\n      const r = c.tick(ctx);\\n      if (r === F || r === R) return r; // short-circuit\\n    }\\n    return S;\\n  }\\n}\\n// Decorator: invert child's result (Success<->Failure)\\nclass Inverter {\\n  constructor(child) { this.child = child; }\\n  tick(ctx) {\\n    const r = this.child.tick(ctx);\\n    return r === S ? F : r === F ? S : R;\\n  }\\n}\\n// Leaf: predicate\\nclass Cond {\\n  constructor(fn) { this.fn = fn; }\\n  tick(ctx) { return this.fn(ctx) ? S : F; }\\n}\\n// Leaf: action; can return Running for multi-tick ops like pathfind\\nclass Action {\\n  constructor(fn) { this.fn = fn; }\\n  tick(ctx) { return this.fn(ctx) || S; }\\n}\\n// Villager AI: flee if low HP, else eat if hungry, else work\\nconst villagerBT = new Selector([\\n  new Sequence([ new Cond(c => c.hp < 30), new Action(c => { c.state = 'flee'; return S; }) ]),\\n  new Sequence([ new Cond(c => c.hunger > 80), new Action(c => { c.state = 'eat';  return S; }) ]),\\n  new Inverter(new Cond(c => c.atWork)),\\n  new Action(c => { c.state = 'work'; return S; })\\n]);\\n// Event-driven tick: only call villagerBT.tick when ctx changes",
          "difficulty": "Advanced",
          "time_to_learn": "1 week",
          "docs_url": "https://www.gamedeveloper.com/programming/behavior-trees-for-ai-how-they-work",
          "pros": [
            "Visual structure = designer edits without code — JSON export/import, Unreal/Unity plugins, no engineering round-trip",
            "Composable nodes — Inverter/Repeater/Cooldown/UntilFail decorators instead of writing each pattern",
            "Reuse across 100 NPC archetypes — same 'Flee' subtree in goblin and dragon via shared subtree reference",
            "Event-driven beats tick-every-frame — 100x less CPU when NPC idle (subscribe to OnTakeDamage, not tick)",
            "Decoupled from entity data — blackboard pattern separates tree structure from entity state",
            "Reactive to external events — 'OnSeeEnemy' instantly triggers attack branch without polling"
          ],
          "cons": [
            "Tick overhead even when no change — solution: external trigger list + tick only on event subscription",
            "Stack depth errors — recursion depth = tree depth, > 1000 levels crashes; iterative traversal for deep trees",
            "State machine in disguise — pure tick-every-frame trees are FSM with extra steps; events needed for value",
            "Debugging harder than FSM — 'why did goblin walk into wall?' = trace parent chain through tree state",
            "Designer learning curve — Sequence vs Selector semantics ≠ intuitive AND/OR; 1-day training needed",
            "Blackboard naming conflicts — two NPC trees reading same 'target' key cross-contaminate state"
          ],
          "gotchas": [
            "Selector (OR): tick children left-to-right, first SUCCESS/RUNNING wins; if all FAIL → return FAILURE",
            "Sequence (AND): tick children left-to-right, any FAILURE/RUNNING short-circuits; all SUCCESS → SUCCESS",
            "Decorators wrap single child: Inverter (Success↔Failure), Repeater (loop N times), UntilFail (loop until FAIL)",
            "Action returns 3 states: Success / Failure / Running — Running means 'still working, tick me next frame'",
            "Event-driven > tick-every-frame — subscribe to events, queue triggers, tick on wake; 100x less CPU",
            "Blackboard = shared state — namespace by entity or global; conflicts are silent bugs, version key prefixes help",
            "Priority order in Selector matters — cheap checks FIRST (cooldown check before pathfind), 10x speedup",
            "Re-entry mid-tick breaks — Sequence returns Running then immediate re-tick = state corruption; protect with mutex"
          ],
          "anti_patterns": [
            "Tick the tree every frame for ALL NPCs regardless — wastes CPU; use event trigger + wake list",
            "Action node reads entity state directly — couples tree to data shape; use blackboard for testability",
            "Sequence with no Failure path — entire NPC stuck forever once first child fails (deadlock)",
            "Same tree INSTANCE shared across entities — blackboard reads cross-contaminate; clone or per-entity BB",
            "Decorator recursion without base case — Repeater with no Failure returns = infinite loop, tab freezes"
          ],
          "prereqs": [
            "4.1",
            "4.3"
          ],
          "next_steps": [
            "4.7",
            "4.8"
          ],
          "tags": [
            "ai",
            "behavior-tree",
            "npc",
            "decision",
            "bt",
            "ai-behavior",
            "npc-ai"
          ],
          "compare_with": [
            "FSM (simpler for <5 states)",
            "GOAP planning (more flexible but slower)",
            "Utility AI (numerical scoring, harder to visualize)"
          ]
        },
        {
          "id": "4.5",
          "title": "Procedural Generation",
          "icon": "🎲",
          "description": "Random content via seeded RNG. Same seed = identical output. Combine algorithms for natural-feeling variety.",
          "when_to_use": "Roguelike levels, sandbox worlds, test data, daily challenges, shareable seeds | เกมที่ต้องการ infinite content",
          "when_not_to_use": "Story-driven linear game, hand-crafted levels required, art direction needs precise control",
          "examples": "stronghold-3d, mars-colony, sims-lite, fps-cops-vs-robbers",
          "snippet": "function mulberry32(s){return function(){let t=s+=0x6D2B79F5;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296}} const rng=mulberry32(seed); const map=Array.from({length:30},()=>Array.from({length:50},()=>rng()<0.45?1:0));",
          "snippet_full": "// mulberry32: 5-line seedable RNG, 2^32 period, good for games\\nfunction mulberry32(seed) {\\n  return function() {\\n    let t = (seed += 0x6D2B79F5);\\n    t = Math.imul(t ^ (t >>> 15), t | 1);\\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\\n  };\\n}\\n// 2D value noise: cheap, deterministic per (x,y) for given seed\\nfunction valueNoise(seed, x, y) {\\n  const xi = Math.floor(x), yi = Math.floor(y);\\n  const xf = x - xi, yf = y - yi;\\n  const a = mulberry32(seed + xi * 7  + yi * 13)();\\n  const b = mulberry32(seed + (xi+1)*7 + yi * 13)();\\n  const c = mulberry32(seed + xi * 7  + (yi+1)*13)();\\n  const d = mulberry32(seed + (xi+1)*7 + (yi+1)*13)();\\n  // smoothstep interpolation: smooth derivatives at integer boundaries\\n  const u = xf * xf * (3 - 2 * xf);\\n  const v = yf * yf * (3 - 2 * yf);\\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\\n}\\n// 40x60 cave: multi-octave noise thresholded to wall/floor\\nfunction generateCave(seed, w = 40, h = 60) {\\n  const map = Array.from({ length: h }, (_, y) =>\\n    Array.from({ length: w }, (_, x) => {\\n      let n = 0, amp = 1, freq = 0.05;\\n      // 4 octaves: large features + medium + small + detail\\n      for (let o = 0; o < 4; o++) {\\n        n += valueNoise(seed + o * 1000, x * freq, y * freq) * amp;\\n        amp *= 0.5; freq *= 2;\\n      }\\n      return n > 0.5 ? 1 : 0; // 1 = wall, 0 = floor\\n    })\\n  );\\n  // Validation: flood-fill from (1,1); if reachable < 30% of floors, regenerate\\n  return validate(map) ? map : generateCave(seed + 1, w, h);\\n}\\n// Usage: same seed = byte-identical output, share-able\\nconst cave = generateCave(8675309);",
          "difficulty": "Intermediate",
          "time_to_learn": "3 days",
          "docs_url": "https://github.com/MauledbyYermi/PCG-Book",
          "pros": [
            "Infinite content without storage — 1KB seed generates GB of world; procedural saves disk, ships bigger games",
            "Shareable seeds — 'try seed 8675309' = reproducible competition (Spelunky Daily Challenge, No Man's Sky portals)",
            "Always-fresh gameplay — no memorization reduces boredom in roguelikes; 1000 hours vs 10 hours",
            "Combined algorithms — Perlin + Voronoi + Poisson gives natural-feeling output (terrain/biomes/trees)",
            "Easy unit testing — fixed seed = deterministic output = regression-testable; CI golden fixtures",
            "Player perception of 'more game' — 1000 generated levels feel like 1000 hand-crafted to most players"
          ],
          "cons": [
            "Quality variance — bad seeds produce broken/unfun levels; validation pass mandatory, not optional",
            "Combinatorial explosion — 100 rooms × 10 templates = 1000 cache slots; LRU cache or compute-on-demand",
            "AI-slop feel — humans detect patterns in 5-10 minutes; pure noise feels hollow, needs curation",
            "Designer override needed — hand-crafted set pieces must override algorithm (boss rooms, story beats)",
            "Performance — 100k tile Perlin = 50ms freeze on slow phones; pre-compute or Web Worker offload",
            "Seed bug reproducibility — wrong RNG = different output, hard to debug without exact seed string"
          ],
          "gotchas": [
            "ALWAYS seed RNG (mulberry32 / sfc32) — Math.random() is unseedable in browsers; same seed MUST give same output",
            "Validate generated output — connected rooms? spawn reachable? boss arena exists? no isolated islands?",
            "Combine techniques: Perlin for heightmap + Poisson for tree placement + Voronoi for biome boundaries",
            "Layer noise frequencies — 1 octave smooth, 8 octaves realistic but 8x cost; tune to content type",
            "Player-progression fairness — generation must adapt to player level (Angry Birds stage difficulty curve)",
            "Determinism needs consistent iteration order — never Set, always sorted Array or insertion-ordered Map",
            "RNG state pollution — each generator reseeds with own seed, not reuse parent RNG (coupling bug)",
            "Output quality check: count dead-ends, isolated islands, unreachable regions; regenerate if fail"
          ],
          "anti_patterns": [
            "Pure Math.random() — unseedable, breaks shareable seeds, no reproducible bugs",
            "Single algorithm for everything — samey output, players spot pattern in minutes; layer techniques",
            "No validation pass — ship broken levels; players find seeds that crash or trap them",
            "Generation on render thread — main thread freezes on Perlin compute; Web Worker or pre-compute",
            "Same RNG instance threaded through parallel async — race conditions in output, hard-to-repro bugs"
          ],
          "prereqs": [
            "4.1"
          ],
          "tags": [
            "procedural",
            "random",
            "pcg",
            "seed",
            "noise",
            "perlin",
            "สุ่ม",
            "มัลเบอร์รี่"
          ],
          "compare_with": [
            "Wave Function Collapse (constraint-based, better for tilesets)",
            "Hand-authored levels (quality ceiling higher but 100x dev cost)"
          ]
        },
        {
          "id": "4.6",
          "title": "Inventory System (Slot/Stack)",
          "icon": "🎒",
          "description": "Slot-based array with maxStack per item, stack-first then empty-slot fill, Object.freeze templates.",
          "when_to_use": "RPG inventory, tycoon resource stock, survival crafting, equipment loadout | เกมที่ player เก็บของได้หลายชิ้น",
          "when_not_to_use": "Single-item pickup, key-only progression, simple carry counter, no UI for items",
          "examples": "stronghold-3d, sims-lite, noodle-shop, street-fighter-thai",
          "snippet": "const ITEMS=Object.freeze({potion:{maxStack:99,icon:'🧪'},sword:{maxStack:1,icon:'⚔️'},arrow:{maxStack:99,icon:'➶'}});class Inv{constructor(n=20){this.slots=new Array(n).fill(null)}add(id,q=1){const t=ITEMS[id];for(const s of this.slots)if(s?.id===id&&s.q<t.maxStack){const take=Math.min(q,t.maxStack-s.q);s.q+=take;q-=take;if(q<=0)return true}for(let i=0;i<this.slots.length&&q>0;i++)if(!this.slots[i]){this.slots[i]={id,q:Math.min(q,t.maxStack)};q-=this.slots[i].q}return q===0}}",
          "snippet_full": "// Item template IMMUTABLE — all inventories reference same object\\nconst ITEMS = Object.freeze({\\n  potion: { name: 'Potion',   maxStack: 99, icon: '🧪' },\\n  sword:  { name: 'Sword',    maxStack: 1,  icon: '⚔️' },\\n  arrow:  { name: 'Arrow',    maxStack: 99, icon: '➶' },\\n  wood:   { name: 'Wood',     maxStack: 99, icon: '🪵' }\\n});\\nclass Inventory {\\n  constructor(maxSlots = 20) {\\n    this.max = maxSlots;\\n    this.slots = new Array(maxSlots).fill(null);\\n  }\\n  add(itemId, qty = 1) {\\n    const tpl = ITEMS[itemId];\\n    if (!tpl) throw new Error('Unknown item: ' + itemId);\\n    // Phase 1: fill existing PARTIAL stacks first (auto-merge)\\n    for (const s of this.slots) {\\n      if (s && s.itemId === itemId && s.qty < tpl.maxStack) {\\n        const take = Math.min(qty, tpl.maxStack - s.qty);\\n        s.qty += take; qty -= take;\\n        if (qty <= 0) return true;\\n      }\\n    }\\n    // Phase 2: empty slots for new stacks\\n    for (let i = 0; i < this.slots.length && qty > 0; i++) {\\n      if (!this.slots[i]) {\\n        const put = Math.min(qty, tpl.maxStack);\\n        this.slots[i] = { itemId, qty: put };\\n        qty -= put;\\n      }\\n    }\\n    return qty === 0; // false = some items didn't fit\\n  }\\n  remove(itemId, qty = 1) {\\n    let need = qty;\\n    // Reverse-iterate: remove from newest stacks first (LIFO)\\n    for (let i = this.slots.length - 1; i >= 0 && need > 0; i--) {\\n      const s = this.slots[i];\\n      if (s && s.itemId === itemId) {\\n        const take = Math.min(need, s.qty);\\n        s.qty -= take; need -= take;\\n        if (s.qty === 0) this.slots[i] = null;\\n      }\\n    }\\n    return need === 0;\\n  }\\n  // Stable sort: itemId then rarity then qty desc; nulls last\\n  sort() {\\n    this.slots.sort((a, b) => {\\n      if (!a && !b) return 0;\\n      if (!a) return 1; if (!b) return -1;\\n      if (a.itemId !== b.itemId) return a.itemId.localeCompare(b.itemId);\\n      return b.qty - a.qty;\\n    });\\n  }\\n}\\n// Usage: const inv = new Inventory(20); inv.add('arrow', 150); // auto-splits into 2 stacks",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "pros": [
            "Clear state — `slots[i] = {itemId, qty}` debuggable in DevTools, JSON-serializable for save",
            "Bounded memory — max slots cap prevents OOM from drop-spam exploit; 20 slots × 50B = 1KB",
            "Stack semantics match player mental model — 99 potions = 1 stack slot, not 99 slots",
            "Easy serialization — `JSON.stringify(slots)` round-trips through localStorage, no Map/Set gotchas",
            "Drop/swap semantics obvious — null slot = empty, swap = exchange pointers, partial-stack split",
            "UI flexibility — same data drives grid, list, hotbar, mini-map representations without change"
          ],
          "cons": [
            "Slot limit feels artificial — 20-slot bag + 100 herbs = frustrating without 'upgrade bag' mechanic",
            "Partial-stack logic verbose — 50 arrows into 2 stacks of 30+20 + empty slot = 3 branches to handle",
            "Drag-drop on touch unreliable — HTML5 DnD fires dragend on iOS inconsistently; need click-to-move fallback",
            "Object.freeze hurts perf in hot loops — copy-on-read vs reference trade-off; freeze templates only",
            "Sort/filter requires stable sort — Array.sort mutates + locale-aware; deep copy before sort",
            "Crafting dependencies — recipe needs 3x item X but inventory has 1 stack of 30 = ambiguous 'remove 3' vs 'split'"
          ],
          "gotchas": [
            "Always Object.freeze the item catalog — mutating ITEMS.potion.heal corrupts ALL inventories referencing it",
            "HTML5 DnD: dragstart requires event.dataTransfer.setData — iOS Safari fires twice, may need dedup",
            "Max stack per item type — gold stacks 999, swords stack 1, configurable per template (not hardcoded)",
            "Auto-stack on add — check existing stacks first before consuming empty slots; 99 herb add = no new slot",
            "Partial fill: qty=150, maxStack=99 → fills stack A (99), starts stack B (51), succeeds with 2 slots used",
            "Drop semantics: held item in cursor slot, not in inventory until drop event fires (atomic)",
            "Sort stability: compare by itemId first, rarity second, qty desc third — localeCompare for names",
            "Save serialization: array of slots smaller than Map — always array for storage, convert at runtime if needed"
          ],
          "anti_patterns": [
            "Mutate the global ITEMS catalog at runtime — all inventories reference same object, one bug spreads",
            "Use plain object as slot — `{itemId, qty}` lacks maxStack metadata, separate lookup fragile",
            "Trust HTML5 DnD without fallbacks — mobile web fails 20% of time, need click-to-move or touch handlers",
            "One slot per item, no stacking — UI wastes 20 slots on 99 herbs; player rage-quits",
            "Allow negative qty on subtract — silent overflow corrupts save file; clamp at 0 minimum"
          ],
          "prereqs": [
            "4.1"
          ],
          "tags": [
            "inventory",
            "slot",
            "stack",
            "rpg",
            "item",
            "ไอเทม",
            "ช่องเก็บของ"
          ],
          "compare_with": [
            "Tetris-style tetris inventory (fixed shape)",
            "Tetris/tetromino-bag (Tetris-like shape constraints)",
            "Weight-based (no slots, total weight cap)"
          ]
        },
        {
          "id": "4.7",
          "title": "Dialogue System (Branching)",
          "icon": "💬",
          "description": "JSON tree of nodes, conditions = function refs evaluated lazily, side effects on choice.",
          "when_to_use": "NPC dialogues, quest giver text, tutorial steps, branching story | เกมที่มี NPC คุยกับ player และเลือกตอบได้",
          "when_not_to_use": "Linear monologue, single cutscene, loading screen tips, system messages",
          "examples": "stronghold-3d, sims-lite, mars-colony, realm-of-heroes",
          "snippet": "const dlg={start:{speaker:'npc',text:'Hi {{name}}',choices:[{text:'Buy',next:'shop',cond:c=>c.gold>=5},{text:'Bye',next:'end'}]},shop:{speaker:'npc',text:'Potions 5g',choices:[{text:'Buy 1',next:'bought',effect:c=>{c.gold-=5;c.pots=(c.pots||0)+1}}]},end:{speaker:'npc',text:'Bye'}};class Run{constructor(t,bb){this.t=t;this.bb=bb;this.id='start';this.h=[]}get cur(){return this.t[this.id]}choose(i){const c=this.cur.choices[i];this.h.push(this.id);if(c.effect)c.effect(this.bb);this.id=c.next}back(){this.id=this.h.pop()||'start'}}",
          "snippet_full": "// Dialogue as JSON: writers edit, code consumes\\nconst dialogue = {\\n  start: {\\n    speaker: 'npc', text: 'Welcome, {{playerName}}.',\n    choices: [\\n      { text: 'Buy potions', next: 'shop', condition: c => c.gold >= 5 },\\n      { text: 'Tell me about dragons', next: 'dragon_lore' },\\n      { text: 'Goodbye', next: 'end' }\\n    ]\\n  },\\n  shop: {\\n    speaker: 'npc', text: 'Potions are 5 gold each.',\\n    choices: [\\n      { text: 'Buy one', next: 'bought', effect: c => { c.gold -= 5; c.potions = (c.potions||0) + 1; } },\\n      { text: 'Back', next: 'start' }\\n    ]\\n  },\\n  dragon_lore: {\\n    speaker: 'npc', text: 'They nest in the north mountains.',\\n    choices: [{ text: 'Thanks', next: 'start' }]\\n  },\\n  bought: { speaker: 'npc', text: 'Here you go!', choices: [{ text: 'Thanks', next: 'start' }] },\\n  end:    { speaker: 'npc', text: 'Farewell.' }\\n};\\nclass DialogueRunner {\\n  constructor(tree, blackboard) {\\n    this.tree = tree; this.bb = blackboard;\\n    this.currentId = 'start'; this.history = [];\\n  }\\n  get current() { return this.tree[this.currentId]; }\\n  // Filter choices by condition at RENDER time (lazy eval)\\n  get availableChoices() {\\n    return (this.current.choices || [])\\n      .map((c, i) => ({ c, i }))\\n      .filter(({ c }) => !c.condition || c.condition(this.bb));\\n  }\\n  choose(index) {\\n    const choice = this.current.choices[index];\\n    this.history.push(this.currentId);\\n    // Side effects fire on PICK, not on display\\n    if (choice.effect) choice.effect(this.bb);\\n    this.currentId = choice.next;\\n    return this.currentId !== 'end';\\n  }\\n  back() { return (this.currentId = this.history.pop()) || 'start'; }\\n  // XSS-safe interpolation: escape {{playerName}} lookup\\n  renderText(text) {\\n    return text.replace(/\\{\\{(\\w+(?:\\.\\w+)*)\\}\\}/g, (_, path) => {\\n      const v = path.split('.').reduce((o, k) => o?.[k], this.bb);\\n      const esc = String(v == null ? '' : v).replace(/[<>&]/g, c =>\\n        ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[c]);\\n      return esc;\\n    });\\n  }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "pros": [
            "Writers edit JSON — no engineer round-trip per line change; non-programmers ship content",
            "Localization trivial — `data[locale][nodeId].text` swap per language; 10x faster than string extraction",
            "Variable substitution — `{{playerName}}` resolves from blackboard; no template engine needed",
            "Conditions as function refs — `{condition: c => c.hasQuest('dragon')}` evaluated lazily at display time",
            "Save minimal — just `{currentId, history[]}` round-trips full conversation state in <100B",
            "Branching depth matches JSON nesting — designers see tree structure visually, no mental translation"
          ],
          "cons": [
            "State management — back button must pop history stack, or player stuck mid-conversation",
            "Variable interpolation risk — raw text injection = XSS if text from user; escape HTML in renderText",
            "Memory: deep JSON tree parses each conversation; 1000-node tree = 500KB; lazy-load by chapter",
            "Side effects (giveItem, startQuest) scattered in choices — hard to audit, hard to test in isolation",
            "No undo of destructive choice — chose 'kill NPC' without save = reload required; checkpoint system needed",
            "Localization drift — `start` updated in EN, missing in TH silently; lint script catches missing keys"
          ],
          "gotchas": [
            "Unique ID per node — auto-rename `node_001`, `node_002`; never trust author-provided IDs (typos)",
            "Save state = `{currentId, history: [n1, n2, n3]}` — minimal payload; reset history on conversation end",
            "Conditions evaluated LAZILY — `fn(blackboard)` runs at choice-display time, not parse time (fresh state)",
            "Speaker attribution: each node has `speaker: 'npc'|'narrator'|'player'` for bubble color/position",
            "Typewriter effect needs `await sleep(30)` per char — frame-budget on long lines (100 chars = 3s)",
            "Variable interpolation: `{{player.name}}` lookup; escape HTML to prevent XSS (`<` → `&lt;`)",
            "Endless loop detection: `currentId === lastChoice.nextId` cycle guard, force-progress after N",
            "Localization keys not nested in `text` — `node.text` is final string, `node.textKey` is i18n lookup key"
          ],
          "anti_patterns": [
            "Hardcode dialogue in JS strings — writers can't edit without redeploy; productivity killer",
            "Skip history stack — 'back' button impossible to implement, players rage-quit",
            "Evaluate conditions at JSON parse time — side effects fire BEFORE player sees choice",
            "One mega-tree for entire game — 10,000 nodes slow to parse, slow to grep, merge conflicts",
            "Mutate JSON tree at runtime — save file references by ID, but ID now means different text (silent bug)"
          ],
          "prereqs": [
            "4.1"
          ],
          "next_steps": [
            "4.8"
          ],
          "tags": [
            "dialogue",
            "npc",
            "branching",
            "json-tree",
            "localization",
            "บทสนทนา",
            "ไดอะล็อก"
          ],
          "compare_with": [
            "Yarn Spinner / Ink markup (designer-friendly syntax)",
            "YAML linear scripts (no branching)",
            "Visual novel engines (Ren'Py/Twine)"
          ]
        },
        {
          "id": "4.8",
          "title": "Quest System",
          "icon": "📜",
          "description": "Mission = objective + progress + reward. ONE central trigger function mutates all quest state.",
          "when_to_use": "RPG/tycoon/MMO with missions, daily challenges, tutorial steps, milestones | เกมที่ player ทำภารกิจเพื่อ reward",
          "when_not_to_use": "Linear tutorial with no completion tracking, story-only game with no side objectives, single-objective games",
          "examples": "stronghold-3d, sims-lite, mars-colony, realm-of-heroes",
          "snippet": "function updateQuestProgress(type,amount=1){for(const[id,q]of active)if(q.type===type){q.progress=Math.min(q.target,q.progress+amount);if(q.progress>=q.target&&!q.done)complete(id)}} updateQuestProgress('gather_wood',5);updateQuestProgress('kill_orc',1);",
          "snippet_full": "// Quest definition as data, code consumes\\nconst QUESTS = {\\n  wood_collector: {\\n    title: 'Gather Wood', type: 'gather', target: 'wood', count: 10,\\n    reward: { gold: 50, xp: 100 },\\n    prereqs: []\\n  },\\n  orc_slayer: {\\n    title: 'Slay Orcs', type: 'kill', target: 'orc', count: 5,\\n    reward: { gold: 100, xp: 200 },\\n    prereqs: ['wood_collector']  // must finish wood first\\n  },\\n  dragon_slayer: {\\n    title: 'Slay the Dragon', type: 'kill', target: 'dragon', count: 1,\\n    reward: { gold: 1000, xp: 5000, items: ['dragon_sword'] },\\n    prereqs: ['orc_slayer']\\n  }\\n};\\nclass QuestMgr {\\n  constructor() { this.active = new Map(); this.completed = new Set(); }\\n  // THE central trigger — ONLY this function mutates quest state\\n  update(type, amount = 1) {\\n    for (const [id, q] of this.active) {\\n      if (q.type !== type) continue;\\n      // Cap at target; prevents overflow on event spam\\n      q.progress = Math.min(q.count, (q.progress || 0) + amount);\\n      if (q.progress >= q.count && !q.done) this._complete(id, q);\\n    }\\n  }\\n  accept(id) {\\n    const q = QUESTS[id];\\n    if (!q) throw new Error('Unknown quest: ' + id);\\n    // Gate by prereqs; prevent accept of blocked quests\\n    if (q.prereqs.some(p => !this.completed.has(p))) return false;\\n    this.active.set(id, { ...q, progress: 0, done: false });\\n    return true;\\n  }\\n  _complete(id, q) {\\n    q.done = true;\\n    // IMMEDIATE reward — not deferred, not batched\\n    if (q.reward.gold)  player.gold += q.reward.gold;\\n    if (q.reward.xp)    player.xp   += q.reward.xp;\\n    if (q.reward.items) q.reward.items.forEach(i => player.inv.add(i));\\n    this.completed.add(id);\\n    this.active.delete(id);\\n    toast(`✅ Quest complete: ${q.title}`);\\n  }\\n}\\n// Usage: QuestMgr.update('kill_orc', 1); // called from combat system",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "pros": [
            "Engagement loop — short quests = dopamine hits = retention metric, proven across MMOs and mobile games",
            "Centralized trigger — ONE `updateQuestProgress(type, amount)` updates 50 quests, no scattered logic",
            "Reward immediacy — gold/exp given on `complete()` not on next scene load, players notice delay otherwise",
            "Composable objectives — `gather_wood` + `kill_orc` chain to `defeat_boss` via prereqs field",
            "UI auto-render — `activeQuests.map(q => renderHUD(q))` always in sync with state, no manual sync",
            "Analytics-friendly — `analytics.track('quest_complete', {id, time})` metrics out of box for tuning"
          ],
          "cons": [
            "State per quest — 100 active quests × 10 fields = memory footprint; compact schema needed",
            "Trigger coordination — `kill_orc` fires from combat, gathering from collection, must debounce duplicate fires",
            "Save/load serialization complexity — Map<id, Quest> needs toJSON/fromJSON, version migration on schema change",
            "Race conditions — two simultaneous triggers (pickup + quest complete) = double reward if not atomic",
            "Prerequisite chain explosion — quest A needs B needs C needs D = linear lock-in, frustrating if stuck",
            "Failure UX — player can't complete quest, no error message, just stuck; need progress hint UI"
          ],
          "gotchas": [
            "Central trigger: `updateQuestProgress(type, amount)` is the ONLY function that mutates quest state",
            "Reward MUST fire IMMEDIATE — don't batch until quest turn-in, players notice delay = bad UX",
            "Quest type enum: 'gather', 'kill', 'deliver', 'talk', 'reach' — single string per quest, dispatch in switch",
            "Progress capped at target — `Math.min(target, progress + amount)` prevents overflow on event spam",
            "Auto-complete when progress >= target — single check in updateQuestProgress, no race condition possible",
            "Quest chains: `prereqs: ['quest_id_1', 'quest_id_2']` blocks UI button until done; gate accept()",
            "Save format: Array.from(activeQuests), not Map — JSON-friendly; load: `new Map(loaded.map(q => [q.id, q]))`",
            "Side objectives (optional) — separate `bonus` field, doesn't block main quest completion (engagement boost)"
          ],
          "anti_patterns": [
            "Update quest state from 10 different places — race conditions, double-credit bugs, debug nightmare",
            "Reward on next scene load — player confused if gold doesn't appear; trust lost, churn up",
            "Linear quest chains with no alternative — 1 stuck quest = entire game blocked; offer 2-3 parallel paths",
            "No progress indicator — 'kill 5 orcs' with no counter = player forgets they have quest, abandons",
            "Hardcoded quest IDs in code — refactor breaks save files; use string const table exported from data"
          ],
          "prereqs": [
            "4.7"
          ],
          "next_steps": [
            "4.9"
          ],
          "tags": [
            "quest",
            "mission",
            "objective",
            "reward",
            "rpg",
            "เควส",
            "ภารกิจ"
          ],
          "compare_with": [
            "Achievement system (4.9 — longer-term, less structured)",
            "Daily challenges (timer-based subset)",
            "MMO quest trackers (more complex UI)"
          ]
        },
        {
          "id": "4.9",
          "title": "Achievement System",
          "icon": "🏆",
          "description": "Discrete accomplishments with idempotent unlock check. Categories for UI grouping. Event-triggered evaluation.",
          "when_to_use": "ทุกเกมที่ต้องการ replayability | Games with long-term progression, completionist goals, daily streaks",
          "when_not_to_use": "Tutorial signposts (use quests instead), one-shot narrative moments, server-validated competitive ranks",
          "examples": "noodle-shop, sims-lite, stronghold-3d, habit-streak",
          "snippet": "class AchMgr{constructor(s){this.s=s;s.achs=s.achs||[];this.q=[]}unlock(id){if(this.s.achs.includes(id))return;this.s.achs.push(id);this.q.push(id)}check(s){if(s.kills>=1)this.unlock('first_blood');if(s.gold>=1000)this.unlock('rich_1k');if(s.day_streak>=7)this.unlock('weekly_warrior')}render(){if(this.q.length===0)return;const n=this.q.shift();showToast(`🏆 ${n}`);setTimeout(()=>this.render(),3000)}}",
          "snippet_full": "// Achievement definitions as data, single check function\\nconst ACHIEVEMENTS = {\\n  first_blood:     { name: 'First Blood',    desc: 'Defeat your first enemy',  cat: 'combat',      icon: '⚔️' },\\n  rich_1k:         { name: 'Wealthy',        desc: 'Accumulate 1000 gold',     cat: 'economy',     icon: '💰' },\\n  wood_legend:     { name: 'Lumberjack',     desc: 'Gather 1000 wood',         cat: 'economy',     icon: '🪵' },\\n  explorer:        { name: 'Cartographer',   desc: 'Visit all map regions',    cat: 'exploration', icon: '🗺️' },\\n  social_butterfly:{ name: 'Friendly',       desc: 'Talk to 50 NPCs',          cat: 'social',      icon: '💬' },\\n  weekly_warrior:  { name: 'Streak Master',  desc: 'Play 7 days in a row',     cat: 'meta',        icon: '🔥' }\\n};\\nclass AchievementMgr {\\n  constructor(state) {\\n    this.state = state;\\n    this.state.achs = state.achs || [];\\n    this.queue = []; // notification queue\\n  }\\n  // THE critical line: idempotency guard\\n  unlock(id) {\\n    if (this.state.achs.includes(id)) return; // <-- the bug-avoider\\n    const ach = ACHIEVEMENTS[id];\\n    if (!ach) return;\\n    this.state.achs.push(id);\\n    this.queue.push(ach);\\n    if (navigator.onLine) this._syncToServer(id); // optional server-validated\\n  }\\n  // Batch check on game state changes — NOT per-frame\\n  check(state) {\\n    if (state.enemiesKilled >= 1)        this.unlock('first_blood');\\n    if (state.gold >= 1000)               this.unlock('rich_1k');\\n    if (state.woodGathered >= 1000)       this.unlock('wood_legend');\\n    if (state.npcsTalked >= 50)           this.unlock('social_butterfly');\\n    if (state.dayStreak >= 7)             this.unlock('weekly_warrior');\\n  }\\n  // Throttled notification display: 1 per 3s, not all at once\\n  renderToasts() {\\n    if (this.queue.length === 0) return;\\n    const next = this.queue.shift();\\n    showToast(`🏆 ${next.icon} ${next.name}: ${next.desc}`);\\n    setTimeout(() => this.renderToasts(), 3000);\\n  }\\n  _syncToServer(id) {\\n    // POST /api/achievements { userId, id, timestamp }\\n    // Server validates, returns updated global % for 'X% of players'\\n    fetch('/api/ach', { method: 'POST', body: JSON.stringify({ id }) });\\n  }\\n}\\n// Hook into game loop: only check on state change events, not every frame",
          "difficulty": "Beginner",
          "time_to_learn": "1 hour",
          "pros": [
            "Idempotent check — `if (state.achs.includes(id)) return` prevents double-unlock toast spam",
            "Long-term goals — 'Reach level 50' runs in background of normal play, no separate UI to manage",
            "Replayability hook — completionist goes for 100% on second playthrough, proven retention driver",
            "Trivial serialization — `state.achs = ['first_blood', 'rich_1k']` is 1 line of JSON round-trip",
            "Cheap to evaluate — check on event (kill, levelup), not per-frame; 100 achievements = <1ms total",
            "Social proof — 'X% of players have this' badge drives engagement, visible leaderboard psychology"
          ],
          "cons": [
            "Users ignore achievements — 70% never look at the screen, dev effort vs retention unclear",
            "Notification spam — unlocking 5 at once buries important feedback; queue + throttle needed",
            "Edge cases — offline progress should still unlock if criteria met; reconcile on first tick",
            "Cheating — localStorage editable, 'achievements' not authoritative; server validation for competitive",
            "Tracking the untrackable — 'be polite to NPCs' needs behavior classification, ML or heuristics",
            "ROI unclear — dev time vs retention lift often negative; instrument and measure before expanding"
          ],
          "gotchas": [
            "`if (state.achs.includes(id)) return` — idempotency is THE bug to avoid; toast fires 5x/sec otherwise",
            "Categories: 'combat', 'economy', 'social', 'exploration', 'meta' — UI grouping for 100+ entries",
            "Notification queue: enqueue, show 1 per 3s, not all at once; queue length cap (drop oldest)",
            "Server-validated achievements for competitive games — Steam, GOG, PSN each have own APIs, dup effort",
            "Date-based achievements — 'play 7 days in a row' needs streak counter, not just count; reset on miss",
            "Hidden achievements — `visible: false` for surprise reveals, surfaces only on unlock (engagement)",
            "Cross-platform sync — Steam achievements ≠ PSN achievements, write platform adapter layer",
            "Test achievement triggers with debug menu — manual playthrough 100x is too slow; CLI unlock-all for QA"
          ],
          "anti_patterns": [
            "No idempotency check — toast fires 5x per second, log flooded, player dismisses notification",
            "Award on frame tick — wastes CPU; award on event (kill/levelup/quest_complete), batch check on save",
            "Hardcoded English strings — no localization, Thai players see 'Kill 100 enemies' in wrong script",
            "100 achievements for trivial actions — 'walk 1 step' = meaningless padding; player tunes out",
            "Server trusts client achievement data — cheaters unlock everything in 30s; sign with HMAC"
          ],
          "prereqs": [
            "4.8"
          ],
          "tags": [
            "achievement",
            "trophy",
            "unlock",
            "idempotent",
            "reward",
            "ความสำเร็จ",
            "รางวัล"
          ],
          "compare_with": [
            "Quest system (4.8 — structured, has progress)",
            "Steam achievements (server-validated, dup platform work)",
            "Daily challenges (timer-based subset)"
          ]
        },
        {
          "id": "4.10",
          "title": "Save Migration",
          "icon": "🔄",
          "description": "Multi-version backward-compat with version field. Forward-only migrations. Default values for missing fields.",
          "when_to_use": "เกมที่ ship หลายเวอร์ชัน, mobile apps with updates, browser games with patches | Any app persisted across deploys",
          "when_not_to_use": "App with single version and never updates, server-only state with full DB migrations, ephemeral session data",
          "examples": "stronghold-3d, noodle-shop, sims-lite, flowboard",
          "snippet": "const MIGS={2:d=>({...d,achs:d.achs||[]}),3:d=>({...d,weather:d.weather??'cloudy'}),4:d=>{let n={...d};n.player={...n.player,loc:n.player.pos||{x:0,y:0}};delete n.player.pos;return n}};function load(){let raw=null;for(let v=CURRENT_VER;v>=1;v--){raw=localStorage.getItem(`app_v${v}`);if(raw)break}if(!raw)return fresh();let d=JSON.parse(raw);for(let v=(d._ver||1);v<CURRENT_VER;v++){const m=MIGS[v+1];if(m){d=m({...d});d._ver=v+1}}return d}",
          "snippet_full": "// Versioned migrations: each fn transforms save to NEXT version\\nconst CURRENT_VER = 5;\\nconst MIGRATIONS = {\\n  // v1 -> v2: achievements array added\\n  2: d => ({ ...d, achievements: d.achievements || [] }),\\n  // v2 -> v3: weather field added with default\\n  3: d => ({ ...d, weather: d.weather ?? 'cloudy' }),\\n  // v3 -> v4: quests array + activeQuest separate from achievements\\n  4: d => ({ ...d, quests: d.quests || [], activeQuest: d.activeQuest || null }),\\n  // v4 -> v5: player.position renamed to player.loc (refactor)\\n  5: d => {\\n    const next = { ...d };\\n    if (next.player) {\\n      next.player = { ...next.player, loc: next.player.position || { x: 0, y: 0 } };\\n      delete next.player.position; // remove old key, do NOT keep both\\n    }\\n    return next;\\n  }\\n};\\nclass SaveMgr {\\n  static save(state) {\\n    const payload = { _ver: CURRENT_VER, ...state, savedAt: Date.now() };\\n    localStorage.setItem('app_v' + CURRENT_VER, JSON.stringify(payload));\\n  }\\n  static load() {\\n    // Try newest key first, fall back to older versions\\n    let raw = null;\\n    for (let v = CURRENT_VER; v >= 1; v--) {\\n      raw = localStorage.getItem('app_v' + v);\\n      if (raw) break;\\n    }\\n    if (!raw) return this._fresh();\\n    let data;\\n    try { data = JSON.parse(raw); }\\n    catch (e) {\\n      console.error('Save corrupted, starting fresh', e);\\n      return this._fresh();\\n    }\\n    // Forward-only migration: apply each step IN ORDER from current to target\\n    const startVer = data._ver || 1;\\n    for (let v = startVer; v < CURRENT_VER; v++) {\\n      const mig = MIGRATIONS[v + 1];\\n      if (mig) {\\n        // Clone first to avoid mutating the parsed input\\n        data = mig({ ...data });\\n        data._ver = v + 1;\\n      }\\n    }\\n    return data;\\n  }\\n  static _fresh() {\\n    return { _ver: CURRENT_VER, player: { loc: { x: 0, y: 0 } }, achievements: [], quests: [], gold: 0 };\\n  }\\n  // CI test: load every version fixture, verify shape\\n  static _runMigrationTests() {\\n    const fixtures = { 1: { /* v1 shape */ }, 2: { /* v2 shape */ } };\\n    for (const v of Object.keys(fixtures)) {\\n      const result = SaveMgr._applyMigrations({ _ver: +v, ...fixtures[v] });\\n      assert(result._ver === CURRENT_VER);\\n      assert(result.player && result.player.loc);\\n    }\\n  }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "pros": [
            "User keeps progress across updates — biggest retention metric in mobile games (Angry Birds saved years)",
            "Forward + backward compat — old save loads in new build, new save loads in old build (with defaults)",
            "Version field is mandatory — every save records `_ver` integer, never trust absent (defensive)",
            "Migration is one-way per upgrade — v1→v2→v3, no skipped steps; idempotent if re-run",
            "Default values for missing fields — `data.quests ||= []` handles partial saves, never crashes",
            "Easy rollback — keep migration code in branches, revert = revert migration + restore old schema"
          ],
          "cons": [
            "Migration code per version — 5 versions × 100 fields = 500 lines of transform functions",
            "Bug amplification — bad migration corrupts ALL saves, not just new ones; CI fixtures MANDATORY",
            "Testing matrix — must test v1, v2, v3, v4, v5 loads in v6 build = combinatorial; automate",
            "Storage fragmentation — old format fields linger forever, can't GC; compression helps",
            "Non-deterministic migrations — Date.now() in migration = different output per run, save file unstable",
            "Schema vs data confusion — adding UI field ≠ save field; schema migration ≠ data migration, separate"
          ],
          "gotchas": [
            "Keep `_ver` field always — never trust `parseInt(undefined)` defaults, must default to 1 explicitly",
            "One-way migration: v1 → v2 (never v2 → v1) — idempotent if you re-run; chain migrations in order",
            "Forward compat: new code reads old save + applies migrations to current version; old code reads new = defaults",
            "Backward compat: old code reads new save via `data.foo ?? defaultValue` everywhere; defensive reads",
            "Default values via `??` not `||` — `gold=0` and `name=''` are valid, `||` substitutes them wrong",
            "Test all version loads in CI — automated fixtures v1.json...v5.json + load test on each PR",
            "Never mutate the input — clone first; shared object reference = silent corruption of localStorage",
            "Migration failures should NOT crash — log + fallback to fresh save + report bug via analytics"
          ],
          "anti_patterns": [
            "Skip versioning — one day you ship 'remove field X', every existing save crashes on load",
            "Mutate input data during migration — original localStorage value corrupted, cannot retry",
            "Use `||` instead of `??` for defaults — `gold=0` becomes `gold=defaultValue`, save data lost",
            "Migration that depends on time/random — output differs per load, save file unstable across reloads",
            "No testing for old version loads — ship update, all players with old save crash, 1-star reviews"
          ],
          "prereqs": [
            "1.4"
          ],
          "tags": [
            "save",
            "migration",
            "versioning",
            "localstorage",
            "persistence",
            "ซave",
            "เวอร์ชัน"
          ],
          "compare_with": [
            "IndexedDB (more storage, async, complex API)",
            "Server-side DB migrations (Liquibase/Flyway)",
            "Backward-compat only (read latest, ignore older)"
          ]
        }
      ]
    },
    {
      "id": 5,
      "name": "Business Apps",
      "icon": "💼",
      "tagline": "Productivity + commerce",
      "sub_phases": [
        {
          "id": "5.1",
          "title": "Kanban Board",
          "icon": "📋",
          "description": "Lists of cards with drag-drop, multi-board, WIP limits, filters. FlowBoard คือ reference implementation ใน CodeHub.",
          "when_to_use": "Project mgmt (sprint board), GTD personal workflow, content pipeline (blog post stages), sales pipeline lite, learning roadmap (flowboard base)",
          "when_not_to_use": "Real-time multi-user (>5 concurrent cursors ต้อง CRDT/Yjs), gantt-heavy project (need timeline), dependency graph (use dedicated PM tool)",
          "examples": "flowboard, habit-streak, knowledge-garden, mavis-home",
          "snippet": "document.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',e.target.dataset.id)); document.addEventListener('drop',e=>{const id=e.dataTransfer.getData('text/plain');const listId=e.target.closest('.list').dataset.listId;state.moveCard(id,listId);render();});",
          "snippet_full": "// FlowBoard-style: Sortable.js (mobile-friendly) + optimistic state\\nimport Sortable from 'sortablejs';\\nconst board = {lists: [{id:'todo',cards:[]},{id:'doing',cards:[]},{id:'done',cards:[]}]};\\nfunction render(){\\n  board.lists.forEach(list => {\\n    const el = document.querySelector(`[data-list-id=\"${list.id}\"] .cards`);\\n    el.innerHTML = list.cards.map(c =>\\n      `<div class=\"card\" data-id=\"${c.id}\">${c.title}</div>`).join('');\\n  });\\n}\\nboard.lists.forEach(list => {\\n  const el = document.querySelector(`[data-list-id=\"${list.id}\"] .cards`);\\n  Sortable.create(el, {\\n    group: 'kanban',\\n    animation: 180,\\n    onEnd: (e) => {\\n      const {id} = e.item.dataset;\\n      const fromList = board.lists.find(l => l.id === e.from.dataset.listId);\\n      const toList = board.lists.find(l => l.id === e.to.dataset.listId);\\n      const card = fromList.cards.splice(e.oldIndex, 1)[0];\\n      toList.cards.splice(e.newIndex, 0, card);\\n      localStorage.setItem('board', JSON.stringify(board));\\n    }\\n  });\\\n});\\nrender();",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://github.com/SortableJS/Sortable",
          "pros": [
            "Visual columns = spatial memory (ตาเห็น state ทันที ไม่ต้อง filter เหมือน table)",
            "Drag-drop เป็น natural mental model (physical sticky notes) = user เรียนรู้ 0 doc",
            "localStorage + JSON export = offline-first, ไม่ต้อง server, GDPR-safe โดย default",
            "WIP limits per column = บังคับให้ team focus (ตามหลัก Kanban ของ Toyota)",
            "FlowBoard architecture = DOM diff เฉพาะการ์ดที่ย้าย (move 200 cards = 1 repaint ไม่ใช่ 200)",
            "Tag/label บนการ์ด = filter SQL-like (multi-tag AND/OR) โดยไม่ต้อง query engine"
          ],
          "cons": [
            "HTML5 Drag-and-Drop API เก่า (2009 spec) = ไม่มี touch support, ghost image customization จำกัด",
            "Sortable.js (~40KB) ตอบโจทย์ mobile แต่ add bundle 1 lib = ต้องชั่งน้ำหนัก",
            "Real-time sync 5+ user = conflict (2 คนลากการ์ดเดียวกัน) ต้อง OT/CRDT layer",
            "Board > 1000 cards = DOM heavy, scroll lag (ใช้ virtual list แทน แต่ DnD กับ virtual scroll ขัดกัน)",
            "Save state ใน localStorage 5-10MB limit = board ใหญ่พร้อม description ยาว quota error",
            "Multi-board ใน URL hash (board#id) = SEO ไม่ได้, share link ต้อง snapshot state"
          ],
          "gotchas": [
            "iOS Safari: HTML5 DnD touch broken ใช้ Sortable.js หรือ pointer events (touchstart + touchmove)",
            "dragstart setData('text/plain', id) เพื่อส่ง id ของการ์ด มิงั้น drop event ไม่รู้ว่าลากอะไร",
            "drop target = column ไม่ใช่การ์ดอื่น — ใช้ closest('.list') หา column id",
            "Animation: transition: transform 200ms บนการ์ดที่ย้าย ไม่ใช่ 200ms ทั้งหมด",
            "Optimistic UI: อัปเดต state ก่อน save แล้ว rollback ถ้า server fail (UX feel snappy)",
            "Undo: เก็บ ops stack {type:'move',cardId,from,to} ไม่ใช่ snapshot ทั้ง board (1KB vs 500KB)",
            "Mobile drag: dragover ต้อง preventDefault() มิงั้น drop ไม่ fire (Chrome quirk)",
            "localStorage quota: ใช้ IndexedDB เมื่อ board > 2MB (description ยาว + attachments)"
          ],
          "anti_patterns": [
            "Re-render ทั้ง board ทุกครั้งที่ย้ายการ์ด — ใช้ DOM diff (Immer + key-based render) ลด reflow 100x",
            "เก็บ state ใน DOM data-attribute — DOM = render layer, state = JS object เท่านั้น",
            "Bind dragend แทน drop เพื่อบันทึก — ถ้า drop นอก valid target state จะ corrupt",
            "ใช้ <iframe> แยกต่อ board เพื่อ isolate CSS — overkill, ใช้ Shadow DOM หรือ scope CSS",
            "Save ทันทีทุก mousemove (debounce 0) — ใช้ throttle 500ms หรือ save ตอน drop เสร็จ"
          ],
          "prereqs": [
            "1.3",
            "1.4"
          ],
          "next_steps": [
            "5.3",
            "5.7"
          ],
          "tags": [
            "kanban",
            "drag-drop",
            "บอร์ด",
            "task",
            "flowboard",
            "wip",
            "sortable"
          ],
          "compare_with": [
            "Trello (closed, paid)",
            "Notion (heavy, multi-tool)",
            "Jira (overkill, dev-focused)"
          ]
        },
        {
          "id": "5.2",
          "title": "Calendar (ICS Export)",
          "icon": "📅",
          "description": "Month/week view + iCal (.ics) export + RFC 5545 recurrence. Use UTC storage + local display ตามหลัก iCalendar.",
          "when_to_use": "Schedule view, booking time slots, content calendar, lesson planner, event RSVP, medbook/flowboard ใช้พื้นฐานนี้",
          "when_not_to_use": "Resource scheduling (rooms/equipment ต้อง conflict detection), team availability (round-robin), Gantt/dependency (use MS Project, ClickUp)",
          "examples": "flowboard, medbook, dinebook, cospace, fitbook",
          "snippet": "function exportICS(events){const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//CodeHub//EN']; events.forEach(e=>{lines.push('BEGIN:VEVENT',`UID:${e.id}@codehub.app`,`DTSTAMP:${formatICS(Date.now())}`,`DTSTART:${formatICS(e.start)}`,`DTEND:${formatICS(e.end)}`,`SUMMARY:${esc(e.title)}`,'END:VEVENT')}); return [...lines,'END:VCALENDAR'].join('\\r\\n')}",
          "snippet_full": "// FlowBoard/medbook style: ICS export with RFC 5545 escape + UTC\\nfunction esc(t){return String(t).replace(/[\\\\,;]/g, c=>'\\\\'+c).replace(/\\n/g,'\\\\n');}\\nfunction fmtICS(ts){return new Date(ts).toISOString().replace(/[-:]/g,'').replace(/\\.\\d{3}/,'');}\\nfunction exportICS(events){\\n  const lines = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//CodeHub//FlowBoard//EN','CALSCALE:GREGORIAN'];\\n  for (const e of events) {\\n    lines.push('BEGIN:VEVENT',\\n      `UID:${e.id}@flowboard.codehub.app`,\\n      `DTSTAMP:${fmtICS(Date.now())}`,\\n      `DTSTART:${fmtICS(e.start)}`,\\n      `DTEND:${fmtICS(e.end)}`,\\n      `SUMMARY:${esc(e.title)}`,\\n      `DESCRIPTION:${esc(e.desc||'')}`,\\n      e.rrule ? `RRULE:${e.rrule}` : '',\\n      'END:VEVENT');\\n  }\\n  lines.push('END:VCALENDAR');\\n  return lines.filter(Boolean).join('\\r\\n');\\n}\\nconst blob = new Blob([exportICS(myEvents)], {type:'text/calendar;charset=utf-8'});\\nwindow.location.href = URL.createObjectURL(blob);",
          "difficulty": "Intermediate",
          "time_to_learn": "1 day",
          "docs_url": "https://datatracker.ietf.org/doc/html/rfc5545",
          "pros": [
            "iCal .ics = text file 50-200 lines เปิดได้ใน Apple Calendar, Google Calendar, Outlook (universal)",
            "Month grid view = scan 30+ วันใน 1 หน้าจอ (better than list view เมื่อ date-dense)",
            "RFC 5545 RRULE string = 'FREQ=WEEKLY;BYDAY=MO,WE,FR' = recurrence 1 บรรทัด (no library)",
            "UTC storage + local display = หลีกเลี่ยง DST bug ที่พบบ่อยที่สุดใน calendar app",
            "UID field per event = email server ใช้ update/delete specific event โดยไม่ซ้ำ",
            "WebCal subscription (webcal://) = subscribe 1 ครั้ง, calendar auto-update ทุกครั้งที่ server เปลี่ยน"
          ],
          "cons": [
            "RFC 5545 recurrence 50+ rules (RRULE exceptions, RDATE, EXDATE) — implement เองเจ็บปวด",
            "iOS WKWebView parse .ics ไม่ได้ (ต้อง download → open in Calendar)",
            "DST: 'America/New_York' เปลี่ยนวัน — ใช้ 'TZID' หรือ UTC + local convert (ซับซ้อน)",
            "Calendar app แต่ละเจ้า render .ics ต่างกัน (Outlook มี escape character ต่างจาก Google)",
            "All-day event = DTSTART;VALUE=DATE ไม่มีเวลา แต่ถ้าใส่ 'T000000' บาง app แสดงเป็น 24-hour event",
            "VEVENT ไม่มี attendee RSVP built-in (ต้องใช้ ATTENDEE + iTIP protocol — heavy)"
          ],
          "gotchas": [
            "DTSTART ต้องเป็น UTC 'YYYYMMDDTHHMMSSZ' แล้ว client app convert local — ห้ามเก็บ local time",
            "iCal line fold: บรรทัดยาว 75 octets ต้อง fold ด้วย CRLF + space (RFC 5545 section 3.1)",
            "Text escape: comma, semicolon, backslash ใน SUMMARY/DESCRIPTION ต้อง escape (\\, \\; \\\\ )",
            "UID: unique per event — ใช้ `${event.id}@yourdomain.com` ไม่ใช่ random เพราะ update ต้อง match UID",
            "Recurrence ID (RECURRENCE-ID) = reschedule 1 instance โดยไม่ทำลาย RRULE ทั้งชุด",
            "DTSTAMP = เวลาที่สร้างไฟล์ (not event time) — ใช้ track version ตอน subscribe update",
            "iOS 17+: .ics จาก webcal:// จะ open Calendar โดยตรง ไม่ต้อง download ก่อน",
            "Thai Buddhist year = +543 (พ.ศ. 2567 = ค.ศ. 2024) — convert ก่อน serialize ไม่งั้น import ผิดปี"
          ],
          "anti_patterns": [
            "เก็บ local time ใน database — DST + timezone change = event shift ±1 ชม. ทุกปี, ใช้ UTC + IANA tz string",
            "Parse RRULE เอง (regex) — ใช้ rrule.js (~10KB) ที่ RFC-compliant, edge case 50+ rules",
            "สร้าง .ics ด้วย JSON template แล้ว stringify — escape character ผิด, ใช้ library ics (npm) ดีกว่า",
            "Embed HTML ใน DESCRIPTION แบบ inline — ใช้ X-ALT-DESC property (RFC 9265) แทน",
            "Save ไฟล์ .ics แล้ว expect browser 'add to calendar' button เด้ง — ต้อง data:text/calendar URL หรือ webcal:// scheme"
          ],
          "prereqs": [
            "1.3"
          ],
          "next_steps": [
            "5.4",
            "5.7"
          ],
          "tags": [
            "calendar",
            "ics",
            "icalendar",
            "ปฏิทิน",
            "rfc5545",
            "rrule",
            "utc",
            "recurrence"
          ],
          "compare_with": [
            "FullCalendar (heavy, ~200KB)",
            "react-big-calendar (React-only)",
            "Luxon (date math, no UI)"
          ]
        },
        {
          "id": "5.3",
          "title": "CRM (Pipeline)",
          "icon": "🤝",
          "description": "Contacts + deals + activities + stages + weighted pipeline value. People-crm บน CodeHub ใช้พื้นฐานนี้",
          "when_to_use": "Sales pipeline, freelancer client tracking, real-estate lead mgmt, partnership outreach, B2B SaaS early-stage (< 1000 contacts)",
          "when_not_to_use": "Enterprise CRM (>50 seats, need territory mgmt), marketing automation (HubSpot territory), helpdesk + ticket routing (Freshdesk, Zendesk)",
          "examples": "people-crm, flowboard, wallet, mood-garden",
          "snippet": "const STAGES=[{id:'lead',prob:.05},{id:'qualified',prob:.2},{id:'proposal',prob:.5},{id:'negotiation',prob:.7},{id:'won',prob:1}]; function pipelineValue(deals){return deals.reduce((s,d)=>s+d.amount*(STAGES.find(s=>s.id===d.stage)?.prob||0),0)}",
          "snippet_full": "// People-crm style: weighted pipeline + activity timeline + encrypted at rest\\nconst STAGES = [{id:'lead',label:'Lead',prob:.05},{id:'qualified',label:'Qualified',prob:.2},{id:'proposal',label:'Proposal',prob:.5},{id:'negotiation',label:'Negotiation',prob:.7},{id:'won',label:'Won',prob:1},{id:'lost',label:'Lost',prob:0}];\\nconst crm = {contacts:[], deals:[], activities:[]};\\nfunction pipelineValue(){return crm.deals.filter(d=>d.stage!=='lost'&&d.stage!=='won').reduce((sum,d)=>sum+(d.amount*STAGES.find(s=>s.id===d.stage).prob),0);}\\nfunction wonValue(){return crm.deals.filter(d=>d.stage==='won').reduce((s,d)=>s+d.amount,0);}\\nfunction addActivity(contactId, type, note){\\n  crm.activities.push({id:crypto.randomUUID(),contactId,type,note,ts:Date.now()});\\n}\\nfunction contactTimeline(id){\\n  return crm.activities\\n    .filter(a=>a.contactId===id)\\n    .sort((a,b)=>b.ts-a.ts);\\n}\\n// Encrypt at rest demo (production: PBKDF2 100k iter + AES-GCM)\\nasync function encrypt(data, key){const iv=crypto.getRandomValues(new Uint8Array(12));const buf=new TextEncoder().encode(JSON.stringify(data));const cipher=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,buf);return {iv,cipher};}",
          "difficulty": "Intermediate",
          "time_to_learn": "2 days",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto",
          "pros": [
            "Pipeline stages = probability-weighted forecast (Lead 5% → Won 100%) = predict revenue ง่ายขึ้น",
            "Single customer view (contact + deals + activities) = no context switching between tools",
            "localStorage / IndexedDB = PII never leaves device (GDPR Art. 25 'data minimization by default')",
            "Activity timeline = chronological log (email/call/meeting) ให้ context ก่อน follow-up",
            "People-crm pattern: 1 contact ↔ N deals ↔ N activities = normalized schema, query ง่าย",
            "Tag system (VIP, decision-maker, churned) = segmentation โดยไม่ต้องสร้าง custom field"
          ],
          "cons": [
            "PII = sensitive (email/phone/address) เก็บใน localStorage = XSS ขโมยได้ทั้งก้อน, encrypt at rest",
            "GDPR/CCPA: 'right to be forgotten' = ต้องลบ contact + deals + activities ทั้งหมด 1-shot (cascade delete)",
            "Email integration (Gmail/Outlook OAuth) = token refresh + scope approval + rate limit 250/day/user",
            "Bulk import 1000+ contacts = duplicate detection fuzzy match (Levenshtein) ไม่งั้น DB ซ้ำซ้อน",
            "Lead scoring manual rules ขยายไม่ได้ — 5+ signal ต้อง decision tree หรือ ML model",
            "Multi-user 5+ = row-level lock conflict (2 คนแก้ contact เดียวกัน) ต้อง version field"
          ],
          "gotchas": [
            "Encrypt at rest: crypto.subtle.encrypt AES-GCM ด้วย PBKDF2-derived key จาก user passphrase (ไม่ใช่ password)",
            "PII field mask: แสดง 'som***@gm***.com' ใน list view, full value ใน detail modal (UI pattern)",
            "Cascade delete: ลบ contact → confirm modal + delete deals + activities (3 queries ใน transaction)",
            "Import CSV: detect duplicate ด้วย email (lowercase + trim) ก่อน insert, แสดง 'merge?' modal",
            "Activity type enum: 'call', 'email', 'meeting', 'note' — enum ไม่ใช่ free-text เพื่อ filter ทำงาน",
            "Deal currency: JPY ไม่มี decimal, KWD 3 decimals — ใช้ BigInt cents แทน float (avoid 0.1+0.2 bug)",
            "iOS Safari: IndexedDB persist ต้อง navigator.storage.persist() ไม่งั้น iOS ลบเมื่อ storage pressure",
            "Search: client-side < 10k contacts = Fuse.js fuzzy, > 10k ต้อง server (Algolia/Meilisearch)"
          ],
          "anti_patterns": [
            "เก็บ password ใน localStorage plaintext — ใช้ Web Crypto API + PBKDF2 100k+ iterations",
            "Allow export ทุก field เป็น CSV โดย default — PII leak risk, opt-in + audit log",
            "ใช้ <table> แสดง 10k rows — virtualize (react-window) หรือ paginate 50/page + filter",
            "Activity log = free-text textarea — enum + structured fields (outcome/duration) เพื่อ analytics",
            "Build custom 'tags system' เป็น many-to-many เต็มรูปแบบ — start ใช้ comma-separated string แล้ว migrate ทีหลัง"
          ],
          "prereqs": [
            "1.3",
            "1.4"
          ],
          "next_steps": [
            "5.4",
            "5.7"
          ],
          "tags": [
            "crm",
            "pipeline",
            "sales",
            "lead",
            "contact",
            "gdpr",
            "deal",
            "people-crm"
          ],
          "compare_with": [
            "HubSpot (heavy, paid)",
            "Notion CRM (general-purpose)",
            "Twenty (open source, server needed)"
          ]
        },
        {
          "id": "5.4",
          "title": "Booking System",
          "icon": "📆",
          "description": "Slot picker + confirmation + 24h reminder + no-show handling. CodeHub มี medbook/dinebook/cospace/fitbook เป็น reference 4 ตัว",
          "when_to_use": "Restaurant (dinebook), clinic (medbook), co-working (cospace), gym (fitbook), spa (glowbook), tutor, salon",
          "when_not_to_use": "Multi-resource (room + therapist + equipment — conflict graph), 1000+ concurrent bookings (need queue + DB), recurring subscription booking (use Stripe Subscriptions)",
          "examples": "medbook, dinebook, cospace, fitbook, glowbook, venuehub, studiobook",
          "snippet": "const SLOTS=['09:00','10:00','11:00','12:00','13:00','14:00','15:00','16:00']; function book(slot,name,dateISO){if(bookings.find(b=>b.slot===slot&&b.date===dateISO))return 'taken'; bookings.push({slot,name,date:dateISO,ts:Date.now()}); return 'ok'}",
          "snippet_full": "// medbook/cospace style: grid slot picker + UTC + double-book check + push reminder\\nconst SLOTS = ['09:00','10:00','11:00','12:00','14:00','15:00','16:00','17:00'];\\nconst DURATION_MIN = 60;\\nfunction isSlotFree(dateISO, slot) {\\n  const start = new Date(`${dateISO}T${slot}:00+07:00`).getTime();\\n  return !bookings.some(b =>\\n    b.date === dateISO && b.slot === slot\\n  );\\n}\\nasync function book({dateISO, slot, name, email}) {\\n  if (!isSlotFree(dateISO, slot)) return {ok:false, err:'SLOT_TAKEN'};\\n  const startTs = new Date(`${dateISO}T${slot}:00+07:00`).getTime();\\n  const booking = {id: crypto.randomUUID(), date:dateISO, slot, name, email, startTs, createdAt: Date.now()};\\n  bookings.push(booking);\\n  // Schedule reminders (server-side cron in real app)\\n  schedulePush(email, startTs - 86400000, `${name}, นัดพรุ่งนี้ ${slot}`);\\n  schedulePush(email, startTs - 3600000,  `${name}, นัดอีก 1 ชม.`);\\n  return {ok:true, booking};\\n}\\n// Render 7-day grid\\nfor (let d = 0; d < 7; d++) {\\n  const date = new Date(Date.now() + d*86400000).toISOString().slice(0,10);\\n  for (const slot of SLOTS) {\\n    if (isSlotFree(date, slot)) renderSlotButton(date, slot);\\n  }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Push_API",
          "pros": [
            "Grid view 7 days × 8 slots = scan 56 options ใน 1 หน้าจอ (faster than calendar picker)",
            "24h + 1h reminder (2-stage) = no-show rate ลด 35-50% (Hospitality industry data)",
            "UTC storage + user TZ display = same booking shows 09:00 BKK ในไทย, 02:00 UTC ใน US",
            "Conflict detection: server-side double-book check (SELECT FOR UPDATE) ก่อน INSERT",
            "Web Push reminder = user ไม่ต้อง install app, แค่ allow notification ครั้งเดียว",
            "Slot duration adaptive: 15/30/60 min ตาม service type (massage 60, lab 15, consult 30)"
          ],
          "cons": [
            "No-show 5-15% baseline (industry avg) — ต้อง prepay/deposit เพื่อลด, แต่ flow ซับซ้อนขึ้น",
            "Time zone = ปวดหัวตลอด: server UTC, client local, DST changes ±1h, user travel across tz",
            "Concurrency: 2 user จอง slot เดียวกัน = race condition, ต้อง DB lock หรือ optimistic versioning",
            "Cancellation policy: refund logic (full 24h+ before, 50% 4-24h, 0% < 4h) = business rule ต่างกันทุกที่",
            "Resource utilization: idle slot = lost revenue, dynamic pricing (off-peak discount) ต้อง ML/heuristic",
            "Recurring booking (every Monday 9am × 12 weeks) = series + exceptions + skip-holiday logic"
          ],
          "gotchas": [
            "Web Push: ต้อง service worker + VAPID key + user gesture ก่อน subscribe, iOS 16.4+ only",
            "Reminder timing: Date.now() + leadTime เป็น wall clock — server time skew ทำให้ notification เพี้ยน",
            "Slot generation: query existing bookings → filter out taken → return free slots (O(n) per request)",
            "Cross-day booking: spa 8pm จบ 10pm = next day, render edge case บน month view",
            "Capacity > 1: yoga class 20 seats, 18 booked → 2 free, decrement atomic ไม่งั้น overbook",
            "Past slot disable: เปรียบเทียบ slot < now() ก่อน render, แต่ 'today 23:00' ต้อง timezone-aware",
            "Time picker 24h vs 12h: locale 'th-TH' = 24h, 'en-US' = 12h AM/PM, ใช้ Intl.DateTimeFormat",
            "Email confirm: SPF/DKIM/DMARC record ของ domain = inbox ไม่ใช่ spam 100%, ตรวจก่อน go-live"
          ],
          "anti_patterns": [
            "เก็บ booking ใน localStorage — 2 device = 2 state, sync conflict, ใช้ server (Postgres/Supabase)",
            "Confirm booking ทันทีหลังกด submit โดยไม่ conflict-check — double-book bug บ่อยที่สุดใน booking system",
            "Reminder setTimeout 1 ชม. ก่อน — ถ้า tab close = reminder หาย, ใช้ server-side cron + push",
            "ใช้ <input type='datetime-local'> เป็น slot picker — UX แย่, สร้าง grid เอง + custom click handler",
            "Assume timezone จาก IP — VPN/CDN ผิด, ใช้ Intl.DateTimeFormat().resolvedOptions().timeZone จาก browser"
          ],
          "prereqs": [
            "5.2",
            "1.7"
          ],
          "next_steps": [
            "5.3",
            "5.5",
            "5.6"
          ],
          "tags": [
            "booking",
            "appointment",
            "นัดหมาย",
            "slot",
            "reminder",
            "medbook",
            "dinebook",
            "timezone"
          ],
          "compare_with": [
            "Cal.com (open source, heavy)",
            "Calendly (paid, vendor lock)",
            "Spine (medical only)"
          ]
        },
        {
          "id": "5.5",
          "title": "POS (Point of Sale)",
          "icon": "💳",
          "description": "Cart + barcode scan + tax/VAT per region + receipt print/PDF + cash/card prompt. Noodle-shop + pos-grocery เป็น reference บน CodeHub",
          "when_to_use": "Restaurant table-side order, retail checkout, kiosk self-order, food truck, pop-up store, noodle-shop / pos-grocery เป็น 2 ตัวอย่างจริง",
          "when_not_to_use": "E-commerce online (need shipping/tax/shipping calc), wholesale B2B (PO + net terms), high-volume supermarket (need scale + barcode hardware spec)",
          "examples": "noodle-shop, pos-grocery, flowboard",
          "snippet": "class POS{constructor(){this.cart=[]}add(item,qty=1){const e=this.cart.find(c=>c.id===item.id);if(e)e.qty+=qty;else this.cart.push({...item,qty})} total(taxRate=0.07){const sub=this.cart.reduce((s,c)=>s+c.price*c.qty,0);return{sub,tax:sub*taxRate,total:sub*(1+taxRate)}} pay(method){const t=this.total();saveReceipt({ts:Date.now(),items:[...this.cart],...t,method});this.cart=[]}}",
          "snippet_full": "// noodle-shop / pos-grocery style: cart + Thai VAT + receipt\\nclass POS {\\n  constructor(taxRate = 0.07) { this.cart = []; this.taxRate = taxRate; }\\n  add(item, qty = 1) {\\n    const ex = this.cart.find(c => c.id === item.id);\\n    if (ex) ex.qty += qty;\\n    else this.cart.push({...item, qty});\\n    this.persist();\\n  }\\n  total() {\\n    const sub = this.cart.reduce((s, c) => s + c.price * c.qty, 0);\\n    const tax = Math.round(sub * this.taxRate * 100) / 100;\\n    return { sub, tax, total: Math.round((sub + tax) * 100) / 100 };\\n  }\\n  persist() { localStorage.setItem('cart', JSON.stringify(this.cart)); }\\n  async pay(method = 'cash', tendered = 0) {\\n    const t = this.total();\\n    const order = {\\n      id: crypto.randomUUID(), ts: Date.now(),\\n      items: [...this.cart], ...t, method, tendered,\\n      change: method === 'cash' ? tendered - t.total : 0\\n    };\\n    await db.orders.add(order);\\n    this.cart = []; this.persist();\\n    return order;\\n  }\\n}\\nconst pos = new POS(0.07);\\npos.add({id:'n1', name:'ก๋วยเตี๋ยว', price:50});\\nconst receipt = await pos.pay('cash', 100);\\nconsole.log(receipt.change); // 50 - 3.5 (tax) = 46.50",
          "difficulty": "Intermediate",
          "time_to_learn": "6 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Web_USB_API",
          "pros": [
            "Cart เก็บ localStorage = tab crash ไม่เสีย order (recovery 1-click restore)",
            "Barcode input via <input> + 'rapid mode' = 60-80 items/min (faster than touch screen picker)",
            "Tax per region: TH 7% VAT, US state tax (5-10%), EU 19% MwSt = lookup table ไม่ใช่ hardcode",
            "Receipt 2 output: thermal printer (ESC/POS 58/80mm) + PDF email backup = redundancy",
            "Offline-first = ต่อเน็ตหลุดก็ขายได้, sync queue เมื่อกลับมา online (Supabase/Firebase)",
            "Keyboard shortcut: F1=search, F2=pay-cash, F3=pay-card, F12=close-shift = cashier speed +40%"
          ],
          "cons": [
            "Tax calculation ผิด = legal liability (audit) — เก็บ rate + rounding rule + tax ID ต่อ transaction",
            "Hardware integration: barcode scanner = HID keyboard input (ต้อง focus <input>), receipt printer = WebUSB/Serial",
            "PCI DSS scope: รับบัตรเครดิต = tokenize ผ่าน Stripe/Omise (ห้ามเก็บ PAN ใน localStorage เด็ดขาด)",
            "Refund/exchange: original transaction id lookup + negative line items + restock (inventory sync)",
            "Shift close: cash count vs system total = ±฿5 acceptable, ±฿100 = investigation (audit trail)",
            "Offline sync conflict: 2 cashier same product sale while offline = negative stock, alert on sync"
          ],
          "gotchas": [
            "iOS 17+: WebUSB block ใน Safari (เฉพาะ Chrome Android/Desktop) — fallback Bluetooth ESC/POS printer",
            "Tax rounding: TH 7% on 99.50 = 6.965 → display 6.97 vs round-down 6.96 (POS choice), เลือก 1 วิธีทั้งระบบ",
            "Receipt font: thermal printer 58mm = 32 chars/line, 80mm = 42 chars/line, word-wrap ตามนี้",
            "Cash payment change: amount - sum(items) แสดง realtime, ป้องกัน over-tender typo",
            "Card payment flow: tap → wait → print slip → 'approved?' confirm → receipt (timeout 30s = cancel)",
            "Tip screen: 15/18/20/25% preset + custom, หลัง payment success, ก่อน receipt (US/UK common)",
            "Discount: line-level (item 10% off) vs cart-level (cart 100฿ off) vs BOGO — apply ตามลำดับ",
            "Audit log: every cart add/remove/price change เก็บ + user id + ts (7 years retention ในบางประเทศ)"
          ],
          "anti_patterns": [
            "เก็บ credit card PAN ใน localStorage — PCI DSS violation, fine $5k-100k/mo, ใช้ Stripe Element/Omise tokenize",
            "Float money = Number — 0.1 + 0.2 = 0.30000000000000004 bug, ใช้ integer cents (satang) หรือ Dinero.js",
            "Print receipt แล้วลบ cart ทันที — printer fail = no receipt, await print ack แล้วค่อย clear",
            "Re-use same order id ใน refund — generate new id + 'originalRef: xyz' link, เก็บ lineage ตลอด",
            "Use <form> submit reload page เพื่อ confirm payment — SPA + optimistic UI + rollback on error"
          ],
          "prereqs": [
            "5.6",
            "5.9"
          ],
          "next_steps": [
            "5.6",
            "5.9"
          ],
          "tags": [
            "pos",
            "cart",
            "ขาย",
            "vat",
            "receipt",
            "tax",
            "noodle-shop",
            "pos-grocery",
            "checkout"
          ],
          "compare_with": [
            "Square (closed, paid)",
            "Stripe Terminal (cloud-first)",
            "Vend (heavy, multi-store)"
          ]
        },
        {
          "id": "5.6",
          "title": "Inventory (SKU + Stock Alert)",
          "icon": "📦",
          "description": "SKU + quantity + reorder point + low-stock alert + multi-location. Pos-grocery + noodle-shop ใช้พื้นฐานนี้ในการจัดการ stock",
          "when_to_use": "Retail store, warehouse mgmt, restaurant ingredient tracking, e-commerce fulfillment, pos-grocery ingredient + product",
          "when_not_to_use": "Manufacturing BOM (need multi-level explode), serialized lot tracking (pharma/food expiry), consignment (3rd-party owned stock)",
          "examples": "pos-grocery, noodle-shop, flowboard",
          "snippet": "class Inventory{remove(sku,qty){const item=items.get(sku);if(!item||item.qty<qty)return{ok:false,err:'OOS'};item.qty-=qty;if(item.qty<item.reorderPoint)notify(`Low stock: ${item.name} (${item.qty})`);return{ok:true}}}",
          "snippet_full": "// pos-grocery style: SKU + reorder point + low-stock + atomic decrement\\nconst items = new Map([\\n  ['SKU-001', {name:'น้ำมันพืช 1L', qty:50, reorderPoint:20, cost:45, location:'A-01'}],\\n  ['SKU-002', {name:'ข้าวสาร 5kg', qty:8, reorderPoint:15, cost:120, location:'A-02'}],\\n]);\\nconst lowStockAlerts = [];\\nfunction remove(sku, qty) {\\n  const item = items.get(sku);\\n  if (!item) return {ok:false, err:'NOT_FOUND'};\\n  if (item.qty < qty) return {ok:false, err:'OUT_OF_STOCK'};\\n  item.qty -= qty;\\n  if (item.qty <= item.reorderPoint) {\\n    lowStockAlerts.push({sku, qty:item.qty, ts:Date.now()});\\n    notify(`⚠️ Low stock: ${item.name} (${item.qty} left, reorder ≤${item.reorderPoint})`);\\n  }\\n  return {ok:true, remaining:item.qty};\\n}\\n// Server-side atomic version (Postgres)\\n// UPDATE inventory SET qty = qty - $1 WHERE sku = $2 AND qty >= $1 RETURNING qty;\\n// If 0 rows returned -> OUT_OF_STOCK",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://www.postgresql.org/docs/current/sql-update.html",
          "pros": [
            "Reorder point = avg_daily_sales × lead_time_days + safety_stock = auto trigger PO ก่อน stockout",
            "SKU encoding: 'CAT-COL-SIZE' (category-color-size) = sort + filter ง่าย, barcode prefix map 1-1",
            "Low-stock alert ผ่าน Web Push/email = proactive ไม่ใช่ reactive (หลังของหมด)",
            "Multi-location (warehouse/store) = transfer stock in/out, inter-location balance",
            "Stocktake: physical count vs system = variance report (shrinkage/theft tracking)",
            "Batch/expiry tracking = FEFO (first-expiry-first-out) ใน food/pharma (ลดของเสีย)"
          ],
          "cons": [
            "Race condition: 2 cashier deduct same item = negative stock, ต้อง atomic UPDATE ... WHERE qty >= n",
            "Multi-location sync: transfer A→B ต้อง transaction 2-step พร้อม rollback ถ้า fail ฝั่งหนึ่ง",
            "FIFO/LIFO/Average cost: เลือก 1 วิธีต่อ org เปลี่ยนยาก, COGS calculation ต้อง consistent",
            "Negative stock prevention: ตรวจก่อน deduct แต่ถ้า offline 2 cashier = bypass, reconcile ตอน sync",
            "Barcode collision: 2 product share barcode (supplier mistake) = scanner ambiguity, manual override",
            "Scale: 100k SKU = full table scan ช้า, ใช้ index (sku, location, expiry) + materialized view"
          ],
          "gotchas": [
            "Atomic decrement: SQL `UPDATE stock SET qty = qty - 1 WHERE sku=? AND qty > 0` — affected rows = 0 = out of stock",
            "Reorder point formula: (avg_30d × lead_time_days) + (1.65 × stddev_daily) = 95% service level",
            "Stock adjustment reason code: 'recount', 'damage', 'theft', 'expired' = audit trail (ตาม ภ.พ.บัญชี)",
            "Currency cost = average moving: (old_qty × old_cost + new_qty × new_cost) / total_qty (perpetual avg)",
            "Negative prevention in JS: check qty >= n ใน transaction ไม่พอ, server-side check authoritative",
            "iOS: barcode via camera ใช้ BarcodeDetector API + ZXing-js fallback (iOS 17+ native support)",
            "Expiry: FEFO sort expiry ASC, pick min expiry, alert 30/7/1 day before expired",
            "Offline queue: cart.add() เก็บใน IndexedDB เป็น 'pending txn' → sync เมื่อ online → resolve conflict"
          ],
          "anti_patterns": [
            "เก็บ stock ใน localStorage แล้ว multi-cashier = state divergence, ใช้ server (Postgres + RLS) เป็น source of truth",
            "Sync stock ทันทีที่ add to cart (reserve) — UX ดี แต่ timeout/cancel ต้อง release (cron job)",
            "Reorder alert แบบ 'qty < 10' fixed — seasonal item (ice cream winter) = overstock, ใช้ moving average",
            "Track cost เป็น float — 0.1+0.2 bug, ใช้ BigInt satang (TH) หรือ integer cents (USD)",
            "Show 'In stock' label ตอน qty=0 — explicit 'Out of stock' + hide 'Add to cart' button"
          ],
          "prereqs": [
            "1.3",
            "1.4"
          ],
          "next_steps": [
            "5.5",
            "5.9"
          ],
          "tags": [
            "inventory",
            "sku",
            "stock",
            "คลัง",
            "reorder",
            "pos-grocery",
            "low-stock",
            "alert"
          ],
          "compare_with": [
            "Sortly (paid SaaS)",
            "inFlow (desktop only)",
            "Zoho Inventory (full ERP)"
          ]
        },
        {
          "id": "5.7",
          "title": "Form Builder",
          "icon": "📝",
          "description": "User-defined schema (fields array) → render form + validate + save JSON. Schema-as-data = no-code สำหรับ non-tech user",
          "when_to_use": "Survey, configurable admin form, dynamic intake (medbook pre-screening), quiz/exam builder, lead-gen form, knowledge-garden schema",
          "when_not_to_use": "Fixed forms (login, signup — overkill), 50+ field types (PDF AcroForm territory), conditional UI heavy (use React Hook Form + Zod schema)",
          "examples": "flowboard, knowledge-garden, people-crm, mood-garden, habit-streak",
          "snippet": "const TYPES={text:{render:f=>`<input name='${f.id}' type='text'>`},select:{render:f=>`<select name='${f.id}'>${f.options.map(o=>`<option value='${o}'>${o}</option>`).join('')}</select>`}}; function renderForm(s){return s.fields.map(f=>`<label>${f.label}${TYPES[f.type].render(f)}</label>`).join('')}",
          "snippet_full": "// FlowBoard / knowledge-garden style: schema-driven form + validation\\nconst schema = {\\n  id: 'intake-v1',\\n  fields: [\\n    {id:'name', label:'ชื่อ-นามสกุล', type:'text', required:true, minLength:2},\\n    {id:'age', label:'อายุ', type:'number', required:true, min:1, max:120},\\n    {id:'topic', label:'หัวข้อ', type:'select', options:['ทั่วไป','ติดตาม','ฉุกเฉิน']},\\n    {id:'consent', label:'ยินยอม', type:'checkbox', required:true},\\n  ]\\n};\\nfunction validate(values) {\\n  const errs = {};\\n  for (const f of schema.fields) {\\n    const v = values[f.id];\\n    if (f.required && !v) errs[f.id] = 'จำเป็นต้องระบุ';\\n    if (f.type === 'number' && v != null && (v < f.min || v > f.max)) errs[f.id] = `${f.min}-${f.max}`;\\n  }\\n  return errs;\\n}\\nfunction render(values = {}) {\\n  return schema.fields.map(f => {\\n    const v = values[f.id] || '';\\n    const input = f.type === 'select'\\n      ? `<select name=\"${f.id}\">${f.options.map(o=>`<option ${o===v?'selected':''}>${o}</option>`).join('')}</select>`\\n      : f.type === 'checkbox'\\n        ? `<input type=\"checkbox\" name=\"${f.id}\" ${v?'checked':''}>`\\n        : `<input type=\"${f.type}\" name=\"${f.id}\" value=\"${v}\">`;\\n    return `<label>${f.label} ${input}</label>`;\\n  }).join('');\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input",
          "pros": [
            "Schema as JSON = version control friendly (git diff), export/import, clone form",
            "Validation rules ใน schema (required, min, max, regex) = 1 source of truth, render + validate ใช้กฎเดียวกัน",
            "Field types: text/email/number/date/select/radio/checkbox/file/signature = 9 types ครอบ 90% use case",
            "Drag-drop field reordering = native HTML5 DnD หรือ react-beautiful-dnd (~50KB)",
            "Conditional logic: 'show field B if A === \"yes\"' = schema rule, render evaluation in 5 lines",
            "JSON export → Google Forms / Typeform alternative = self-hosted, no per-response fee"
          ],
          "cons": [
            "Conditional logic ซับซ้อน: A && (B || C) → !D = parser เอง 50+ LOC, ใช้ json-rules-engine (~80KB)",
            "Field type จำกัด: ไม่มี rich text/multi-step/calculated field ใน 1-day build, ต้อง extension point",
            "Validation 2 ชั้น: client (UX ทันที) + server (authoritative) — ห้ามพึ่ง client เดียว",
            "File upload: schema declared แต่ storage (S3/Supabase Storage) ต้อง wiring เพิ่ม แยก",
            "Multi-page form = state mgmt ยากขึ้น (state เก็บไหน ระหว่าง page), context API หรือ store",
            "i18n: field label/error message = 2-3 ภาษา = schema ใหญ่ขึ้น 2-3x, ใช้ i18n key + lookup table"
          ],
          "gotchas": [
            "Unique field name: enforce ใน builder UI ก่อน save (duplicate name = render conflict)",
            "Field id = stable key (uuid) ไม่ใช่ label — เปลี่ยน label ในภายหลัง ไม่ break submission data",
            "Validation timing: onBlur (per field) + onSubmit (all) — onChange รำคาญ user",
            "Required + initial empty: ใช้ empty string check ไม่ใช่ null check (form input default)",
            "Date input: <input type='date'> return 'YYYY-MM-DD' (no TZ) ≠ Date object, เก็บ string หรือ parse เป็น UTC",
            "Signature canvas: Pointer Events ต้อง touch+mouse, pressure ไม่ support (iOS Safari quirk)",
            "File upload preview: URL.createObjectURL(file) — revoke เมื่อ unmount มิงั้น memory leak",
            "Submission quota: form 100k response = DB 1GB+ (ถ้าเก็บ JSON blob), ใช้ normalized table แทน"
          ],
          "anti_patterns": [
            "Render form ด้วย eval('if ('+condition+') ...') — XSS vector, ใช้ json-rules-engine หรือ custom AST",
            "Save form schema เป็น JavaScript object literal ใน code — version control nightmare, JSON ใน .json file",
            "Validate ฝั่ง client อย่างเดียว — DevTools bypass ได้, server validate เสมอ (Zod, Joi, Yup)",
            "เก็บ submission เป็น JSON blob ใน 1 column — query/aggregate ทำไม่ได้, ใช้ normalized table (field_value ต่อ row)",
            "ใช้ innerHTML render user-supplied field label — XSS, ใช้ textContent หรือ DOMPurify sanitize"
          ],
          "prereqs": [
            "1.3",
            "1.4"
          ],
          "next_steps": [
            "5.3",
            "5.10"
          ],
          "tags": [
            "form",
            "builder",
            "ฟอร์ม",
            "schema",
            "no-code",
            "validation",
            "dynamic",
            "survey"
          ],
          "compare_with": [
            "Form.io (heavy enterprise)",
            "SurveyJS (paid)",
            "react-hook-form (React-only)"
          ]
        },
        {
          "id": "5.8",
          "title": "Dashboard Charts (ECharts)",
          "icon": "📊",
          "description": "ECharts interactive (line/bar/pie/scatter/heatmap) + responsive + a11y palette. Ai-data-analyzer ใช้พื้นฐานนี้",
          "when_to_use": "Admin dashboard, KPI visualization, sales report, data exploration, ai-data-analyzer + flowboard analytics",
          "when_not_to_use": "Print-only chart (PDF report — static SVG), 10k+ points streaming (D3 + canvas), sparkline inline (custom SVG <50 LOC)",
          "examples": "ai-data-analyzer, flowboard, pos-grocery, mood-garden",
          "snippet": "const chart=echarts.init(document.getElementById('chart')); chart.setOption({xAxis:{type:'category',data:['Mon','Tue','Wed']},yAxis:{type:'value'},series:[{data:[120,200,150],type:'bar',itemStyle:{color:'#10b981'}}]}); new ResizeObserver(()=>chart.resize()).observe(document.getElementById('chart'));",
          "snippet_full": "// ai-data-analyzer style: ECharts bar + line combo + responsive + tooltip\\nimport * as echarts from 'echarts/core';\\nimport {BarChart,LineChart} from 'echarts/charts';\\nimport {GridComponent,TooltipComponent,LegendComponent,DataZoomComponent} from 'echarts/components';\\nimport {CanvasRenderer} from 'echarts/renderers';\\necharts.use([BarChart,LineChart,GridComponent,TooltipComponent,LegendComponent,DataZoomComponent,CanvasRenderer]);\\nconst chart = echarts.init(document.getElementById('chart'), null, {renderer:'canvas'});\\nchart.setOption({\\n  color: ['#10b981','#6366f1','#f59e0b'],\\n  tooltip: {trigger:'axis', axisPointer:{type:'shadow'}},\\n  legend: {data:['Revenue','Orders']},\\n  xAxis: {type:'category', data:['Mon','Tue','Wed','Thu','Fri','Sat','Sun']},\\n  yAxis: [{type:'value', name:'฿'},{type:'value', name:'Orders'}],\\n  series: [\\n    {name:'Revenue', type:'bar', data:[12400,18200,15600,22100,28400,35300,29800], yAxisIndex:0},\\n    {name:'Orders', type:'line', data:[42,58,49,71,92,118,97], yAxisIndex:1, smooth:true}\\n  ]\\n});\\nnew ResizeObserver(() => chart.resize()).observe(chart.getDom());\\nwindow.addEventListener('beforeunload', () => chart.dispose());",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://echarts.apache.org/en/option.html",
          "pros": [
            "ECharts ~1MB gzipped = 30+ chart types (line/bar/pie/scatter/heatmap/sankey/treemap) ใน bundle เดียว",
            "Responsive default: resize ไม่ต้อง setOption ใหม่, container query ใช้ได้",
            "Color-blind safe palette (ColorBrewer) = a11y + สวย, contrast ratio 4.5:1 ผ่าน WCAG AA",
            "Data zoom + brush select = interactive exploration (zoom range, select subset, drill-down)",
            "Tooltips rich: HTML content, formatter function, dynamic per series (currency/percent/ratio)",
            "Theme JSON: switch dark/light 1 line (echarts.init(el, 'dark')), brand color override"
          ],
          "cons": [
            "ECharts 1MB gzipped หนักกว่า Chart.js (~150KB) — ใช้ light build หรือ import เฉพาะ type ที่ใช้",
            "Accessibility: ECharts = canvas-based, screen reader ไม่อ่าน (ต้อง aria-label + data table alternative)",
            "Performance 1k+ series points: downsample ก่อน render (LTTB algorithm) หรือ dataZoom default = 1st 100",
            "Mobile touch: tooltip ตามนิ้ว แต่ pinch-zoom ต้อง enable: {x: true, y: false} เพื่อ UX ดี",
            "3D charts: ใช้ WebGL แต่ performance drop บน mobile 5-10x เทียบ 2D",
            "Export PNG/SVG: client-side ใช้ echarts.getDataURL() แต่ resolution = screen, PDF = ink scale"
          ],
          "gotchas": [
            "Resize observer: ECharts ไม่ auto-detect sidebar toggle, wrap container ใน ResizeObserver",
            "Color palette: ใช้ colorblind-safe อย่าง Wong/Okabe-Ito (8 colors) แทน rainbow default",
            "Tooltips: trigger='axis' สำหรับ multi-series, 'item' สำหรับ single point, เลือกตาม use case",
            "Animation: animationDuration 800ms default, ปิด 0 เมื่อ redraw บ่อย (real-time dashboard)",
            "Stacked bar: stack: 'total' ทุก series ในกลุ่มเดียวกัน, ข้าม series = grouped bar แทน",
            "Negative value: y-axis 'dataMin' ไม่ใช่ 0, ใช้ yAxis.min = 'dataMin' (auto-fit)",
            "Time axis: xAxis.type='time' + ISO string, ห้าม Date object (timezone bug)",
            "iOS Safari: canvas chart ไม่ support backdrop-filter blur — ใช้ solid background tooltip"
          ],
          "anti_patterns": [
            "Render chart ใหม่ทั้ง option ทุก data update — merge เฉพาะ series.data ใช้ setOption({series:[{data:newData}]}, {notMerge:false})",
            "ใช้ 3D pie chart — 2D pie + label better, 3D distort perception (most data viz research)",
            "Pie chart > 5 slices — ใช้ bar chart แทน, pie ใช้ได้ 2-3 categories เท่านั้น",
            "Skip axis label (อ่านยาก) เพื่อ 'สวย' — label every nth + tooltip detail, a11y > aesthetic",
            "Load ECharts ทั้ง bundle — ใช้ tree-shake import (import * as echarts from 'echarts/core'; import {BarChart} from 'echarts/charts')"
          ],
          "prereqs": [
            "1.7"
          ],
          "next_steps": [
            "5.10"
          ],
          "tags": [
            "chart",
            "echarts",
            "dashboard",
            "กราฟ",
            "visualization",
            "responsive",
            "analytics"
          ],
          "compare_with": [
            "Chart.js (lighter, simpler)",
            "D3.js (low-level, flexible)",
            "Plotly.js (3D, scientific)",
            "Recharts (React-only)"
          ]
        },
        {
          "id": "5.9",
          "title": "Invoice (PDF)",
          "icon": "🧾",
          "description": "jsPDF client-side (zero install) + Thai font embed (Sarabun) + line items + tax + auto-numbering. Pos-grocery ใช้ receipt pattern เดียวกัน",
          "when_to_use": "Freelancer billing, SMB invoice, retail receipt, e-ticket, contract, pos-grocery receipt, certificate of completion",
          "when_not_to_use": "Recurring billing (Stripe Subscriptions / Omise), multi-page legal contract (use DocuSign), tax compliance 100% (need certified accounting software)",
          "examples": "pos-grocery, noodle-shop, people-crm",
          "snippet": "const doc=new jsPDF({unit:'mm',format:'a4'}); doc.addFileToVFS('Sarabun.ttf',sarabunB64); doc.addFont('Sarabun.ttf','Sarabun','normal'); doc.setFont('Sarabun'); doc.text('ใบเสร็จรับเงิน',20,20); doc.save(`inv-${Date.now()}.pdf`);",
          "snippet_full": "// pos-grocery / freelance-invoice style: jsPDF + Thai Sarabun + QR PromptPay + line items\\nimport jsPDF from 'jspdf';\\nimport QRCode from 'qrcode';\\nimport sarabunB64 from './Sarabun-Regular.base64.txt';\\nasync function generateInvoice({orderId, items, customer, promptPayId}) {\\n  const doc = new jsPDF({unit:'mm', format:'a4'});\\n  doc.addFileToVFS('Sarabun.ttf', sarabunB64);\\n  doc.addFont('Sarabun.ttf','Sarabun','normal');\\n  doc.setFont('Sarabun');\\n  doc.setFontSize(20); doc.text('ใบเสร็จรับเงิน / Receipt', 20, 20);\\n  doc.setFontSize(11);\\n  doc.text(`เลขที่ / No: INV-${orderId}`, 20, 30);\\n  doc.text(`วันที่ / Date: ${new Date().toLocaleDateString('th-TH')}`, 20, 37);\\n  doc.text(`ลูกค้า / Customer: ${customer.name}`, 20, 44);\\n  let y = 60;\\n  doc.setFontSize(10);\\n  items.forEach((it, i) => {\\n    doc.text(`${i+1}. ${it.name} x${it.qty}`, 20, y);\\n    doc.text(`${(it.price*it.qty).toFixed(2)} ฿`, 160, y, {align:'right'});\\n    y += 7;\\n  });\\n  const sub = items.reduce((s,i)=>s+i.price*i.qty,0);\\n  const vat = sub * 0.07;\\n  y += 5;\\n  doc.text(`Subtotal: ${sub.toFixed(2)} ฿`, 130, y); y += 6;\\n  doc.text(`VAT 7%: ${vat.toFixed(2)} ฿`, 130, y); y += 6;\\n  doc.setFontSize(14); doc.text(`Total: ${(sub+vat).toFixed(2)} ฿`, 130, y);\\n  // QR PromptPay\\n  const qrDataUrl = await QRCode.toDataURL(`000201010211...${promptPayId}...${(sub+vat).toFixed(2)}5303764540`);\\n  doc.addImage(qrDataUrl, 'PNG', 150, y+10, 35, 35);\\n  doc.save(`inv-${orderId}.pdf`);\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://artskydj.github.io/jsPDF/docs/",
          "pros": [
            "jsPDF client-side = no server cost, GDPR-friendly (data ไม่ออกจาก browser), offline-capable",
            "Thai/VI/ZH/JA/KR font embed base64 = ใบเสร็จ/ใบแจ้งหนี้ภาษา local render ถูกต้อง",
            "PDF is final-form: recipient เปิดใน Adobe/Preview/browser ได้, ไม่ต้อง install",
            "Auto-numbering INV-2024-001 → INV-2024-002 = watermark + sequential + year prefix",
            "Tax breakdown: subtotal + VAT line (7% TH, 19% DE) + total = audit-ready, ไม่ใช่แค่ราคารวม",
            "QR code payment (PromptPay TH): jsPDF + qrcode.js = scan จ่ายได้ทันที (1-2 second transfer)"
          ],
          "cons": [
            "Email deliverability: invoice PDF เข้า spam 50%+ ถ้า SPF/DKIM/DMARC ไม่ตั้ง — ใช้ SendGrid/Resend",
            "CJK font base64 = 1-3MB inline → PDF ใหญ่ขึ้น, แต่ละ font subset ต้อง license (Sarabun OFL ok)",
            "jsPDF no CSS engine: ต้องคำนวณ position เอง (x, y, fontSize), ไม่มี flexbox/grid",
            "Recurring monthly invoice: client-side generate 12 PDF ต่อเดือน = UX ลำบาก, ใช้ Stripe/Omise subscription",
            "Late fee / interest calc: business rule แต่ละที่ต่างกัน, ต้อง config layer",
            "Digital signature: PDF เซ็นชื่อ legally = X.509 cert + pkcs12 (server-side sign), ไม่ใช่ jsPDF"
          ],
          "gotchas": [
            "Thai font broken: jsPDF default Helvetica ไม่รองรับ unicode ภาษาไทย, embed Sarabun/Noto Sans TH base64",
            "CORS: external image (logo) ต้อง fetch → toDataURL ก่อน addImage มิงั้น canvas taint",
            "jsPDF unit: 'mm' (default 'pt') — A4 = 210×297mm, ใช้ mm อ่านง่ายกว่า pixel",
            "Page break: invoice 20+ items = overflow 1 page, เช็ค y > 280mm แล้ว doc.addPage()",
            "Font subset: โหลด Sarabun เต็ม 3MB เพื่อ 50 คำ = สิ้นเปลือง, ใช้ pyftsubset ตอน build",
            "QR code size: 25×25mm = scan ได้ทันที, 20mm = scan fail บน iPhone บางรุ่น",
            "Tax rounding: 99.50 × 1.07 = 106.465 → display 106.47 vs 106.46 = เลือก rounding mode 1 ที่เดียว",
            "iOS 17 Mail preview: PDF เปิด inline ถ้า < 5MB, > 5MB = download prompt (size cap)"
          ],
          "anti_patterns": [
            "Upload HTML ไป server, render ด้วย Puppeteer, ส่ง PDF link — slower, server cost, no offline",
            "Embed base64 ใน HTML แล้ว browser print to PDF — ไม่มี line items table, format ไม่ consistent",
            "Generate invoice แล้ว save as Blob URL ตลอดไป — URL.revokeObjectURL หลัง download เพื่อ free memory",
            "Use Times New Roman default = ไม่ render ภาษาไทย, embed Sarabun ก่อน text() เสมอ",
            "Re-generate PDF ทุกครั้งที่ view = cache (IndexedDB blob) ครั้งแรก, serve cache ครั้งต่อไป"
          ],
          "prereqs": [
            "1.7",
            "5.5"
          ],
          "next_steps": [
            "5.3"
          ],
          "tags": [
            "invoice",
            "pdf",
            "ใบเสร็จ",
            "receipt",
            "jspdf",
            "thai",
            "sarabun",
            "promptpay",
            "qr"
          ],
          "compare_with": [
            "pdfmake (declarative, larger)",
            "pdf-lib (edit existing)",
            "react-pdf (React-only)",
            "Puppeteer (server, full HTML)"
          ]
        },
        {
          "id": "5.10",
          "title": "Analytics",
          "icon": "📈",
          "description": "Capture events + funnels + retention + cohort + anonymize IP + batched send. Ai-data-analyzer ใช้ self-hosted analytics เป็น layer หนึ่ง",
          "when_to_use": "Product analytics, marketing site conversion funnel, A/B test results, feature adoption tracking, SaaS retention curve",
          "when_not_to_use": "Embedded analytics (customer-facing dashboard = Metabase/Preset), real-time sub-second (need Kafka + ClickHouse), mobile app (use Mixpanel/Amplitude SDK)",
          "examples": "ai-data-analyzer, flowboard, mood-garden, habit-streak",
          "snippet": "function track(event,props={}){queue.push({event,props,ts:Date.now(),anonId:getAnonId()});if(queue.length>=10)flush()} window.addEventListener('beforeunload',()=>{navigator.sendBeacon('/api/event',new Blob([JSON.stringify(queue)],{type:'application/json'}))});",
          "snippet_full": "// Self-hosted analytics: event batch + anonymize IP + sendBeacon\\nconst queue = [];\\nconst sessionId = sessionStorage.getItem('sid') || (() => {\\n  const id = crypto.randomUUID();\\n  sessionStorage.setItem('sid', id);\\n  return id;\\n})();\\nfunction anonymizeIP(ip) {\\n  if (!ip) return null;\\n  if (ip.includes(':')) return ip.split(':').slice(0,3).join(':') + ':0:0:0:0:0:0';\\n  return ip.split('.').slice(0,3).join('.') + '.0';\\n}\\nfunction track(event, props = {}) {\\n  queue.push({event, props, ts:Date.now(), sid:sessionId});\\n  if (queue.length >= 10) flush();\\n}\\nasync function flush() {\\n  if (queue.length === 0) return;\\n  const batch = queue.splice(0, queue.length);\\n  const blob = new Blob([JSON.stringify({events:batch})], {type:'application/json'});\\n  if (navigator.sendBeacon) {\\n    navigator.sendBeacon('/api/events', blob);\\n  } else {\\n    await fetch('/api/events', {method:'POST', body:blob, keepalive:true});\\n  }\\n}\\nwindow.addEventListener('beforeunload', flush);\\nsetInterval(flush, 30000); // 30s safety net\\n// Funnel: track each step\\n['view_landing','click_signup','complete_form','verify_email','first_purchase'].forEach(step => {\\n  track(step, {utm: new URLSearchParams(location.search).get('utm_source')});\\n});",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon",
          "pros": [
            "Funnel analysis: signup → activate → purchase = conversion % แต่ละ step, drop-off insight",
            "Retention cohort: week-0 100% → week-4 35% = visualize churn ใน heatmap (ผู้ใช้ return แค่ไหน)",
            "Event batching (10 events ต่อ 1 request) = 10x network efficiency vs fire-each-event",
            "Anonymize IP: strip last octet (192.168.1.xxx) = GDPR Art. 5 'data minimization' compliant",
            "Self-hosted (PostHog/Plausible) = ไม่ต้องพึ่ง GA4 cookie banner, EU data residency",
            "Funnel + cohort + path = 3 query pattern ครอบ 80% product decision making"
          ],
          "cons": [
            "GDPR cookie banner: GA4/Facebook Pixel ใช้ cookie = EU ต้อง consent (opt-in), โอกาส 30% user decline",
            "Data warehouse cost: 100M events/month = $200-500/mo (BigQuery/Snowflake), ใช้ ClickHouse self-host ถูกกว่า 5x",
            "Real-time (sub-second) = Kafka + ClickHouse + materialized view, ไม่ใช่ Postgres + cron",
            "Privacy: fingerprinting (canvas + audio + fonts) ผิด GDPR — ใช้ server-set first-party cookie เท่านั้น",
            "Funnel re-order: user ข้ามขั้น (signup → purchase โดยไม่ผ่าน onboarding) = funnel undercount",
            "Identity stitching: anonymous → user after login = event merge heuristic (IP+UA+session) ไม่แม่น 100%"
          ],
          "gotchas": [
            "Send on beforeunload: fetch with keepalive:true ไม่ cancel เมื่อ tab close (event ไม่หาย)",
            "Batch size: 10-20 events ต่อ 1 POST = balance latency vs request count (10 = 100KB, 1 = 10KB)",
            "Anonymize IP client-side: ip.split('.').slice(0,3).join('.') + '.0' (IPv4 only, IPv6 ต้อง /48 mask)",
            "Session ID: crypto.randomUUID() เก็บ sessionStorage (ลบเมื่อ tab close) = 1 session = 1 funnel entry",
            "User ID หลัง login: set(userId) merge anonymous + identified events (PostHog distinct_id pattern)",
            "Sampling: 100% event ตอน dev, 10% production (เก็บ sample rate flag ใน event meta)",
            "Time zone: event.ts = Date.now() UTC ms, แสดง user TZ ใน dashboard (ไม่เก็บ local)",
            "Schema migration: เพิ่ม property ใหม่ต้อง default null ใน query เก่า (no breaking change)"
          ],
          "anti_patterns": [
            "Track every click — noisy, ใช้ auto-track เฉพาะ data-attribute (data-track='cta-click') explicit",
            "Block analytics script ด้วย cookie gate ก่อน consent — script load delay = first event miss 100%",
            "ใช้ Google Analytics แล้ว assume 'anonymous IP' = GDPR safe — GA4 still need consent, EU DPA ฟ้องได้",
            "Fire event ผ่าน setInterval flush ทุก 1s — burst 100 events = 100 req, batch 10 = 10 req",
            "เก็บ PII (email, name) ใน event property — anonymize เฉพาะ aggregate metric, ไม่ raw event"
          ],
          "prereqs": [
            "1.7"
          ],
          "next_steps": [
            "5.8"
          ],
          "tags": [
            "analytics",
            "event",
            "funnel",
            "cohort",
            "gdpr",
            "tracking",
            "ai-data-analyzer",
            "retention"
          ],
          "compare_with": [
            "PostHog (self-host OSS)",
            "Plausible (privacy-first paid)",
            "GA4 (free, cookie banner)",
            "Mixpanel (mobile-focused paid)"
          ]
        }
      ]
    },
    {
      "id": 6,
      "name": "Media Apps",
      "icon": "🎬",
      "tagline": "Audio + video + image",
      "sub_phases": [
        {
          "id": "6.1",
          "title": "Audio Editor (Waveform)",
          "icon": "🎵",
          "description": "Visual waveform + region trim + fade in/out. WaveSurfer.js v7 ships regions + MP3 decode + 50KB footprint.",
          "when_to_use": "Podcast editor, music trimmer, karaoke timing, voice memo cleanup, SFX browser ใน CodeHub (karaoke-studio ใช้แพทเทิร์นเดียวกัน)",
          "when_not_to_use": "Multi-track DAW (8+ tracks, VST plugins, MIDI); pro mastering (need sample-accurate offline render); real-time DSP effects chain (need AudioWorklet)",
          "examples": "karaoke-studio, sj88cute-voice-isolator",
          "snippet": "const ws=WaveSurfer.create({container:'#wf',waveColor:'#ec4899',progressColor:'#a855f7',url:'audio.mp3',peaks:[/*pre-computed*/]});",
          "snippet_full": "const audioCtx = new (window.AudioContext||window.webkitAudioContext)();\\nconst buf = await audioCtx.decodeAudioData(await fetch('song.mp3').then(r=>r.arrayBuffer()));\\nconst peaks = [];\\nconst ch = buf.getChannelData(0);\\nfor (let i = 0; i < buf.duration * 100; i++) {\\n  const s = Math.floor(i * ch.length / (buf.duration * 100));\\n  let min = 1, max = -1;\\n  for (let j = 0; j < ch.length / (buf.duration*100); j++) {\\n    const v = ch[s+j] || 0; if (v<min)min=v; if (v>max)max=v;\\n  }\\n  peaks.push([min, max]);\\n}\\nconst ws = WaveSurfer.create({container:'#wf',waveColor:'#ec4899',progressColor:'#a855f7',url:'song.mp3',peaks});",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://wavesurfer.xyz/docs/",
          "pros": [
            "AudioContext.decodeAudioData() ให้ Float32 PCM peaks ทันที ไม่ต้อง parse WAV header เอง",
            "Region select = trim/fade โดยไม่ต้อง re-encode เพราะใช้ <audio> element seek",
            "WaveSurfer v7 มี Regions/Timeline/Minimap plugin ใน bundle เดียว ~50KB gzipped",
            "Web Audio API playback = sample-accurate scrub, สามารถใส่ gain node ต่อ region ได้",
            "ทำงานบน mobile ได้ (iOS Safari รองรับ decodeAudioData ตั้งแต่ iOS 13)",
            "Export MP3 ผ่าน lamejs (~30KB) ไม่ต้องส่งไป server"
          ],
          "cons": [
            "decodeAudioData() ใช้ memory = file_size × 4 (Float32) → ไฟล์ 50MB ใช้ RAM 200MB",
            "WaveSurfer render ใช้ canvas ขนาด 1:1 กับ pixels → 4K width = slow on mobile",
            "ไม่มี built-in effect chain (compressor/EQ) — ต้องเขียน AudioWorklet เอง",
            "Cannot open .aiff/.flac บน Safari (MP3/WAV/AAC/OGG เท่านั้น)",
            "Visual seek ไม่ sample-accurate ถ้าไฟล์ VBR MP3 (seek table ผิด)",
            "iOS background tab = audio pause ทันที (autoplay policy)"
          ],
          "gotchas": [
            "iOS Safari: user gesture (tap) ต้องเกิดก่อน audio.play() ไม่งั้น block + console error",
            "decodeAudioData() returns Promise — ห่อ try/catch เพราะ CORS file จะ throw โดยไม่บอกรายละเอียด",
            "CORS: cross-origin audio ต้องมี Access-Control-Allow-Origin หรือ response เป็น opaque (แล้ว decodeAudioData จะล้มเหลว)",
            "Web Audio context = single instance per page สร้างซ้อนกันไม่ได้ (resume() หลัง user gesture)",
            "MP3 VBR seek ไม่แม่น → ใช้ peaks pre-computed แทนการ random-access",
            "Canvas resize during playback = waveform ต้อง redraw (debounce 100ms)",
            "Region drag บน touch device = pointer events ต้องใช้ PointerEvent ไม่ใช่ touchstart",
            "getByteFrequencyData ต้องเรียกหลัง analyser.getFloatTimeDomainData ทุก frame"
          ],
          "anti_patterns": [
            "Parse WAV header เอง (RIFF chunk) เพื่ออ่าน samples — ใช้ decodeAudioData() แท้ๆ ทำได้ใน 1 บรรทัด",
            "Render waveform ทุก animation frame — peaks ไม่เปลี่ยน decode ครั้งเดียว วาดครั้งเดียว",
            "ใช้ setInterval(ontimeupdate) — ใช้ AudioContext.currentTime เป็น master clock แทน",
            "เก็บ original audio ใน <audio> + AudioBuffer ซ้ำกัน — ใช้ AudioBufferSourceNode.playbackRate แทน",
            "Export โดย upload file → server → process — ใช้ lamejs (MP3) หรือ WebM Opus encode client-side"
          ],
          "prereqs": [
            "1.5",
            "1.6"
          ],
          "next_steps": [
            "6.6",
            "6.7"
          ],
          "tags": [
            "audio",
            "waveform",
            "เสียง",
            "wavesurfer",
            "webaudio",
            "trim",
            "fade"
          ],
          "compare_with": [
            "Tone.js (effect chain, larger 200KB)",
            "Howler.js (playback only, no waveform)",
            "Peak.js (BBC, frame-accurate but ~150KB)"
          ]
        },
        {
          "id": "6.2",
          "title": "Custom Video Player",
          "icon": "▶️",
          "description": "HTML5 <video> + custom controls + playlist + speed + WebVTT subs + Picture-in-Picture. ~30KB zero-dep.",
          "when_to_use": "Learning platform, social feed video, portfolio reel, internal training, embedded player สำหรับ karaoke-studio / sj88-video-editor-pro previews",
          "when_not_to_use": "DRM content (Widevine/FairPlay ต้อง HTTPS + license server + MSE/EME); live streaming (HLS.js หรือ Shaka Player); ad monetization (IMA SDK)",
          "examples": "karaoke-studio, sj88-video-editor-pro, sj88cute-frame-extractor",
          "snippet": "const v=document.querySelector('video'); v.playbackRate=1.5; v.requestPictureInPicture(); v.textTracks[0].mode='showing';",
          "snippet_full": "const v = document.querySelector('video');\\nconst track = v.addTextTrack('captions','en','English');\\ntrack.addCue(new VTTCue(2,5,'Hello world'));\\ntrack.mode = 'showing';\\nv.playbackRate = 1.5;\\nv.addEventListener('enterpictureinpicture', () => console.log('PiP on'));\\ndocument.querySelector('#pip-btn').onclick = () => v.requestPictureInPicture();\\nnavigator.mediaSession.metadata = new MediaMetadata({title:'Lesson 1',artist:'CodeHub'});",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video",
          "pros": [
            "Native <video> = zero decode cost, GPU compositor, ใช้ codec ของ OS (H.264/H.265/AV1/VP9)",
            "WebVTT via <track> = captions ทำงานทันที, screen readers อ่านได้ (a11y boost)",
            "Picture-in-Picture API (Chrome/Safari) = ลอย video บน desktop ขณะ multitask",
            "PlaybackRate 0.0625–16 = useful สำหรับ tutorial (slow-mo) และ skip-through",
            "MediaSession API ติดปุ่ม lock-screen controls (iOS/Android) = native feel",
            "No build, no framework, ship <video>+<script> ได้เลย"
          ],
          "cons": [
            "DRM/encrypted streams ต้อง MSE + EME + HTTPS + license request (CORS nightmare)",
            "iOS Safari: playsinline attribute ต้องใช้ มิงั้น fullscreen อัตโนมัติ",
            "Autoplay policy: Chrome block autoplay จนกว่า user gesture + muted หรือ media engagement",
            "Quality switching ต้อง MSE + manifest (HLS/DASH) ไม่ใช่ <video src> ธรรมดา",
            "Subtitle styling จำกัด (ไม่รองรับ ::cue pseudo บน legacy Edge)",
            "Seek precision ขึ้นกับ keyframe interval — VBR content seek ±2-5s"
          ],
          "gotchas": [
            "iOS playsinline: <video playsinline webkit-playsinline> ไม่งั้น iPhone fullscreen ทันทีเมื่อ play",
            "preload='metadata' = โหลดแค่ header (fps/duration) ลด bandwidth 90% เทียบ preload='auto'",
            "currentTime = 0.001 trick เพื่อ force thumbnail แสดง (Chrome bug บาง version)",
            "onloadedmetadata ก่อน oncanplay — ใช้ loadedmetadata เพื่อ get duration",
            "WebVTT cues ใช้ timestamp HH:MM:SS.mmm — ไม่มี frame number (VTT ไม่ใช่ frame-based)",
            "iOS volume control ผ่าน JS ไม่ได้ (security) — ใช้ MediaSession metadata แทน",
            "Track element default ไม่แสดง — ต้อง track.default = true (default attribute)",
            "Black frame ตอน seek = post-processing keyframe ใหม่, ใช้ requestVideoFrameCallback (Chrome) detect"
          ],
          "anti_patterns": [
            "สร้าง custom decoder/encoder — browser มี HW-accelerated decoder แล้ว ใช้ <video> ดีกว่า",
            "ใช้ <iframe YouTube> เพื่อ 'custom' UI — track events ไม่ได้, no offline, branding",
            "Force flash fallback — Flash เลิกใช้ 2017 แล้ว",
            "Re-render controls ทุก video state change — ใช้ CSS class toggle แทน (reflow 5x น้อยกว่า)",
            "Bind onwheel บน progress bar เพื่อ scrub — conflict กับ page zoom, ใช้ pointer events"
          ],
          "prereqs": [
            "1.6"
          ],
          "next_steps": [
            "6.3",
            "6.8"
          ],
          "tags": [
            "video",
            "player",
            "วิดีโอ",
            "html5",
            "webvtt",
            "pip",
            "playback"
          ],
          "compare_with": [
            "Video.js (full player ~200KB, plugins)",
            "Plyr (lightweight ~30KB, opinionated)",
            "HLS.js (live streaming, MSE-based)"
          ]
        },
        {
          "id": "6.3",
          "title": "Video Editor (Timeline)",
          "icon": "🎥",
          "description": "Multi-track timeline + clip trim/ripple + transitions + WebCodecs decode + ffmpeg.wasm export. Reference: sj88-video-editor-pro.",
          "when_to_use": "Short-form social video, course/tutorial editor, podcast clipper, meme-maker, sj88-video-editor-pro (CapCut-style) ใช้พื้นฐานนี้",
          "when_not_to_use": "Long-form pro (Premiere/Resolve); collaborative real-time multi-user (need OT/CRDT); 4K RAW color grading",
          "examples": "sj88-video-editor-pro, karaoke-studio, sj88cute-voice-isolator, sj88cute-frame-extractor",
          "snippet": "const enc=new VideoEncoder({output:chunk=>muxer.addVideoChunk(chunk),error:console.error}); enc.configure({codec:'avc1.42E01E',width:1920,height:1080,bitrate:5_000_000,framerate:30});",
          "snippet_full": "// sj88-video-editor-pro style: WebCodecs frame-by-frame export\\nconst muxer = new Mp4Muxer({target:new ArrayBufferTarget(),video:{codec:'avc',width:1920,height:1080},fastStart:'in-memory'});\\nconst enc = new VideoEncoder({output:c=>muxer.addVideoChunk(c),error:e=>console.error(e)});\\nenc.configure({codec:'avc1.42E01E',width:1920,height:1080,bitrate:5_000_000,framerate:30});\\nfor (let t = 0; t < duration; t += 1/30) {\\n  const frame = new VideoFrame(canvas, {timestamp:t*1_000_000});\\n  enc.encode(frame, {keyFrame:t%(30*2)===0});\\n  frame.close();\\n}\\nawait enc.flush();\\ndownloadBlob(new Blob([muxer.target.buffer],{type:'video/mp4'}),'export.mp4');",
          "difficulty": "Advanced",
          "time_to_learn": "2 weeks",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API",
          "pros": [
            "WebCodecs API (Chrome 94+, Edge) = hardware decode/encode VideoFrame โดยตรง, 5-10x เร็วกว่า <video>+canvas",
            "ffmpeg.wasm 30MB single-file = MP4/H.264 export client-side ไม่ต้อง server",
            "Canvas 2D + WebGL shader = transition/effect chain render ที่ 60fps ได้",
            "IndexedDB blob storage = project file เก็บในเบราว์เซอร์, export MP4 ตอน save",
            "Multi-track = audio หลายชั้น + video overlays (text/sticker) ใน timeline เดียว",
            "Keyframe interpolation (linear/ease/bezier) ใน JS = animation ลื่น ไม่ต้อง After Effects"
          ],
          "cons": [
            "WebCodecs = Chrome/Edge only (Safari 16.4 partial, Firefox behind flag) — fallback <video>+canvas ช้ากว่า 5x",
            "ffmpeg.wasm 30MB initial load = first export รอ 5-10s ขณะ WASM compile",
            "4K60 timeline = 8GB RAM (VideoFrame pool ไม่คืน memory ทันที)",
            "Audio sync drift: media time ≠ wall clock เมื่อ render นาน > 30s (frame counter mismatch)",
            "No GPU shader caching: effect chain 8 filters = recompile shader ทุก frame ถ้าไม่ memoize",
            "Project state = JSON ไม่พอ — binary blobs (raw frames, audio) เก็บแยก IndexedDB"
          ],
          "gotchas": [
            "WebCodecs VideoFrame.close() = ต้องเรียนด้วยตัวเอง มิงั้น GPU memory leak 1GB/min",
            "ffmpeg.wasm: SharedArrayBuffer ต้อง COOP/COEP headers (cross-origin isolated)",
            "Frame timing: requestVideoFrameCallback() ให้ mediaTime จริง (ไม่ใช่ performance.now)",
            "Audio drift: AudioContext.currentTime - video.currentTime ตรวจทุก 5s แล้ว resync",
            "Canvas captureStream(30) = 30fps cap — render 60fps เสียเปล่า, ใช้ VideoEncoder ตรงๆ ดีกว่า",
            "IndexedDB quota ≈ 60% disk → video project เกิน 2GB จะ quota error",
            "Seeking = dispose VideoFrame ทั้งหมดก่อน, decode ใหม่ (ทำลาย seek perf)",
            "WebGL texture upload = bottleneck ถ้าไม่ใช้ texImage2D จาก VideoFrame (vs HTMLVideoElement)"
          ],
          "anti_patterns": [
            "ใช้ MediaRecorder + canvas.captureStream() เพื่อ export — WebCodecs VideoEncoder เร็วกว่า 10x, ได้ keyframe control",
            "Render ทุก frame ทั้ง timeline — pre-compute static segments, render dynamic เฉพาะส่วนเปลี่ยน",
            "เก็บ project ใน localStorage 5MB limit — ใช้ IndexedDB สำหรับ binary, JSON สำหรับ metadata",
            "Build timeline DOM = div ต่อ frame — ใช้ canvas เดียว (1 clip = 1 region) ลด DOM ลง 1000x",
            "Sync audio ผ่าน <audio>.currentTime = video.currentTime — drift ทันที, ใช้ AudioContext destination + GainNode"
          ],
          "prereqs": [
            "1.9",
            "6.2"
          ],
          "next_steps": [
            "6.6",
            "6.7",
            "6.10"
          ],
          "tags": [
            "video",
            "editor",
            "วิดีโอ",
            "timeline",
            "webcodecs",
            "ffmpeg",
            "keyframe",
            "export"
          ],
          "compare_with": [
            "Remotion (React, server-render only)",
            "MLT/Melted (C++ pipeline, native only)",
            "CapCut Web (closed source)"
          ]
        },
        {
          "id": "6.4",
          "title": "Image Editor (Crop/Filter)",
          "icon": "🖼️",
          "description": "Canvas 2D + WebGL filters (brightness/contrast/blur) + sticker overlay + text. Reference: fitcheck, cutout-studio, palette.",
          "when_to_use": "Profile pic cropper, sticker maker, meme layer, product photo (fitcheck, cutout-studio, palette ใช้พื้นฐานนี้), social post composer",
          "when_not_to_use": "RAW/DNG editing; HDR merge; 16-bit color; Photoshop-grade layers (50+ smart objects, blend modes)",
          "examples": "fitcheck, cutout-studio, palette",
          "snippet": "const ctx=canvas.getContext('2d'); ctx.filter='brightness(1.2) saturate(1.5) contrast(1.1)'; ctx.drawImage(img,0,0); ctx.filter='none';",
          "snippet_full": "// fitcheck / cutout-studio style: layered editor with re-doable filters\\nconst cvs = document.querySelector('canvas');\\nconst ctx = cvs.getContext('2d', {willReadFrequently:false});\\nconst bitmap = await createImageBitmap(file, {imageOrientation:'from-image'});\\ncvs.width = bitmap.width; cvs.height = bitmap.height;\\nconst filters = {brightness:1.1, contrast:1.05, saturate:1.4, blur:0};\\nfunction render(){\\n  ctx.filter = `brightness(${filters.brightness}) contrast(${filters.contrast}) saturate(${filters.saturate}) blur(${filters.blur}px)`;\\n  ctx.clearRect(0,0,cvs.width,cvs.height);\\n  ctx.drawImage(bitmap, 0, 0);\\n}\\nrender();\\ncvs.toBlob(b=>downloadBlob(b,'edited.webp'),'image/webp',0.9);",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter",
          "pros": [
            "ctx.filter='brightness(1.2)' = CSS-level effect 0-line, GPU-accelerated",
            "OffscreenCanvas + Worker = filter 4K image โดยไม่ block main thread (responsive UI)",
            "WebGL fragment shader = 50+ custom filters (sepia/glitch/vignette) ที่ 60fps",
            "createImageBitmap() = decode 12MP JPEG ใน ~80ms (vs <img> 200ms)",
            "toBlob('image/webp', 0.9) = 30-50% smaller output กว่า JPEG เทียบ visual quality",
            "Sticker/text layer = transform matrix (translate/rotate/scale) ผ่าน canvas.setTransform"
          ],
          "cons": [
            "Canvas 2D filter quality ต่ำกว่า Photoshop (no 16-bit, no CMYK, limited blend modes)",
            "WebGL shader ต้อง recompile ทุกครั้ง filter เปลี่ยน — memoize program ด้วย string key",
            "EXIF orientation: <img> ไม่หมุนอัตโนมัติ, ต้อง parse EXIF หรือ createImageBitmap({imageOrientation:'from-image'})",
            "4K image (3840×2160) × Float32 RGBA = 130MB GPU memory — tile-based render แทน",
            "Browser max canvas = 16384×16384 บน Chrome / 4096×4096 iOS — resize ก่อน drawImage",
            "iOS Safari: OffscreenCanvas + Worker ตั้งแต่ 16.4, bug บน 14-15"
          ],
          "gotchas": [
            "iOS rotation bug: iPhone photo ถ่าย portrait แต่ EXIF landscape → drawImage ออกมาตะแคงข้าง",
            "Workaround: createImageBitmap(blob,{imageOrientation:'from-image'}) แล้ว draw แบบ bitmap",
            "ctx.filter ต้อง set ก่อน drawImage ไม่งั้นไม่ apply",
            "WebGL texImage2D from HTMLImageElement = upload CPU-side pixel (slow) → ใช้ texImage2D(target,level,format,bitmap) แทน",
            "iOS Safari: canvas.toBlob() = 'image/png' เท่านั้น (ไม่รองรับ webp ในบาง version)",
            "Filter pipeline: ต้อง save/restore context เพื่อไม่ให้ filter leak ไป layer ถัดไป",
            "Crop aspect ratio: devicePixelRatio 2x retina → canvas ใหญ่กว่าที่ตาเห็น 2 เท่า (handle ก่อน export)",
            "Undo/redo = เก็บ ImageData snapshot → 50MB per layer × 20 steps = OOM, ใช้ Command pattern (delta) แทน"
          ],
          "anti_patterns": [
            "Stack CSS filter + ctx.filter = double apply (CSS คือ compositing pass, ไม่ใช่ effect layer)",
            "ใช้ base64 string แทน Blob URL = RAM x4, GC pressure สูง, ใช้ URL.createObjectURL(file)",
            "เก็บ canvas state ใน JSON serialization (toDataURL ใน array) — base64 33% bigger, ใช้ OffscreenCanvas snapshot",
            "Read EXIF manual เพื่อ orientation — createImageBitmap options ทำให้แล้ว 1 บรรทัด",
            "Re-render full canvas ทุก slider change — debounce 16ms (1 frame) หรือ in-place filter chain"
          ],
          "prereqs": [
            "1.6"
          ],
          "next_steps": [
            "6.5",
            "6.9"
          ],
          "tags": [
            "image",
            "editor",
            "รูปภาพ",
            "canvas",
            "filter",
            "crop",
            "sticker",
            "webgl"
          ],
          "compare_with": [
            "Fabric.js (object-oriented, ~300KB)",
            "Konva.js (DOM-based, ~150KB)",
            "P5.js (creative coding, ~700KB)"
          ]
        },
        {
          "id": "6.5",
          "title": "PDF Generation",
          "icon": "📄",
          "description": "jsPDF client-side (zero install) vs WeasyPrint/Puppeteer server-side (HTML-to-PDF). Reference: pos-grocery receipt, nesspaper-style.",
          "when_to_use": "Invoice/receipt (pos-grocery), report, certificate, contract, ticket, e-book, nesspaper-style newspaper layout",
          "when_not_to_use": "Real-time collaborative PDF edit (Google Docs model); 500+ page documents (memory + render time); interactive PDF forms (need PDF.js or AcroForm)",
          "examples": "pos-grocery",
          "snippet": "const doc=new jsPDF(); doc.setFont('Sarabun'); doc.text('ใบเสร็จรับเงิน',20,20); doc.setFontSize(12); doc.text('Total: 350 บาท',20,30); doc.save('receipt.pdf');",
          "snippet_full": "// pos-grocery style: Thai receipt via jsPDF + base64 font\\nimport jsPDF from 'jspdf';\\nimport sarabunBase64 from './Sarabun-Regular.base64.txt';\\nconst doc = new jsPDF({unit:'mm',format:'a4'});\\ndoc.addFileToVFS('Sarabun.ttf', sarabunBase64);\\ndoc.addFont('Sarabun.ttf', 'Sarabun', 'normal');\\ndoc.setFont('Sarabun');\\ndoc.setFontSize(18);\\ndoc.text('ใบเสร็จรับเงิน / Receipt', 20, 20);\\ndoc.setFontSize(11);\\nlet y = 35;\\nitems.forEach(it => { doc.text(`${it.name} x${it.qty}`, 20, y); doc.text(`${it.price*it.qty} ฿`, 150, y); y += 7; });\\ndoc.setFontSize(14); doc.text(`รวม / Total: ${total} ฿`, 20, y+5);\\ndoc.save(`receipt-${orderId}.pdf`);",
          "difficulty": "Intermediate",
          "time_to_learn": "1 day",
          "docs_url": "https://artskydj.github.io/jsPDF/docs/",
          "pros": [
            "jsPDF ฝั่ง client = no server cost, GDPR-friendly (data ไม่ออกจาก browser), offline-capable",
            "Puppeteer/WeasyPrint server = full HTML/CSS (flexbox, grid) → PDF ตรง design 100%",
            "jsPDF font embedding base64 = CJK (TH/VI/ZH/JA/KR) render ถูกต้อง (ฟอนต์ Sarabun, Noto Sans TH)",
            "pdf-lib client-side = edit existing PDF (add page, fill form, merge) โดยไม่ round-trip server",
            "Puppeteer headless Chrome = same engine as user's browser = pixel-perfect print",
            "jsPDF + html2canvas = rasterize HTML div → image → embed (escape hatch เมื่อ layout ซับซ้อน)"
          ],
          "cons": [
            "jsPDF: ไม่มี CSS engine — ต้องคำนวณ position เอง (x,y,fontSize), ไม่มี flexbox/grid",
            "Puppeteer headless Chrome = 200-300MB RAM per process, เปิดใหม่ 1-2s per PDF (cold)",
            "CJK font base64 = 1-3MB inline → PDF ใหญ่ขึ้น, แต่ละ font subset ต้อง license (Noto OFL ok)",
            "Puppeteer render = blocking event loop ใน Node (--no-sandbox แล้ว security risk)",
            "html2canvas + jsPDF = rasterize = text ไม่ selectable, ไฟล์ใหญ่, ไม่ accessible",
            "jsPDF autoTable plugin ไม่รองรับ colspan/rowspan ซับซ้อน, header repeat across pages เอง"
          ],
          "gotchas": [
            "CORS: external image ใน jsPDF ต้อง fetch → base64 (jsPDF.addImage base64 only)",
            "Thai text broken = ใช้ฟอนต์ที่ไม่มีสระลอย (Sarabun, Noto Sans Thai) แล้ว embed เป็น .ttf base64",
            "Puppeteer sandbox: Docker ต้อง --no-sandbox flag หรือ seccomp profile เพื่อหลีกเลี่ยง crash",
            "jsPDF: addFont() ต้องเรียกก่อน doc.text() มิงั้น default Helvetica ใช้แล้ว text ผิด encoding",
            "Font subsetting: jsPDF โหลดฟอนต์เต็ม 3MB เพื่อคำ 5 คำ — ใช้ pyftsubset ฝั่ง build pipeline",
            "A4 = 595×842 pt (not px!) — convert px@96dpi ด้วย 595/96 = 6.2x scale factor",
            "Puppeteer page.pdf() ไม่ render @media print stylesheet เสมอ — ใช้ emulateMedia('print')",
            "WeasyPrint: ไม่รองรับ JS execution (Python+CSS only) — สำหรับ static layout เท่านั้น"
          ],
          "anti_patterns": [
            "ใช้ html2canvas + jsPDF แทน server-side — raster PDF ใหญ่ 10-20x, ไม่ selectable, accessibility แย่",
            "Load ฟอนต์จาก Google Fonts ใน jsPDF — CORS block ตอน runtime, embed base64 ตอน build แทน",
            "Spawn Puppeteer ต่อ request = 300MB process / 2s spinup — ใช้ pool (puppeteer-cluster) หรือ serverless browser",
            "Generate PDF เป็น base64 string เก็บ DB — base64 33% larger, ใช้ binary blob (BYTEA, S3)",
            "Assume jsPDF รู้จัก UTF-8 = false — ใช้ addFont() with 'utf-8' encoding parameter เสมอ"
          ],
          "prereqs": [
            "1.7"
          ],
          "tags": [
            "pdf",
            "jspdf",
            "invoice",
            "ใบเสร็จ",
            "receipt",
            "html2pdf",
            "puppeteer",
            "weasyprint"
          ],
          "compare_with": [
            "pdfmake (declarative, ~500KB)",
            "pdf-lib (edit existing, ~400KB)",
            "react-pdf (React, server-side only)"
          ]
        },
        {
          "id": "6.6",
          "title": "Lyric Video Maker",
          "icon": "📝",
          "description": "Karaoke-style timing editor: LRC import + click-to-set timing + animated highlight + canvas overlay + WebM export. Reference: karaoke-studio (6+ theme variants).",
          "when_to_use": "Music content creator, social lyric clip (TikTok/Reels), karaoke timing rehearsal, lyric translation video, karaoke-studio base",
          "when_not_to_use": "Live karaoke (need real-time pitch detection เช่น YIN/CREPE); VFX-heavy music video; auto-align to vocals (need audio-to-text alignment)",
          "examples": "karaoke-studio",
          "snippet": "video.ontimeupdate=()=>{for(let i=lines.length-1;i>=0;i--)if(t>=lines[i].start){line.textContent=lines[i].text;break}};",
          "snippet_full": "// karaoke-studio style: LRC parse + scroll highlight via audio clock\\nconst audio = new Audio('song.mp3');\\nconst lrc = `[00:12.34]Line one\\n[00:15.67]Line two\\n[00:19.00]Line three`;\\nconst lines = lrc.split('\\n').map(l => {\\n  const m = l.match(/\\[(\\d+):(\\d+\\.\\d+)\\](.*)/);\\n  return m ? {start: +m[1]*60 + +m[2], text: m[3]} : null;\\n}).filter(Boolean);\\naudio.play();\\nconst ctx = document.querySelector('canvas').getContext('2d');\\nfunction draw(){\\n  const t = audio.currentTime;\\n  ctx.clearRect(0,0,canvas.width,canvas.height);\\n  ctx.font = 'bold 48px Sarabun';\\n  const active = lines.findIndex((l,i,arr) => t >= l.start && (i===arr.length-1 || t < arr[i+1].start));\\n  lines.forEach((l,i) => {\\n    ctx.fillStyle = i===active ? '#fde047' : '#64748b';\\n    ctx.fillText(l.text, 50, 100 + i*60);\\n  });\\n  requestAnimationFrame(draw);\\n}\\ndraw();",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://en.wikipedia.org/wiki/LRC_(file_format)",
          "pros": [
            "LRC format = universal (10+ tools export), แค่ [mm:ss.xx]text per line",
            "Click-to-set timing = กด spacebar ตอนฟัง + click line = sub-second timing precision",
            "Canvas overlay = render highlight color/bounce/glow ต่อ character (ไม่ใช่ทั้งบรรทัด)",
            "requestAnimationFrame + audio.currentTime = 60fps highlight scroll แม่นยำ",
            "WebM export via MediaRecorder + canvas.captureStream = no server, ~5MB/min 720p",
            "karaoke-studio 11 themes ใช้ pattern เดียวกัน (theme = JSON of CSS+canvas configs)"
          ],
          "cons": [
            "LRC ไม่มี syllable timing (ทั้งบรรทัดเปลี่ยนพร้อมกัน) — karaoke effect แท้ๆ ต้อง advanced LRC (Enhanced LRC / LRCv2)",
            "WebM export = Safari/iOS เปิดไม่ได้ (VP9) — ต้อง MP4 export แยก (ffmpeg.wasm)",
            "requestAnimationFrame หยุดเมื่อ tab background → timing drift (ใช้ AudioContext clock แทน)",
            "Subtitle 'bounce' effect ต้อง pre-compute keyframes (ไม่ใช่ random per frame)",
            "Vertical video (9:16 TikTok) export = re-author layout, WebM ใช้ 720x1280 ไม่ใช่ 1280x720",
            "Font loading race: โหลดฟอนต์ไม่เสร็จตอน render = fall back to default (text width เปลี่ยน)"
          ],
          "gotchas": [
            "LRC tag order: [ti:title][ar:artist][al:album][by:creator] ต้องมาก่อน [mm:ss.xx] แรก",
            "Enhanced LRC: [mm:ss.xx]<mm:ss.xx>word<mm:ss.xx>word สำหรับ per-word highlight",
            "iOS Safari: ไม่ render ฟอนต์ใน <canvas> ถ้า @font-face ยังโหลดไม่เสร็จ (document.fonts.ready await ก่อน)",
            "AudioContext clock (currentTime) ไม่ drift แม้ background tab — ใช้เป็น timing master",
            "WebM MediaRecorder bitrate 2500kbps = 5min = 95MB, ใช้ VP9 + Opus 1500kbps ได้คุณภาพใกล้เคียง",
            "LRC timestamp regex: \\[(\\d+):(\\d+\\.?\\d*)\\] capture min and sec, validate mm<60 ss<60",
            "Sync offset: LRC อาจ offset +200ms เทียบ audio — ให้ user ปรับ global offset slider",
            "Export: render ผ่าน captureStream 30fps = encoding ใช้ CPU 100% บน laptop, เตือน user"
          ],
          "anti_patterns": [
            "setInterval 16ms เพื่อ update highlight — drift 5s ใน 10 นาที, ใช้ rAF + audio clock",
            "ใช้ <video> + <track> WebVTT ทำ lyric video — WebVTT ไม่มี per-character style (CSS ::cue จำกัด)",
            "Pre-render ทุก frame เป็น <img> sequence — memory 1GB/30s, ใช้ canvas real-time render",
            "Convert LRC → JSON แล้วเก็บใน localStorage เป็น array — LRC string 50KB เปลี่ยน < 5KB JSON",
            "ใช้ CSS animation keyframes แทน rAF ใน WebM export — CSS anim ไม่ sync กับ audio clock"
          ],
          "prereqs": [
            "1.9",
            "6.1"
          ],
          "next_steps": [
            "6.7"
          ],
          "tags": [
            "lyric",
            "lrc",
            "เนื้อเพลง",
            "karaoke",
            "timing",
            "subtitle",
            "vtt"
          ],
          "compare_with": [
            "Aegisub (native, desktop only)",
            "Subtitle Edit (Windows only)",
            "Hand-rolled WebVTT (simpler but no per-word)"
          ]
        },
        {
          "id": "6.7",
          "title": "Karaoke Studio (Full)",
          "icon": "🎤",
          "description": "End-to-end: FFT beat detection (1024-pt) + 11 visual themes + per-word timing + MP4 export via ffmpeg.wasm. Reference: karaoke-studio.",
          "when_to_use": "Music creator publish karaoke video, social content (9:16 lyric clip), YouTube karaoke channel, karaoke-studio คือ reference implementation ของ pattern นี้",
          "when_not_to_use": "Live karaoke (need pitch detection, microphone real-time, scoring); album-grade mastering; pro DAW with VST",
          "examples": "karaoke-studio",
          "snippet": "// Detect beats: for(let i=2;i<windows-2;i++)if(rms[i]>max*.55&&rms[i]>avg*1.3&&(t-lastBeat)>0.2)peaks.push({t,a:rms[i]});",
          "snippet_full": "// karaoke-studio style: FFT beat detection + theme render + ffmpeg.wasm export\\nconst audio = new Audio('song.mp3');\\nconst audioCtx = new AudioContext();\\nconst src = audioCtx.createMediaElementSource(audio);\\nconst analyser = audioCtx.createAnalyser();\\nanalyser.fftSize = 1024;\\nsrc.connect(analyser); analyser.connect(audioCtx.destination);\\nconst data = new Uint8Array(analyser.frequencyBinCount);\\nconst beats = [];\\nlet lastBeat = 0;\\nconst THEME = {font:'Sarabun',accent:'#fde047',bg:'#0a0a14',ease:cubicOut};\\nfunction cubicOut(t){return 1-Math.pow(1-t,3);}\\naudio.play();\\nfunction loop(t){\\n  analyser.getByteFrequencyData(data);\\n  let rms=0; for(let v of data) rms+=v*v; rms=Math.sqrt(rms/data.length)/255;\\n  if (rms > 0.55 && t - lastBeat > 0.2) {\\n    beats.push({t, energy: rms}); lastBeat = t;\\n  }\\n  if (!audio.paused) requestAnimationFrame(loop);\\n}\\nrequestAnimationFrame(loop);",
          "difficulty": "Advanced",
          "time_to_learn": "1 week",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode",
          "pros": [
            "AnalyserNode FFT size 1024 = 43 beats/sec @ 44100Hz, balance precision vs CPU",
            "RMS threshold 0.55 + adaptive peak detection = beat sync ตามจังหวะเพลง 95%+",
            "11 themes = JSON config (font/colors/animation curve) swap = render pipeline เดียว",
            "ffmpeg.wasm MP4 export = H.264/AAC browser-side, ไฟล์เปิดได้ทุก platform (ต่างจาก WebM)",
            "Per-word timing เก็บใน Enhanced LRC = karaoke effect จริง (highlight เคลื่อนที่ตามคำร้อง)",
            "Export 1080p60 = ~1.5GB RAM, mobile อาจ crash — auto downscale 720p30 fallback"
          ],
          "cons": [
            "ffmpeg.wasm = 30MB download, first export รอ 5-10s ขณะ WASM compile + COOP/COEP headers",
            "FFT 1024 = bass ไม่แม่น (low freq resolution 21Hz) — ใช้ 2048 สำหรับ beat-heavy song",
            "Beat detection ผิดที่ genre ไม่ใช่ 4/4 (jazz, classical, rubato) — เพิ่ม onset detection fallback",
            "MP4 export 3 นาที = 60-90s render time บน M1, 5-8 นาทีบน low-end laptop",
            "Per-word timing ต้อง manual ใส่ (ไม่มี auto-align client-side แม่น) — Whisper API ช่วยได้",
            "Audio mixing = Web Audio destination → MediaRecorder = mute issue ถ้า tab background"
          ],
          "gotchas": [
            "SharedArrayBuffer: COOP: same-origin + COEP: require-corp header จำเป็นสำหรับ ffmpeg.wasm multi-thread",
            "FFT window: AnalyserNode.fftSize=1024, getByteFrequencyData ให้ 0-255 (map 0-1 RMS)",
            "RMS formula: sqrt(sum(sample^2)/N) — window 1024 sample overlap 50% = smoother beat",
            "Beat peak: rms[i] > max*0.55 AND rms[i] > localAvg*1.3 AND (i-prevBeat) > 0.2s (debounce)",
            "ffmpeg.wasm: -c:v libx264 -preset ultrafast -crf 23 -c:a aac -b:a 128k for browser MP4",
            "Canvas captureStream + MediaRecorder VP9 vs VP8 vs H.264 — เลือก H.264 ตอน export จริง (ffmpeg remux)",
            "AudioContext: createMediaStreamDestination() ต่อไป MediaRecorder + ffmpeg.wasm mux เข้าด้วยกัน",
            "Theme JSON: animation curve ease-out (cubic) = เนียนกว่า linear 5x, keyframe density 30/sec"
          ],
          "anti_patterns": [
            "Beat detection ด้วย zero-crossing — ใช้ energy/RMS + spectral flux ดีกว่า 10x",
            "MP3 export ผ่าน lamejs — iOS/Safari เปิดไฟล์ได้ แต่ social media (TikTok/IG) ต้อง MP4/H.264",
            "Manual frame-by-frame highlight เก็บ 30fps × 180s = 5400 entries ใน JSON — ใช้ onset times + ease interpolation",
            "Export WebM แล้ว expect upload to YouTube — YouTube รับ WebM แต่ compress แย่กว่า MP4",
            "ใช้ setInterval 16ms draw highlight — background tab หยุด, ใช้ AudioContext clock + rAF"
          ],
          "prereqs": [
            "6.6",
            "6.1",
            "1.9"
          ],
          "next_steps": [
            "6.10"
          ],
          "tags": [
            "karaoke",
            "beat",
            "fft",
            "lyric",
            "mp4",
            "ffmpeg",
            "export",
            "audio-analysis"
          ],
          "compare_with": [
            "Aegisub (native desktop)",
            "Karaoke Mug Editor (Windows)",
            "Vrew (AI auto-sub, closed source)"
          ]
        },
        {
          "id": "6.8",
          "title": "Subtitle Editor (VTT/SRT)",
          "icon": "💬",
          "description": "VTT/SRT editor + waveform sync + auto-time-stretch + bilingual. WebVTT for web, SRT for video file. Reference: sj88-video-editor-pro caption panel.",
          "when_to_use": "Course/tutorial (sj88-video-editor-pro), accessibility (a11y WCAG 2.1), multilingual video, social clip captions (TikTok/IG auto-import SRT)",
          "when_not_to_use": "Auto-transcription (Whisper API needed); live broadcast closed-captioning (real-time STT); translate API (DeepL/Google)",
          "examples": "sj88-video-editor-pro",
          "snippet": "WEBVTT\\n\\n1\\n00:00:00.000 --> 00:00:03.000\\nHello world\\n\\n2\\n00:00:03.500 --> 00:00:06.000 align:center line:80%\\nSecond line centered",
          "snippet_full": "// VTT/SRT dual-format editor (sj88-video-editor-pro style)\\nconst cues = [{id:1, start:0, end:3, text:'Hello world'}, {id:2, start:3.5, end:6, text:'Second line'}];\\nfunction toVTT(){let s='WEBVTT\\n\\n'; for(const c of cues) s+=`${c.id}\\n${fmt(c.start)} --> ${fmt(c.end)}\\n${c.text}\\n\\n`; return s;}\\nfunction toSRT(){let n=1,s=''; for(const c of cues){s+=`${n++}\\n${fmt(c.start,',')} --> ${fmt(c.end,',')}\\n${c.text}\\n\\n`;} return s;}\\nfunction fmt(sec, sep='.'){const h=Math.floor(sec/3600),m=Math.floor(sec%3600/60),sc=(sec%60).toFixed(3).padStart(6,'0'); return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${sc}`.replace('.', sep);}\\nconst vtt = new Blob([toVTT()], {type:'text/vtt'});\\nconst a = document.createElement('a'); a.href=URL.createObjectURL(vtt); a.download='subs.vtt'; a.click();",
          "difficulty": "Beginner",
          "time_to_learn": "2 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API",
          "pros": [
            "WebVTT = W3C standard, native <track> support, ::cue pseudo-class for styling",
            "SRT = 99% player support (VLC, mpv, OBS), human-readable 4-byte timestamp",
            "Waveform thumbnail strip = visual cue place (เหมือน audio editor แต่ cue-based)",
            "Bilingual stack: <track srclang='en' label='English'> + <track srclang='th' label='ไทย' default>",
            "Import SRT → VTT converter ใช้ regex 1-pass, เก็บ cue ID auto-generate",
            "Export ใช้ Blob + download attribute = ไม่ผ่าน server, GDPR-safe"
          ],
          "cons": [
            "VTT cue settings (line:80%, position:50%) browser-specific render — test ทุก target",
            "SRT 1-based index, VTT optional cue ID = conversion ง่ายแต่ round-trip มี gotcha",
            "Long lines > 42 chars wrap ไม่สวย — ใช้ VTT line:13, position:50% center ดีกว่า",
            "Frame-accurate timing ไม่มีใน VTT/SRT (HH:MM:SS.mmm only) — video editor ใช้ FCPXML แทน",
            "Right-to-left (Arabic/Hebrew) ต้อง mark <c.langdir='rtl'> + reverse word order (FFmpeg ช่วย)",
            "Tag escape: VTT ห้ามมี '<' '>' '&' ภายใน cue text ต้อง &lt; &gt; &amp;"
          ],
          "gotchas": [
            "VTT ต้องขึ้นต้นด้วย 'WEBVTT' + บรรทัดว่าง (header parser strict)",
            "Timestamp format VTT: HH:MM:SS.mmm (dot ไม่ใช่ comma), SRT: HH:MM:SS,mmm (comma)",
            "Cue settings: align:start|center|end, line:0-100%, position:0-100%, size:0-100%",
            "VTT ไม่มี cue ID mandatory แต่ถ้าใส่ ต้อง unique ต่อ file (ใช้เป็น anchor ตอน update)",
            "Default track: <track ... default> = browser เลือกอัตโนมัติ, ถ้ามีหลาย default = เอาอันแรก",
            "srclang ต้องเป็น BCP 47 ('en', 'th', 'pt-BR') ไม่ใช่ display name",
            "Cue ซ้อนทับกัน: VTT render ทั้งคู่ (player ไม่ enforce non-overlap), SRT ก็เหมือนกัน",
            "Subtitle timing: 1.5-7s per cue (a11y guideline), 42 char/line (display) — editor ต้อง warn"
          ],
          "anti_patterns": [
            "Hardcode subtitle ใน video timeline text layer — export MP4 embed, search/edit ไม่ได้",
            "Convert VTT → SRT ด้วย .replace(',', '.') เฉยๆ — SRT ใช้ comma, ไม่ใช่ dot, ผิด format",
            "ใช้ setTimeout แสดง subtitle แทน <track> — drift 5s ใน 10 นาที, <track> sync กับ media time",
            "Store subtitle ใน textarea เดียว — structured data (cues array) edit/undo ได้ดีกว่า 10x",
            "Ignore newline ใน VTT (\\n) — display เป็น space ถ้าไม่ใส่ '\\n' explicit"
          ],
          "prereqs": [
            "1.3"
          ],
          "next_steps": [
            "6.3",
            "6.6"
          ],
          "tags": [
            "subtitle",
            "vtt",
            "srt",
            "caption",
            "คำบรรยาย",
            "webvtt",
            "a11y"
          ],
          "compare_with": [
            "Subtitle Edit (Windows native)",
            "Aegisub (advanced ASS format)",
            "Amara (web collab, paid)"
          ]
        },
        {
          "id": "6.9",
          "title": "Meme Generator",
          "icon": "😂",
          "description": "Template picker + top/bottom Impact text + stroke + center-wrap + share. <50KB, no server. Reference: sj88cute-frame-extractor style.",
          "when_to_use": "Social media share, internal comms, education example (captioning), viral marketing, drag-drop meme studio",
          "when_not_to_use": "Brand-safe design (Canva/Figma); video/GIF meme (use sj88-video-editor-pro); AI meme generator (DALL-E/MJ API)",
          "examples": "sj88cute-frame-extractor, sj88-video-editor-pro",
          "snippet": "ctx.font=`bold ${h*.08}px Impact, 'Arial Black', sans-serif`; ctx.textAlign='center'; ctx.strokeText(txt,x,y); ctx.fillText(txt,x,y);",
          "snippet_full": "// Meme generator: top/bottom Impact text with stroke + auto-wrap\\nconst cvs = document.querySelector('canvas');\\nconst ctx = cvs.getContext('2d');\\nconst img = new Image(); img.src = 'drake-template.jpg';\\nawait img.decode();\\ncvs.width = img.width; cvs.height = img.height;\\nctx.drawImage(img, 0, 0);\\nconst top = 'When user says'; const bottom = 'It works on my machine';\\nfunction drawLine(lines, y, size){\\n  ctx.font = `bold ${size}px Impact, 'Arial Black', sans-serif`;\\n  ctx.textAlign = 'center';\\n  ctx.lineWidth = size*0.08; ctx.strokeStyle = 'black'; ctx.fillStyle = 'white';\\n  lines.forEach((line,i) => { const yy = y + i*size*1.1; ctx.strokeText(line, cvs.width/2, yy); ctx.fillText(line, cvs.width/2, yy); });\\n}\\nfunction wrap(t, maxW, size){\\n  ctx.font = `bold ${size}px Impact`;\\n  const words = t.split(' '); const lines = []; let cur='';\\n  for (const w of words) { const test = cur ? cur+' '+w : w; if (ctx.measureText(test).width > maxW) { lines.push(cur); cur=w; } else cur=test; }\\n  if (cur) lines.push(cur); return lines;\\n}\\nconst size = cvs.height*0.08;\\ndrawLine(wrap(top, cvs.width*0.9, size), size*0.9, size);\\ndrawLine(wrap(bottom, cvs.width*0.9, size), cvs.height - size*0.3*(wrap(bottom, cvs.width*0.9, size).length-1), size);\\ncvs.toBlob(b => navigator.share ? navigator.share({files:[new File([b],'meme.png',{type:'image/png'})]}) : downloadBlob(b,'meme.png'), 'image/png');",
          "difficulty": "Beginner",
          "time_to_learn": "1 hour",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeText",
          "pros": [
            "Impact font + black stroke = 95% readability บนทุก background (classic meme convention)",
            "Canvas text wrapping (measureText + manual break) = center per-line ไม่ใช่ left-aligned",
            "Text uppercasing 'hello' → 'HELLO' (meme style) = single CSS text-transform",
            "Web Share API navigator.share() = mobile native share sheet (FB/IG/Twitter 1 tap)",
            "toBlob('image/png') = ไฟล์ lossless, social media upload quality เต็ม",
            "Template library: 20 popular meme template เก็บเป็น base64 = offline ready"
          ],
          "cons": [
            "Impact font ไม่มีบน Linux/Android (fallback Arial Black) — embed @font-face ถ้าจริงจัง",
            "Canvas text baseline = alphabetic (descender 'g' ตัด) — ใช้ 'middle' baseline แล้ว offset y",
            "Long text wrap by word = 1 line จะเล็กกว่า 5 lines (font size = canvas.height/numLines)",
            "Copyright: ใช้ template ที่ไม่ใช่ IP (Drake meme ok, Disney character = infringement)",
            "EXIF strip: meme ที่ upload จาก iPhone มี GPS — canvas re-render strip metadata แต่ watermark risk",
            "Dark mode preview: meme ใน dark feed จะจางลง (Impact+black stroke ยังอ่านได้)"
          ],
          "gotchas": [
            "ctx.strokeText ก่อน ctx.fillText (ไม่งั้น stroke ทับ fill = readable แต่ ugly)",
            "strokeWidth = fontSize × 0.08 (4px stroke สำหรับ 50px font) — vary ตาม view port",
            "Multi-line wrap: วน split words, measureText accumulate, เมื่อ width > maxWidth → \\n",
            "Font load delay: document.fonts.ready await ก่อน render ไม่งั้น fallback font = wrong layout",
            "Image source: <input type='file' accept='image/*'> + URL.createObjectURL ห้ามลืม revoke",
            "Twitter share: navigator.canShare เช็คก่อน (iOS Safari 14+ only), fallback download + manual share",
            "Top text y position = fontSize × 0.85 (ไม่ใช่ 0) เพราะ visual baseline = ขอบล่างของ capital",
            "Bottom text y = canvas.height - 10 (10px padding ล่าง) — centered per line by font baseline"
          ],
          "anti_patterns": [
            "ใช้ CSS text-shadow แทน canvas stroke — export แล้วหาย (CSS = render only, not baked)",
            "Hardcode font 'Impact' บน Linux = fallback ugly — embed base64 TTF หรือ document.fonts.load check",
            "Top/bottom text = 1 string เสมอ — render line-by-line wrap, ไม่ใช่ multiline string",
            "Image source = base64 in URL → memory leak — Blob URL + URL.revokeObjectURL หลัง done",
            "No watermark/source attribution = โดน claim copyright จาก template owner (Drake Hot Court)"
          ],
          "prereqs": [
            "1.6"
          ],
          "next_steps": [
            "6.10"
          ],
          "tags": [
            "meme",
            "generator",
            "มีม",
            "viral",
            "impact",
            "social",
            "share"
          ],
          "compare_with": [
            "imgflip (web, ad-supported)",
            "Canva (full editor, $)",
            "Kapwing (video meme, paid)"
          ]
        },
        {
          "id": "6.10",
          "title": "Screen Recorder",
          "icon": "🖥️",
          "description": "getDisplayMedia + MediaRecorder (WebM VP9/Opus) + mic mixing via Web Audio. Reference: sj88-video-editor-pro recorder, sj88cute-frame-extractor capture flow.",
          "when_to_use": "Tutorial/demo recording, bug report capture, internal training, social clip (sj88-video-editor-pro, sj88cute-frame-extractor ใช้ pattern เดียวกัน), product walkthrough",
          "when_not_to_use": "Game capture > 60fps (OBS-native, hardware enc); facecam+game composite พร้อม overlay (OBS/Streamlabs); long-form > 1hr recording (memory)",
          "examples": "sj88-video-editor-pro, sj88cute-frame-extractor",
          "snippet": "const stream=await navigator.mediaDevices.getDisplayMedia({video:{frameRate:30},audio:true}); const rec=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp9,opus',videoBitsPerSecond:2_500_000});",
          "snippet_full": "// Screen + mic + system audio mixed recorder (sj88-video-editor-pro style)\\nconst screen = await navigator.mediaDevices.getDisplayMedia({video:{frameRate:30,width:1920},audio:true});\\nconst mic = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}});\\nconst audioCtx = new AudioContext();\\nconst mixed = audioCtx.createMediaStreamDestination();\\naudioCtx.createMediaStreamSource(mic.getAudioTracks()[0]).connect(mixed);\\nif (screen.getAudioTracks()[0]) audioCtx.createMediaStreamSource(screen.getAudioTracks()[0]).connect(mixed);\\nconst out = new MediaStream([...screen.getVideoTracks(), mixed.stream.getAudioTracks()[0]]);\\nconst chunks = [];\\nconst rec = new MediaRecorder(out, {mimeType:'video/webm;codecs=vp9,opus', videoBitsPerSecond:2_500_000, audioBitsPerSecond:128_000});\\nrec.ondataavailable = e => chunks.push(e.data);\\nrec.onstop = () => downloadBlob(new Blob(chunks, {type:'video/webm'}), `rec-${Date.now()}.webm`);\\nrec.start(1000);\\nscreen.getVideoTracks()[0].onended = () => rec.stop();",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Screen_Capture_API",
          "pros": [
            "navigator.mediaDevices.getDisplayMedia() = 1 call ได้ screen + system audio (Chrome/Edge)",
            "MediaRecorder + VP9 codec = 1 hour recording ≈ 1.5GB, เล่น Chrome/Firefox ได้ทันที",
            "Audio mixing: AudioContext.createMediaStreamDestination() mix mic + system → record 1 track",
            "Web Speech API caption realtime overlay (browser built-in, no API key)",
            "Picture-in-Picture recording = tab-only capture (Safari ได้, Chrome ตั้งแต่ 104)",
            "Pause/Resume = MediaRecorder.pause()/resume() ไม่สร้าง file ใหม่ (single Blob)"
          ],
          "cons": [
            "Safari getDisplayMedia = tab only (ไม่ได้ full screen) ก่อน 16.4, ตอนนี้ full screen ได้",
            "System audio (Chrome): ต้อง check 'Share tab audio' ตอนเลือก source — ไม่มี API enforce",
            "Firefox ไม่ support system audio เลย (ต้อง mic only)",
            "iOS Safari: ไม่รองรับ getDisplayMedia (ไม่มี screen capture extension)",
            "WebM file ใหญ่ 720p30 1hr ≈ 2.5GB VP9, MP4 export ต้อง ffmpeg.wasm (30MB)",
            "Memory: 1 hour recording = 2-3GB RAM (VideoEncoder + audio buffer pool)"
          ],
          "gotchas": [
            "getDisplayMedia = Promise ต้อง user gesture (button click) — ไม่งั้น NotAllowedError",
            "videoBitsPerSecond: 2_500_000 (2.5Mbps) เป็น sweet spot — 5Mbps ไฟล์ใหญ่เกิน, 1Mbps เห็น artifact",
            "audioBitsPerSecond: 128_000 Opus = podcast quality, 64_000 speech-only = OK",
            "mimeType 'video/webm;codecs=vp9,opus' ตรวจ MediaRecorder.isTypeSupported ก่อนใช้ (fallback vp8)",
            "Mic mixing: getUserMedia({audio:true}) → createMediaStreamSource → destination stream",
            "Two stream: screen video + mixed audio → MediaRecorder รับได้แค่ 1 stream ต่อไป ต้อง addTrack merge",
            "Pause/Resume ระหว่าง recording: dataavailable event ต้อง save chunk แยก, concat ตอน stop",
            "Permission re-request: navigator.permissions.query({name:'camera'}) cache ไม่ได้, user ต้อง allow ใหม่ทุก navigation"
          ],
          "anti_patterns": [
            "Record เป็น data array สะสมใน memory — 1 hour = 2GB array, ใช้ stream + chunks เขียน disk incremental",
            "getUserMedia({audio:true}) เพื่อ system audio — Chrome getDisplayMedia({audio:true}) เท่านั้นที่ได้ tab audio",
            "WebM upload ตรงเข้า YouTube — YouTube รับ แต่ transcode 2x, MP4 เข้า transcode ครั้งเดียว",
            "Bind record start บน page load — NotAllowedError ทันที, ต้อง user click handler",
            "ไม่ handle 'ended' event ของ track — user click 'Stop sharing' ใน Chrome UI = MediaRecorder stop อัตโนมัติ, blob empty"
          ],
          "prereqs": [
            "1.9"
          ],
          "next_steps": [
            "6.3",
            "6.8"
          ],
          "tags": [
            "screen",
            "record",
            "อัดหน้าจอ",
            "getdisplaymedia",
            "mediarecorder",
            "tutorial",
            "webm"
          ],
          "compare_with": [
            "OBS Studio (native, scene+overlay)",
            "Loom (cloud, paid)",
            "Vimeo Record (web, paid)"
          ]
        }
      ]
    },
    {
      "id": 7,
      "name": "AI Skills",
      "icon": "🤖",
      "tagline": "OpenAI + embeddings + RAG",
      "sub_phases": [
        {
          "id": "7.1",
          "title": "OpenAI API (Chat/Completion/Streaming)",
          "icon": "🧠",
          "description": "GPT-4o / GPT-4o-mini via fetch + SSE stream. The default brain for any CodeHub AI app.",
          "when_to_use": "chatbot/memory agent (memo-ai), text summarizer, generator, classifier, JSON-extractor. ใช้เมื่อ task ต้องการ SOTA reasoning หรือ multi-step planning ที่ rule-based ทำไม่ได้",
          "when_not_to_use": "< 500ms hard latency; no API key; cost-sensitive (>10M tokens/mo) — try TF-IDF (7.5) or local LLM first; deterministic math (use code interpreter)",
          "examples": "memo-ai, mavis-assistant, ai-data-analyzer, why-minimax",
          "snippet": "const r=await fetch('https://api.openai.com/v1/chat/completions',{method:'POST',headers:{'Authorization':'Bearer '+KEY,'Content-Type':'application/json'},body:JSON.stringify({model:'gpt-4o-mini',messages:[{role:'user',content:q}],stream:true})});",
          "snippet_full": "// Server-side proxy (NEVER expose sk- to browser)\\nimport OpenAI from 'openai';\\nconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\\n\\nexport async function POST(req) {\\n  const { messages, model = 'gpt-4o-mini' } = await req.json();\\n  const stream = await openai.chat.completions.create({\\n    model, messages, stream: true, temperature: 0.7,\\n    max_tokens: 1000,\\n    response_format: { type: 'json_object' } // forces valid JSON\\n  });\\n  const encoder = new TextEncoder();\\n  const readable = new ReadableStream({\\n    async start(controller) {\\n      for await (const chunk of stream) {\\n        const delta = chunk.choices[0]?.delta?.content || '';\\n        controller.enqueue(encoder.encode(`data: ${JSON.stringify({t:delta})}\\n\\n`));\\n      }\\n      controller.enqueue(encoder.encode('data: [DONE]\\n\\n'));\\n      controller.close();\\n    }\\n  });\\n  return new Response(readable, { headers: { 'Content-Type': 'text/event-stream' } });\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://platform.openai.com/docs/api-reference/chat",
          "pros": [
            "gpt-4o-mini at $0.15/M input is ~15x cheaper than gpt-4o and handles 95% of use cases (summarize, classify, draft, translate)",
            "Streaming via SSE cuts TTFB from ~8s to ~0.4s — perceived speed is what makes chat UIs feel alive",
            "Function calling (sub 7.7) turns chat into action — AI can hit YOUR DB, send email, book a slot",
            "JSON-mode (`response_format:{type:'json_object'}`) eliminates 90% of parsing errors vs regex on prose",
            "128k context window on gpt-4o means you can stuff 5+ chapters of docs in one prompt for analysis",
            "OpenAI SDK is a thin fetch wrapper — no vendor lock-in if you wrap behind your own `ai.complete()` interface"
          ],
          "cons": [
            "Cost compounds: a 10-turn chat with 4k context each is ~40k tokens = $0.006 on mini, $0.10 on 4o (16x more)",
            "Network round-trip 200-800ms per call (excluding generation time) — chatty agents will feel sluggish",
            "Rate limits hit hard at scale: Tier 1 is 500 RPM / 30k TPM — a runaway loop can lock you out for 60s",
            "Non-deterministic: same prompt can give different outputs (seed helps but doesn't guarantee identical)",
            "API can be down 0.1-1% of the time; you NEED a fallback (cached response, static reply, or queued retry)",
            "Reasoning models (o1/o3) are 10-100x slower and more expensive — only use for hard math/code, not chat"
          ],
          "gotchas": [
            "API key in browser = instant leak via DevTools or extension — always proxy through YOUR server, never expose `sk-` to client",
            "Streaming with `stream:true` returns newline-delimited `data: {...}` chunks — split on `\\n\\n` not `\\n` (each SSE event is double-newline terminated)",
            "`max_tokens` is a HARD cap including the stop reason — set to 100 for classifiers, 800-2000 for chat, 4000+ for long-form",
            "System prompt counts toward `max_tokens` — for 128k models you have room, but on older 4k/8k models a 2k system prompt leaves nothing for output",
            "GPT-4o has knowledge cutoff Oct 2023 — for recent events you MUST pass context (RAG sub 7.8) or it'll confidently hallucinate",
            "Temperature 0 ≠ deterministic — it's 'most likely' sampling; for true determinism use seed + `top_p=0` AND cache results",
            "Tool-call JSON in response can have wrong types (string vs number) — always validate with Zod/Pydantic before passing to your function",
            "Embeddings (sub 7.4) and chat completions are SEPARATE endpoints with SEPARATE rate limit buckets — bumping one doesn't help the other"
          ],
          "anti_patterns": [
            "Calling OpenAI from the browser with the API key in `localStorage` or hardcoded — leaks in 24h to scrapers",
            "Building a 'do-everything' mega-prompt that asks for JSON + translation + summary + sentiment — break into 2-3 calls, each is cheaper and more accurate",
            "Using gpt-4o when gpt-4o-mini suffices (3-line prompt, simple classification) — wastes 15x cost for no quality gain",
            "No retry/backoff on 429/500/503 — a 60s `setTimeout` retry on 429 with exponential backoff is mandatory for production",
            "Storing full chat history in every new request without summarization — at 50 turns you'll blow past context and pay 10x more than needed"
          ],
          "prereqs": [
            "1.7"
          ],
          "next_steps": [
            "7.4",
            "7.6",
            "7.7",
            "7.9"
          ],
          "tags": [
            "ai",
            "openai",
            "gpt",
            "llm",
            "chatgpt",
            "สมอง"
          ],
          "compare_with": [
            "Anthropic Claude (longer context, better code)",
            "Google Gemini (free tier, vision)",
            "Local Ollama (no API cost, slower)"
          ]
        },
        {
          "id": "7.2",
          "title": "Web Speech API (TTS/STT)",
          "icon": "🗣️",
          "description": "Browser-native TTS (`speechSynthesis`) + STT (`SpeechRecognition`). Zero API cost, gesture-required, varies by browser.",
          "when_to_use": "voice UI for a11y, prototype Thai/English voice features, offline narration (knowledge-garden reading mode), no-budget MVPs. ใช้เมื่อต้องการ TTS/STT ฟรีและเร็วโดยไม่ต้องเรียก OpenAI",
          "when_not_to_use": "production voice cloning (use ElevenLabs), cross-browser identical voice (Safari/Firefox voices differ), high-accuracy Thai STT (browser STT is 70-80% WER vs 95%+ for cloud), server-side processing (Web Speech is client-only)",
          "examples": "svj-thai-voice-factory, knowledge-garden, karaoke-studio, memo-ai",
          "snippet": "const u=new SpeechSynthesisUtterance(text); speechSynthesis.speak(u); const rec=new (window.SpeechRecognition||window.webkitSpeechRecognition)(); rec.onresult=e=>console.log(e.results[0][0].transcript);",
          "snippet_full": "// Production STT+TTS with Thai support, gesture-safe, cross-browser\\nconst SR = window.SpeechRecognition || window.webkitSpeechRecognition;\\nlet rec, listening = false;\\n\\nexport function startListen(onText, onPartial, lang = 'th-TH') {\\n  if (!SR) { onText?.('__UNSUPPORTED__'); return; }\\n  rec = new SR();\\n  rec.lang = lang;\\n  rec.continuous = true;\\n  rec.interimResults = true;\\n  rec.onresult = e => {\\n    const last = e.results[e.results.length - 1];\\n    const txt = last[0].transcript;\\n    last.isFinal ? onText?.(txt) : onPartial?.(txt);\\n  };\\n  rec.onerror = e => { if (e.error === 'not-allowed') alert('กรุณาอนุญาตไมโครโฟน'); };\\n  rec.onend = () => listening && rec.start(); // auto-restart\\n  rec.start();\\n  listening = true;\\n}\\n\\nexport function speak(text, lang = 'th-TH', rate = 1) {\\n  if (!('speechSynthesis' in window)) return;\\n  speechSynthesis.cancel(); // stop current\\n  const chunks = text.match(/.{1,150}[.!?]?\\s*/g) || [text];\\n  chunks.forEach(c => {\\n    const u = new SpeechSynthesisUtterance(c.trim());\\n    u.lang = lang; u.rate = rate;\\n    speechSynthesis.speak(u);\\n  });\\n}",
          "difficulty": "Beginner",
          "time_to_learn": "2 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API",
          "pros": [
            "Zero API cost — `speechSynthesis.speak()` is a one-liner with no auth, no quota, no latency to a remote server",
            "Offline-capable on Chrome with downloaded voices — works on airplanes, behind firewalls, with no internet",
            "Privacy-preserving: STT audio never leaves the device — ideal for medical/legal note-taking (svj-thai-voice-factory pattern)",
            "A11y win: screen-reader users expect TTS, and self-voicing controls (volume/rate) need almost no code",
            "Cross-platform: works on every modern browser + iOS Safari (with `webkitSpeechRecognition` prefix)",
            "No server load: 1000 concurrent users TTS'ing = 0 backend cost, the browser does the work"
          ],
          "cons": [
            "Voice quality is robotic vs cloud TTS (ElevenLabs, OpenAI TTS) — user testing shows 60% prefer cloud for long-form listening",
            "STT accuracy on Thai is ~70-80% WER (Word Error Rate) vs cloud at 95%+ — many 'voice input' UIs feel broken in production",
            "Voice selection is non-deterministic: same code on Chrome Windows picks Microsoft Zira, on Mac picks Samantha — UX will drift",
            "STT requires user gesture in Chromium (click-to-start) — can't auto-listen on page load (privacy feature, but limits hands-free flows)",
            "No server-side access: Web Speech is browser-only, so a Node.js background job that needs STT must use a different API",
            "Firefox lacks STT entirely (only TTS) — graceful degradation is REQUIRED, not optional"
          ],
          "gotchas": [
            "Safari uses `webkitSpeechRecognition` and `webkitSpeechRecognitionError` — always check `window.SpeechRecognition || window.webkitSpeechRecognition` and wrap in try/catch",
            "First call to `speechSynthesis.speak()` on Chrome is silent for ~100ms while voices load — pre-load by calling `getVoices()` on `voiceschanged` event",
            "Thai STT in Chrome requires `lang='th-TH'` AND a Thai voice pack installed in OS — silent failure if missing, no error thrown",
            "Long text (>150 chars) gets cut off on iOS Safari — chunk into 2-3 utterances with `boundary` event between them",
            "`recognition.continuous = true` on Chrome will auto-stop after ~60s of silence — re-start in `onend` for a 'always listening' UX",
            "iOS Safari pauses TTS when phone locks or app backgrounds — add `onerror` handler and resume on `visibilitychange`",
            "Microphone permission revoked = `recognition.onerror({error:'not-allowed'})` — show a clear 'enable mic' CTA, not a generic 'failed'",
            "STT `interimResults = true` gives you live partials (great for 'user is saying X' UI) but final result may differ — always show '...', then commit"
          ],
          "anti_patterns": [
            "Assuming the same voice across browsers/sessions — test on Chrome Win, Chrome Mac, Safari iOS, Firefox separately, every time",
            "Using Web Speech for production Thai dictation (medical, legal) — switch to OpenAI Whisper or Google Cloud Speech for 95%+ accuracy",
            "TTS'ing full articles without pause/chunking — long text stalls iOS; split at sentence boundaries with `split(/[.!?]\\s+/)`",
            "No fallback when STT unsupported (Firefox, old Edge) — check `if (!('webkitSpeechRecognition' in window)) showFallbackInput()`",
            "Calling `recognition.start()` in `useEffect` without user gesture — Chrome blocks silently; always trigger from click"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "7.3",
            "7.6"
          ],
          "tags": [
            "voice",
            "tts",
            "stt",
            "speech",
            "เสียง",
            "พูด",
            "thai",
            "ไทย"
          ],
          "compare_with": [
            "OpenAI Whisper (server STT, 95%+ accuracy)",
            "ElevenLabs (premium TTS, voice cloning)",
            "Google Cloud Speech (production STT)"
          ]
        },
        {
          "id": "7.3",
          "title": "Vision API (gpt-4o Vision / Gemini)",
          "icon": "👁️",
          "description": "Image understanding via multimodal LLMs. gpt-4o sees 1024+ px images, returns structured descriptions, OCR, or classification.",
          "when_to_use": "bg-remover prompts, cutout-studio caption, document scanner (receipt → JSON), product photo tagging, accessibility (alt-text at scale). ใช้เมื่อต้อง OCR/general-vision ที่ rule-based (template match) ทำไม่ได้",
          "when_not_to_use": "real-time video (use sub 7.1 with frame sampling); high-privacy images of faces/IDs (data leaves your server); production-grade OCR for invoices (use Google Document AI, AWS Textract — half the error rate); single-color segmentation (use CLIP/segment-anything)",
          "examples": "bg-remover, cutout-studio, sj88cute-heatmap, ai-data-analyzer",
          "snippet": "const r=await openai.chat.completions.create({model:'gpt-4o',messages:[{role:'user',content:[{type:'text',text:'Describe'},{type:'image_url',image_url:{url}}]}]});",
          "snippet_full": "// Production vision call: resize, base64, structured output, retry\\nasync function analyzeImage(file, prompt) {\\n  // 1. Resize to <1024px longest side\\n  const img = await createImageBitmap(file);\\n  const scale = Math.min(1, 1024 / Math.max(img.width, img.height));\\n  const w = Math.round(img.width * scale), h = Math.round(img.height * scale);\\n  const canvas = new OffscreenCanvas(w, h);\\n  canvas.getContext('2d').drawImage(img, 0, 0, w, h);\\n  const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 });\\n  const b64 = await blobToBase64(blob);\\n\\n  // 2. Call with detail:low unless high-res needed\\n  const r = await fetch('https://api.openai.com/v1/chat/completions', {\\n    method: 'POST',\\n    headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' },\\n    body: JSON.stringify({\\n      model: 'gpt-4o-mini',\\n      messages: [{\\n        role: 'user',\\n        content: [\\n          { type: 'text', text: prompt },\\n          { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${b64}`, detail: 'low' } }\\n        ]\\n      }],\\n      response_format: { type: 'json_object' },\\n      max_tokens: 500\\n    })\\n  });\\n  return JSON.parse((await r.json()).choices[0].message.content);\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://platform.openai.com/docs/guides/vision",
          "pros": [
            "Zero training: gpt-4o-vision handles OCR, object detection, scene description, color/style analysis out of the box",
            "One endpoint, many tasks: same `image_url` input works for 'describe', 'extract JSON', 'classify into X/Y/Z', 'count objects'",
            "Handles 1024x1024 to 4096x4096 images natively (auto-tiles larger) — no preprocessing pipeline needed for typical photos",
            "Cheap for batch: $0.00255/image (gpt-4o-mini, low-res) → $0.0115/image (gpt-4o, high-res) — process 10k receipts for ~$115",
            "Multilingual OCR: Thai, Japanese, Arabic, handwritten — single call, no per-language model swap",
            "Returns rich JSON when you prompt right: `[{label:'product',confidence:0.9,box:[x,y,w,h]}]` instead of just text"
          ],
          "cons": [
            "High-res images explode tokens: 4096x4096 ≈ 13k tokens = $0.033/image on gpt-4o (vs $0.001 on mini) — always resize first",
            "Latency: 2-8s per image (vs 200ms for classification) — bad UX for live camera feeds without aggressive caching",
            "Hallucinates text/numbers on low-quality images — won't say 'I can't read this', will confidently give wrong digits",
            "No bounding box output (unlike YOLO/Detectron) — to localize objects you need a second call or post-processing",
            "Privacy/data residency: image leaves your server → OpenAI's GPU — won't pass HIPAA/medical or EU GDPR-Sovereign requirements",
            "Rate limits are TOUGH on vision: 30k tokens/min means ~2 large images/sec, not the 500 RPM chat limit"
          ],
          "gotchas": [
            "Always resize to <1024px longest side before sending — cuts cost 10x with 0% quality loss for most tasks; use `canvas.toBlob({quality:0.8})`",
            "Send image as `data:image/jpeg;base64,...` URL when <100KB, or upload to your CDN and send URL — base64 has 33% size overhead",
            "`detail:'low'` is 85 tokens fixed (great for 'is there a dog?' yes/no), `detail:'high'` auto-tiles — pick explicitly, don't default to auto",
            "GPT-4o Vision is a CHAT endpoint — same rate limit bucket as text — vision traffic will starve your text traffic if you don't separate keys",
            "Thai handwritten text recognition is ~80% accurate vs 99% for printed — for forms consider a dedicated Thai OCR model (Baidu/Google) first",
            "Always include image in SECOND message, not first — first message is system context; put text instruction in system, image in user",
            "Gemini 1.5 Pro processes 1M context — can analyze 100s of images in one prompt for video understanding, but $$ adds up",
            "Pre-process: remove EXIF (privacy), strip alpha channel (PNG→JPEG), grayscale if color doesn't matter — 50% size reduction"
          ],
          "anti_patterns": [
            "Sending full 4K camera frames at 30fps to vision API — bankrupts you in minutes; sample 1 fps, resize to 512px, use `detail:'low'`",
            "Trusting vision output without spot-check — 1-5% hallucination rate on numbers/IDs means manual verify step is mandatory for financial/medical",
            "Using gpt-4o-vision for tasks a $0 free model handles (Tesseract.js for printed English text, MediaPipe for face detection)",
            "Storing full base64 image in chat history context — blows past 128k after 30 images; reference URL or summary instead",
            "Forgetting to set `max_tokens` — vision responses can ramble; cap at 500 tokens for JSON extraction, 2000 for descriptions"
          ],
          "prereqs": [
            "7.1"
          ],
          "next_steps": [
            "7.4",
            "7.7",
            "7.8"
          ],
          "tags": [
            "vision",
            "image",
            "ocr",
            "gpt-4o",
            "multimodal",
            "รูปภาพ"
          ],
          "compare_with": [
            "Google Document AI (specialized OCR)",
            "AWS Textract (forms/tables)",
            "YOLOv8 (self-hosted detection)",
            "CLIP (zero-shot classification)"
          ]
        },
        {
          "id": "7.4",
          "title": "Embeddings + Vector Search",
          "icon": "🧮",
          "description": "Text → 1536-dim float vectors (text-embedding-3-small). Cosine similarity finds semantically similar docs, not just keyword matches.",
          "when_to_use": "semantic search ('how to fix wifi' finds 'router reset steps'), recommendation, dedup, RAG retrieval (sub 7.8), clustering. ใช้เมื่อ keyword search ไม่เจอเพราะ user พูดคนละคำกับ doc",
          "when_not_to_use": "exact match (use SQL LIKE/=); structured filters (use SQL WHERE); corpus < 100 docs (TF-IDF sub 7.5 is free + faster); multilingual when all docs are in one language and you can stem/lemmatize",
          "examples": "memo-ai, quick-note, knowledge-garden, codehub-search",
          "snippet": "const r=await openai.embeddings.create({input:text,model:'text-embedding-3-small'}); const cos=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)/(mag(a)*mag(b));",
          "snippet_full": "// Production embeddings: batch, normalize, store with metadata\\nimport { openai } from './client.js';\\nimport { Pool } from 'pg';\\nconst db = new Pool({ connectionString: process.env.DATABASE_URL });\\n\\nexport async function embedBatch(texts, model = 'text-embedding-3-small') {\\n  // OpenAI accepts up to 2048 inputs per call\\n  const BATCH = 100;\\n  const all = [];\\n  for (let i = 0; i < texts.length; i += BATCH) {\\n    const batch = texts.slice(i, i + BATCH);\\n    const r = await openai.embeddings.create({ input: batch, model });\\n    all.push(...r.data.map(d => d.embedding)); // already normalized\\n    if (i + BATCH < texts.length) await new Promise(r => setTimeout(r, 50)); // rate limit\\n  }\\n  return all;\\n}\\n\\nexport async function searchSimilar(query, k = 5, filter = {}) {\\n  const [qEmb] = await embedBatch([query]);\\n  const { rows } = await db.query(`\\n    SELECT id, text, metadata, 1 - (embedding <=> $1) AS sim\\n    FROM docs\\n    WHERE metadata @> $2\\n    ORDER BY embedding <=> $1\\n    LIMIT $3\\n  `, [`[${qEmb.join(',')}]`, filter, k]);\\n  return rows;\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "5 hours",
          "docs_url": "https://platform.openai.com/docs/guides/embeddings",
          "pros": [
            "Semantic: 'car' matches 'vehicle', 'auto', 'รถยนต์' — no synonym lists to maintain, the model learned it",
            "text-embedding-3-small at $0.02/M tokens = $0.02 per 1k chunks of 500 tokens — cheaper than one RAG query's LLM call",
            "Multilingual out of box: 100+ languages including Thai (CLIP-style training) — embed Thai + English in same vector space",
            "1536-dim dense vector captures nuance a TF-IDF sparse vector misses (sentiment, intent, abstract concepts)",
            "Pre-compute once at index time, search at query time in O(log n) with HNSW — 1M vectors search in <10ms",
            "Combine with metadata filters: `WHERE category='receipts' AND embedding <-> query_emb < 0.3` for hybrid search"
          ],
          "cons": [
            "Storage: 1M docs × 1536 floats × 4 bytes = 6 GB just for vectors — pgvector is fine to 10M, then Pinecone/Qdrant",
            "No exact matching: 'error code E1234' won't rank a doc containing 'E1234' higher than a doc with synonyms (need hybrid)",
            "Re-embedding is expensive: changing models means re-embed ALL docs (hours + $) — pin your model version forever",
            "Cosine ≠ relevance: two unrelated docs can have high cosine if they share style/tone; add cross-encoder re-rank (sub 7.8)",
            "Cold-start latency: first embed call takes 200-500ms (model warm-up), subsequent calls are 50-100ms",
            "No 'why this matched' explanation: a 0.92 cosine score is a black box — users need to trust it or you need to add citations"
          ],
          "gotchas": [
            "ALWAYS normalize vectors before cosine — OpenAI embeddings are already normalized but if you mix models, normalize to unit length",
            "Batch embedding: `embeddings.create({input:[t1,t2,...t100]})` is 5-10x cheaper per token than 100 individual calls (also avoids rate limit)",
            "Chunk text to ~500 tokens (not 1 sentence, not 5 pages) — too small loses context, too large dilutes signal; overlap 50 tokens",
            "Store the original text alongside the vector — you NEED it for LLM context (RAG), and for debugging 'why did this match?'",
            "text-embedding-3-large (3072 dim) is 6.5x more expensive but only 2-5% better on most tasks — stick with `3-small` unless benchmarks prove otherwise",
            "Matryoshka embeddings: `3-small` supports custom dimensions (512, 256) — smaller dims = 4x faster search with minimal quality loss",
            "For Thai: tokenize with `new Intl.Segmenter('th', {granularity:'word'})` before chunking — word boundary matters for embedding quality",
            "Index choice: pgvector IVFFlat for <100k, HNSW for 100k-10M, Pinecone/Qdrant for 10M+ or when ops overhead is too much"
          ],
          "anti_patterns": [
            "Embedding the entire document as one vector — chunks of 200-800 tokens are 30-50% more accurate for retrieval (Naver/Stanford benchmarks)",
            "Using cosine on un-normalized vectors — gives wrong rankings for OpenAI; they happen to be normalized, don't assume for other models",
            "No re-embedding strategy when model deprecates — OpenAI deprecates embedding models in 6 months; always store `model_version` in your metadata",
            "Storing vectors only (no original text) — you can't debug, can't show citations, can't re-embed without re-fetching source",
            "Cosine similarity alone for production search — combine BM25 (keyword) + vector (semantic) via Reciprocal Rank Fusion for 20-40% better recall"
          ],
          "prereqs": [
            "7.1"
          ],
          "next_steps": [
            "7.5",
            "7.8"
          ],
          "tags": [
            "embeddings",
            "vector",
            "semantic",
            "similarity",
            "cosine",
            "rag",
            "ความหมาย"
          ],
          "compare_with": [
            "TF-IDF (sub 7.5, free, no semantics)",
            "BM25 (classic IR, keyword)",
            "Sentence-Transformers (self-hosted)",
            "Cohere Embed v3 (multilingual)"
          ]
        },
        {
          "id": "7.5",
          "title": "TF-IDF Search (Bilingual TH+EN, no API)",
          "icon": "🔍",
          "description": "Tokenize with regex (Latin word OR Thai unicode range), compute TF-IDF vectors, cosine-rank. Zero cost, instant, fully offline.",
          "when_to_use": "static site search (codehub-search powers /api/search), bilingual corpus < 50k docs, no API budget, want explainable matches. ใช้เมื่อต้องการ search ที่ทำงาน offline, deterministic, และ zero cost",
          "when_not_to_use": "deep semantic matching (user query and doc use different words for same concept — use sub 7.4); > 50k docs without indexing (build at index time, but query still O(n) unless inverted index); ranking depends on synonym/relatedness",
          "examples": "codehub-search, quick-note, knowledge-garden, flowboard",
          "snippet": "import re; tokens=re.findall(r'[a-zA-Z0-9]+|[\\฀-\\๿]+',text.lower()); df={}; for doc in docs: for t in set(tokens(doc)): df[t]=df.get(t,0)+1; idf={t:math.log((n+1)/(df_t+1))+1 for t,df_t in df.items()}",
          "snippet_full": "// Production TF-IDF: bilingual tokenize, IDF pre-compute, cosine rank\\nimport re, math\\nTOKEN_RE = re.compile(r'[a-zA-Z0-9]+|[\\\\฀-\\\\๿]+')\\nSTOP_EN = new Set(['the','a','is','of','and','to','in','for','on','with'])\\nSTOP_TH = new Set(['ของ','ใน','และ','ที่','เป็น','มี','ให้','ได้','กับ','ก็'])\\n\\nfunction tokenize(text) {\\n  return TOKEN_RE.findAll(text.toLowerCase())\\n    .filter(t => t.length > 1 && !STOP_EN.has(t) && !STOP_TH.has(t));\\n}\\n\\nexport function buildIndex(docs) {\\n  const N = docs.length;\\n  const df = {};\\n  docs.forEach(d => {\\n    new Set(tokenize(d.text)).forEach(t => df[t] = (df[t]||0) + 1);\\n  });\\n  const idf = {};\\n  for (const t in df) idf[t] = Math.log((N + 1) / (df[t] + 1)) + 1;\\n  // Pre-compute TF-IDF vectors for each doc (sparse {token: weight})\\n  const indexed = docs.map(d => {\\n    const tf = {};\\n    tokenize(d.text).forEach(t => tf[t] = (tf[t]||0) + 1);\\n    const len = Object.values(tf).reduce((a,b)=>a+b, 0) || 1;\\n    const vec = {};\\n    for (const t in tf) vec[t] = (tf[t] / len) * idf[t];\\n    return { ...d, vec, mag: Math.hypot(...Object.values(vec)) || 1 };\\n  });\\n  return { docs: indexed, idf };\\n}\\n\\nexport function search(index, query, k = 10) {\\n  const qTokens = tokenize(query);\\n  const qVec = {};\\n  qTokens.forEach(t => qVec[t] = (qVec[t]||0) + 1);\\n  const qMag = Math.hypot(...Object.values(qVec)) || 1;\\n  return index.docs\\n    .map(d => {\\n      let dot = 0;\\n      for (const t in qVec) if (d.vec[t]) dot += qVec[t] * d.vec[t];\\n      let score = dot / (d.mag * qMag);\\n      // Hybrid boost: exact phrase +0.3, any thai token match +0.05\\n      if (d.text.toLowerCase().includes(query.toLowerCase())) score += 0.3;\\n      return { ...d, score };\\n    })\\n    .filter(d => d.score > 0)\\n    .sort((a,b) => b.score - a.score)\\n    .slice(0, k);\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "5 hours",
          "docs_url": "https://en.wikipedia.org/wiki/Tf%E2%80%93idf",
          "pros": [
            "Zero cost: pure JS regex + math, runs in browser or Node, no API key, no rate limit, no internet after index build",
            "Deterministic: same input always gives same result — cacheable, debuggable, explainable (show matched terms)",
            "Bilingual out of box: regex `[a-zA-Z0-9]+|[\\฀-\\๿]+` tokenizes English words AND Thai runs as separate tokens",
            "Real-time: search 10k docs in <50ms in pure JS — fits in a single Cloudflare Worker free tier call",
            "Explainable: you can show 'matched: [รถยนต์, ราคา]' to the user — vector search gives a black-box score",
            "Works on cold data instantly: no pre-warming, no model load, no GPU — just a JSON index of IDF weights"
          ],
          "cons": [
            "No semantic understanding: query 'cheap' won't match doc with 'affordable' — must maintain synonym lists manually",
            "Thai tokenization is naive: 'เล่นกับเพื่อน' becomes ['เล่น', 'กับ', 'เพื่อน'] instead of proper segmentation — misses compound words",
            "Stop words pollute: 'the', 'a', 'ของ', 'ใน', 'และ' appear in 80% of docs, kill IDF signal — must filter explicitly",
            "Scaling: O(n) per query unless you build an inverted index — 100k docs takes 500ms+ which feels slow in UI",
            "Boost tuning is fragile: the magic numbers `+0.3 exact, +0.05 thai` are corpus-specific — re-tune when content changes",
            "No context: matching 'apple' in 'apple pie recipe' same as 'Apple Inc stock' — needs LLM re-rank or document structure"
          ],
          "gotchas": [
            "Thai unicode range `\\฀-\\๿` covers only Thai — if your docs have Lao/Khmer/Japanese, add those ranges or tokenize per-lang",
            "Lowercase BEFORE counting: 'Apple' and 'apple' are different tokens without `.toLowerCase()` — IDF inflates and rankings go wrong",
            "Filter stop words: maintain a list of ~200 EN + 100 TH common words ('the', 'a', 'ของ', 'ใน', 'คือ') — subtract from tokens before TF",
            "IDF formula: `Math.log((N+1)/(df+1)) + 1` (smoothed, never zero) — classic `log(N/df)` gives -Infinity for terms in every doc",
            "Build index at INDEX TIME not query time: pre-compute TF-IDF vectors for all docs, store JSON; query is just `cosine(query_vec, doc_vec)`",
            "Cosine vs dot-product: if vectors are L2-normalized (mag=1), they're equivalent — normalize once at index time for 3x faster search",
            "Hybrid boost: in codehub-search we add `+0.3` to score if exact phrase appears in doc, `+0.05` for any Thai token match — empirically 40% better NDCG",
            "Update strategy: append-only index works fine to 100k docs; for more, re-build index in background worker every N docs added"
          ],
          "anti_patterns": [
            "Using plain `text.includes(query)` for search — case-sensitive, word-boundary-blind, ranks 'cat food' lower than 'category food'",
            "Computing IDF on the fly per query — build the IDF dict ONCE at index time, ship it as JSON (KBs for 10k docs), query just does TF×IDF",
            "Ignoring token frequency in long docs — a 10k-word doc naturally matches more terms; divide by `sqrt(word_count)` (length normalization)",
            "Same boost numbers copied from Stack Overflow — TF-IDF tuning is corpus-specific; re-tune with a held-out query set of 50+ real queries",
            "No fallback when 0 results — always show 'no results for X, did you mean Y?' with edit-distance or prefix match on tokens"
          ],
          "prereqs": [
            "1.7"
          ],
          "next_steps": [
            "7.4",
            "7.8"
          ],
          "tags": [
            "search",
            "tfidf",
            "bilingual",
            "thai",
            "english",
            "offline",
            "ค้นหา",
            "ไทย"
          ],
          "compare_with": [
            "Embeddings (sub 7.4, semantic)",
            "BM25 (Okapi, classic IR)",
            "Lunr.js (browser search lib)",
            "FlexSearch (faster, larger)"
          ]
        },
        {
          "id": "7.6",
          "title": "Streaming Responses (SSE / ReadableStream)",
          "icon": "📡",
          "description": "Server-Sent Events for token-by-token AI output. TTFB drops from 8s to 0.4s. Cancellable via AbortController.",
          "when_to_use": "chat UIs (memo-ai), AI assistants (mavis-assistant), long-form generation where perceived speed matters, autocomplete-as-you-type. ใช้เมื่อ user รอ output > 2s และ type-while-generate จะให้ UX ดีกว่า",
          "when_not_to_use": "sub-200ms responses (streaming adds 50-100ms overhead, not worth it for fast endpoints); one-shot JSON API calls (sub 7.1 sync); when user is on a 2G connection and bandwidth matters (SSE holds connection open)",
          "examples": "memo-ai, mavis-assistant, ai-data-analyzer",
          "snippet": "const r=await fetch('/api/stream'); const reader=r.body.getReader(); while(true){const{done,value}=await reader.read(); if(done)break; text+=new TextDecoder().decode(value)}",
          "snippet_full": "// Client-side: fetch POST with AbortController + ReadableStream parsing\\nasync function streamChat(messages, onDelta, signal) {\\n  const r = await fetch('/api/chat', {\\n    method: 'POST',\\n    headers: { 'Content-Type': 'application/json' },\\n    body: JSON.stringify({ messages })\\n  });\\n  const reader = r.body.getReader();\\n  const decoder = new TextDecoder();\\n  let buf = '';\\n  while (true) {\\n    const { done, value } = await reader.read();\\n    if (done) break;\\n    buf += decoder.decode(value, { stream: true });\\n    // SSE events separated by double newline\\n    const events = buf.split('\\\\n\\\\n');\\n    buf = events.pop(); // keep incomplete\\n    for (const ev of events) {\\n      const line = ev.replace(/^data: /, '').trim();\\n      if (line === '[DONE]') return;\\n      if (line) {\\n        try {\\n          const { t: delta } = JSON.parse(line);\\n          if (delta) onDelta(delta);\\n        } catch {} // ignore parse errors on partial JSON\\n      }\\n    }\\n  }\\n}\\n// Usage: streamChat(msgs, delta => ui.append(delta), ctrl.signal)",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Streams_API",
          "pros": [
            "Perceived speed: 0.4s TTFB vs 8s — users see the first word 20x faster, even though total time is the same",
            "Cancellable: AbortController + `signal: ctrl.signal` lets user stop generation mid-stream, saves 90% of remaining tokens/cost",
            "Memory efficient: server doesn't buffer full response, client doesn't need full response in memory — streams 100k tokens fine",
            "Backpressure built-in: `await reader.read()` blocks if client is slow, server naturally throttles — no overload",
            "Auto-reconnect: EventSource has built-in retry on disconnect; `fetch` + ReadableStream you handle it manually (more control)",
            "Works through proxies/CGNAT: SSE is HTTP/1.1 long-poll, not WebSocket — survives corporate firewalls that block WS"
          ],
          "cons": [
            "Server complexity: you need streaming-capable infra (Node `Response(stream)`, FastAPI `StreamingResponse`, no nginx buffering)",
            "Can't set status code after first byte: if auth fails mid-stream, you've already sent 200 OK — must validate before streaming starts",
            "Buffering kills it: nginx `proxy_buffering on` (default) waits for full response before forwarding — set `off` for `/api/stream/*`",
            "Connection limits: browsers max 6 concurrent HTTP/1.1 connections per origin — one stream holds one, exhausting budget for other fetches",
            "No built-in ack: client doesn't know if server crashed mid-stream — must send explicit `event: end` and handle reconnection",
            "Time-to-first-byte varies: 0.2-1s is normal, but cold model (first request) can be 5s — set `Transfer-Encoding: chunked` early"
          ],
          "gotchas": [
            "EventSource is GET-only — for POST bodies, use `fetch + body.getReader()` instead (memo-ai does this for chat POST)",
            "SSE format: each event is `data: <json>\\n\\n` (DOUBLE newline) — `data:` without trailing blank line is silently dropped by EventSource",
            "Flush explicitly: `controller.enqueue(...)` is buffered; some servers need `flushHeaders()` or `resp.flush()` to push to network immediately",
            "AbortController: pass `signal` to fetch, then `ctrl.abort()` on user click — but ALSO clean up server-side (close the upstream API stream)",
            "Don't parse JSON per line for OpenAI: chunks are `data: {json}\\n\\n`, NOT line-delimited JSON — split on `\\n\\n` first, then strip `data: `",
            "OpenAI stream chunks: `delta.content` is empty in some chunks (tool calls, role tokens) — use `chunk.choices[0]?.delta?.content || ''`",
            "Server-Sent Events need `Content-Type: text/event-stream` AND `Cache-Control: no-cache, no-transform` — missing these and proxies cache",
            "CORS: SSE responses need `Access-Control-Allow-Origin` like any other response — but preflight is for POST, not GET-with-EventSource"
          ],
          "anti_patterns": [
            "Using WebSocket for one-way AI streaming — SSE is 5x simpler, no server state, works through proxies, browser reconnection is free",
            "Buffering full response server-side before streaming — kills the whole point; stream from OpenAI directly through to client",
            "No client-side abort on unmount — React component unmounts but fetch keeps running, leaks memory and burns API quota",
            "Wrapping stream in JSON.stringify per chunk — `JSON.parse` per chunk wastes CPU; send raw text or use `application/x-ndjson`",
            "No heartbeat: long streams (5+ minutes) get killed by intermediate proxies' 60-120s idle timeout — send `: heartbeat\\n\\n` every 15s"
          ],
          "prereqs": [
            "1.7",
            "7.1"
          ],
          "next_steps": [
            "7.7",
            "7.8"
          ],
          "tags": [
            "streaming",
            "sse",
            "readable-stream",
            "realtime",
            "fetch",
            "สตรีม"
          ],
          "compare_with": [
            "WebSocket (bi-directional, more complex)",
            "Long polling (legacy fallback)",
            "Server-Sent Events vs WebSockets (sub 7.6 SSE simpler)"
          ]
        },
        {
          "id": "7.7",
          "title": "Function Calling (OpenAI / Anthropic tools API)",
          "icon": "🛠️",
          "description": "AI returns a JSON tool call → your code executes → result fed back. Turns chat into action (book, query, send, delete).",
          "when_to_use": "agent AI that needs to DO things (mavis-assistant booking, email send, DB query, file edit), structured data extraction with validation, multi-step workflows. ใช้เมื่อ AI ต้อง interact กับ external system ไม่ใช่แค่ตอบ",
          "when_not_to_use": "pure Q&A or summarization (no action needed); single-call simple JSON (just use response_format); when deterministic logic is enough (a button is better than AI for 'click here')",
          "examples": "mavis-assistant, mavis-vs-ai, memo-ai, ai-data-analyzer",
          "snippet": "const r=await openai.chat.completions.create({model:'gpt-4o',messages,tools:[{type:'function',function:{name:'get_weather',parameters:{type:'object',properties:{city:{type:'string'}},required:['city']}}}]});",
          "snippet_full": "// Production function calling: define tools, validate, loop until done\\nimport OpenAI from 'openai';\\nimport { z } from 'zod';\\nconst openai = new OpenAI();\\n\\nconst tools = [{\\n  type: 'function',\\n  function: {\\n    name: 'get_weather',\\n    description: 'Get current weather for a city. Use when user asks about temperature, rain, or conditions.',\\n    parameters: z.object({\\n      city: z.string().describe('City name, e.g. Bangkok, Tokyo'),\\n      unit: z.enum(['celsius','fahrenheit']).default('celsius')\\n    }).toJSONSchema()\\n  }\\n}];\\n\\nasync function runAgent(userMsg) {\\n  const messages = [{ role: 'user', content: userMsg }];\\n  for (let i = 0; i < 5; i++) { // max 5 tool rounds\\n    const r = await openai.chat.completions.create({\\n      model: 'gpt-4o-mini', messages, tools, tool_choice: 'auto'\\n    });\\n    const msg = r.choices[0].message;\\n    messages.push(msg);\\n    if (!msg.tool_calls) return msg.content; // done\\n    for (const call of msg.tool_calls) {\\n      const args = JSON.parse(call.function.arguments);\\n      try {\\n        const result = await (call.function.name === 'get_weather'\\n          ? getWeather(args.city, args.unit)\\n          : { error: 'unknown tool' });\\n        messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });\\n      } catch (e) {\\n        messages.push({ role: 'tool', tool_call_id: call.id, content: `Error: ${e.message}` });\\n      }\\n    }\\n  }\\n  return 'Agent max-iterations reached';\\n}",
          "difficulty": "Advanced",
          "time_to_learn": "1 day",
          "docs_url": "https://platform.openai.com/docs/guides/function-calling",
          "pros": [
            "AI acts on YOUR data: weather, calendar, internal DB, Stripe — anything you can wrap in a function becomes an AI tool",
            "Structured I/O: tool args are validated JSON-schema, not free text — no regex parsing, no 'did the AI say 5 or five?'",
            "Multi-tool in one call: define 10 tools, model picks which to call (or none) — single round-trip for complex intents",
            "Parallel tool calls: gpt-4o can call 3 tools at once (e.g. weather+calendar+email) — model sees results, decides next step",
            "Anthropic and OpenAI have nearly identical tool APIs — same `tools: [...]` schema works on both with minor tweaks",
            "Reduces hallucination: AI can't 'guess' the weather, it MUST call `get_weather('Bangkok')` — grounded in real data"
          ],
          "cons": [
            "Schema design is hard: bad descriptions cause wrong tool selection — 'use this to fetch weather' vs vague 'weather tool' is 30% accuracy diff",
            "Multi-turn orchestration: model may call wrong tool, your code must validate, send back error, let model retry (adds 1-3 round-trips)",
            "Cost: each tool call is a full chat completion round-trip — a 5-tool workflow = 5x cost vs single prompt; cache tool results when possible",
            "Latency: tool execution adds 200-2000ms before the LLM 'thinks' about result — for time-critical flows, this dominates",
            "Security: AI calling `delete_user(id)` is dangerous — ALWAYS whitelist tools, require confirmation for destructive actions",
            "Model can refuse to call tools or call them when not needed — system prompt with 'use X only when user explicitly asks' helps"
          ],
          "gotchas": [
            "ALWAYS validate tool args server-side with Zod/Pydantic — model can send `age: 'twenty'` when schema says `number`; trust nothing",
            "Tool descriptions are CRITICAL: 'Get current weather for a city. Returns {temp_c, conditions, humidity}. Use when user asks about weather.' — be specific",
            "Max tools in one call: ~15-20 before model gets confused; group similar tools or use sub-routing (one tool that dispatches to 10 internal ones)",
            "Anthropic requires `input_schema` (renamed from `parameters`), no `type: 'function'` wrapper, and `tool_use` blocks (not `function_call`) — small diffs",
            "For multi-step: after tool executes, send back `{role:'tool', tool_call_id:'abc', content:result}` — model needs the id to associate result with call",
            "Long tool outputs: if `get_logs()` returns 100k tokens, truncate to top 500 lines OR summarize with another LLM call before sending back",
            "Idempotency: model may call same tool twice on retry — pass an `idempotency_key` derived from the user message + tool name to dedupe server-side",
            "Streaming + tools: OpenAI stream returns tool calls in `delta.tool_calls` (not content) — buffer these, they arrive in pieces"
          ],
          "anti_patterns": [
            "Exposing 'dangerous' tools (delete_*, send_email_*, charge_card_*) without confirmation — at minimum require explicit user approval for each call",
            "Using tools for things the LLM already knows (translate, summarize) — adds latency, no benefit; use direct prompt",
            "Tools with huge schemas (50+ fields) — model will miss required fields; break into 2-3 tools with simpler scopes",
            "No timeout on tool execution — a slow DB query blocks the chat for 30s; enforce 5s timeout, return error, let model decide next step",
            "Logging full tool args to console in production — can leak PII (emails, addresses) to log aggregators; redact sensitive fields"
          ],
          "prereqs": [
            "7.1",
            "7.6"
          ],
          "next_steps": [
            "7.8",
            "7.10"
          ],
          "tags": [
            "ai",
            "agent",
            "function-calling",
            "tools",
            "openai",
            "anthropic",
            "agentic"
          ],
          "compare_with": [
            "LangChain (framework, more deps)",
            "AutoGen (multi-agent)",
            "Raw prompt engineering (sub 7.9)",
            "Anthropic MCP (newer standard)"
          ]
        },
        {
          "id": "7.8",
          "title": "RAG (Retrieval Augmented Generation)",
          "icon": "📚",
          "description": "Embed query → search top-k similar chunks → stuff in LLM prompt. Grounds AI in YOUR data, eliminates hallucination, cites sources.",
          "when_to_use": "chat on your docs (knowledge-garden Q&A, quick-note search), customer support bot on knowledge base, internal company wiki assistant, code search. ใช้เมื่อ AI ต้องตอบจากข้อมูลเฉพาะที่ LLM ไม่รู้",
          "when_not_to_use": "general Q&A (LLM knows it, no need for RAG); structured data queries (use SQL with text-to-SQL); < 50 docs (just put in system prompt); when freshness matters to the hour (need streaming index updates, not nightly batch)",
          "examples": "quick-note, knowledge-garden, memo-ai, ai-data-analyzer",
          "snippet": "const qEmb=await embed(q); const top5=docs.sort((a,b)=>cos(qEmb,b.emb)-cos(qEmb,a.emb)).slice(0,5); const prompt=`Based on: ${top5.map(d=>d.text).join('\\n')}\\nQ: ${q}\\nA:`;",
          "snippet_full": "// Production RAG: chunk on save, embed, hybrid search, re-rank, cite\\nimport { embedBatch } from './embeddings.js';\\nimport { searchHybrid } from './search.js';\\nimport OpenAI from 'openai';\\nconst openai = new OpenAI();\\n\\n// 1. CHUNK on save (semantic = markdown headers, fallback = 500 tokens)\\nfunction chunkDoc(text, docId) {\\n  const chunks = text.split(/\\\\n#{1,3} /).filter(c => c.length > 50);\\n  return chunks.map((c, i) => ({\\n    id: `${docId}#${i}`,\\n    text: c.slice(0, 2000), // 500-800 tokens\\n    metadata: { docId, section: i, lang: detectLang(c) }\\n  }));\\n}\\n\\n// 2. INDEX on save (batch embed, store with metadata)\\nasync function indexDoc(text, docId) {\\n  const chunks = chunkDoc(text, docId);\\n  const embs = await embedBatch(chunks.map(c => c.text));\\n  await db.query(\\`INSERT INTO chunks (id, text, embedding, metadata) VALUES %$\\n    ${chunks.map((c, i) => `(${c.id}, ${c.text}, [${embs[i]}], ${c.metadata})`).join(',')}\\n    ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding\\`);\\n}\\n\\n// 3. QUERY: hybrid search → re-rank → prompt with citations\\nasync function ragAnswer(question, k = 5) {\\n  const top = await searchHybrid(question, k * 3); // fetch more, re-rank down\\n  const ctx = top.map(c => `[${c.id}] ${c.text}`).join('\\\\n---\\\\n');\\n  const r = await openai.chat.completions.create({\\n    model: 'gpt-4o-mini',\\n    messages: [\\n      { role: 'system', content: 'Answer using ONLY the context below. Cite sources like [doc-id#section]. If unsure, say \"I don\\'t know\".' },\\n      { role: 'user', content: `Context:\\\\n${ctx}\\\\n\\\\nQ: ${question}\\\\nA:` }\\n    ],\\n    temperature: 0.2\\n  });\\n  return { answer: r.choices[0].message.content, sources: top.slice(0, k) };\\n}",
          "difficulty": "Advanced",
          "time_to_learn": "3 days",
          "docs_url": "https://docs.llamaindex.ai/en/stable/getting_started/concepts/",
          "pros": [
            "Eliminates hallucination: AI quotes YOUR docs verbatim, not inventing facts from training data",
            "Always cited: show user 'source: doc-id, page 3' — builds trust, lets them verify, required for medical/legal",
            "Cheap to update: add a doc → re-embed → done, no fine-tuning ($1000s) or retraining (weeks)",
            "Works on private data: docs never leave your server (until final LLM call) — fine-tune was the only alternative before",
            "Multi-source: combine PDFs, Slack, Notion, DB rows in one vector index — model doesn't care where chunks came from",
            "Quick-Note pattern: chunk on save, embed, index — next user query hits all 3 sources in <200ms total"
          ],
          "cons": [
            "Quality = GIGO: bad chunks (too big, too small, wrong boundaries) = bad retrieval = bad answers — chunking is 60% of the work",
            "Context window pressure: 5 chunks × 1000 tokens = 5000 tokens just for context, leaving less for answer + conversation history",
            "Embedding cost adds up: 1M chunks × 500 tokens × $0.02/M = $10 one-time + storage forever — re-embedding is expensive if you change models",
            "Cross-encoder re-rank is +30% quality but +200ms latency + separate model — usually skipped in MVP, regretted in production",
            "Thai/Asian language RAG is harder: chunk boundaries (sentences) don't exist in Thai the same way, embeddings are 10-20% worse than EN",
            "Stale data: if you forget to re-embed updated docs, users get old answers — need an ingestion pipeline with version tracking"
          ],
          "gotchas": [
            "Top-k sweet spot is 3-5, NOT 20: more chunks dilute the prompt with noise, model gets confused; 3-5 with re-rank beats 20 without",
            "Chunking strategy matters: Markdown headers (semantic) > fixed 500 chars (dumb) > single sentence (too small); aim for 200-800 token chunks",
            "Overlap: 50-100 token overlap between adjacent chunks preserves context at boundaries; without it, 'the cat sat on the ___' gets cut mid-sentence",
            "Hybrid search wins: vector (semantic) + BM25 (keyword) fused via Reciprocal Rank Fusion is 20-40% better than vector-only on tech docs",
            "Cross-encoder re-rank: after vector search, run a smaller model (`cross-encoder/ms-marco-MiniLM`) to score query+chunk pairs, re-sort — +30% NDCG@10",
            "Metadata filters BEFORE vector search: `WHERE category='api' AND created_at > '2024-01-01'` reduces search space 10-100x, faster + better",
            "Prompt template: 'Use ONLY the context below. If answer not in context, say \"I don\\'t know\". Cite source IDs.' — critical to prevent hallucination",
            "Chunk metadata: store `{doc_id, section, page, lang, timestamp}` with each vector — needed for citations, filtering, debugging"
          ],
          "anti_patterns": [
            "Embedding the WHOLE document as one vector — precision dies, you get the right doc but wrong section; always chunk",
            "No chunk overlap — context gets cut at boundaries, answers miss critical info at start/end of sections",
            "Re-ranking 50 chunks with cross-encoder — too slow, too expensive; re-rank top-20 from vector, return top-5",
            "Stuffing 10+ chunks into prompt — model gets confused, attention dilutes, answers degrade; 3-5 is the sweet spot",
            "No evaluation set: shipping RAG without measuring recall@10 on 50+ held-out queries = flying blind; build eval set before launch"
          ],
          "prereqs": [
            "7.4"
          ],
          "next_steps": [
            "7.9",
            "7.10"
          ],
          "tags": [
            "rag",
            "retrieval",
            "knowledge",
            "ai",
            "context",
            "ความรู้",
            "ค้นหา"
          ],
          "compare_with": [
            "Fine-tuning ($$$ for static knowledge)",
            "Long context (1M tokens works, but $)",
            "GraphRAG (Microsoft, for relational queries)"
          ]
        },
        {
          "id": "7.9",
          "title": "Prompt Engineering (System, Few-shot, CoT)",
          "icon": "📝",
          "description": "The art of writing prompts that consistently get high-quality outputs. 30-50% quality lift on same model, free, no code.",
          "when_to_use": "every AI integration in memo-ai, before reaching for a bigger model, when outputs are inconsistent or wrong. ใช้เนื้อ 90% ของเวลาในการ tune AI feature ก่อนจะไปถึง RAG/fine-tune",
          "when_not_to_use": "model is the wrong size (gpt-4o-mini can't reason like gpt-4o — no prompt fixes this); task is impossible (AI can't count letters in words reliably); you need 100% determinism (use code, not AI)",
          "examples": "memo-ai, mavis-assistant, ai-data-analyzer, why-minimax",
          "snippet": "const sys='You are a helpful assistant. Be concise.'; const prompt=`Examples:\\nQ: 1+1?\\nA: 2\\nQ: 2+2?\\nA: 4\\nQ: ${q}\\nA:`;",
          "snippet_full": "// Production prompt: system persona + few-shot + JSON mode + low temperature\\nconst SYSTEM = `You are a Thai customer support agent for an e-commerce store.\\n- Reply in Thai, polite, using ค่ะ/ครับ\\n- 2-3 sentences max\\n- Return JSON: {reply: string, sentiment: 'positive'|'neutral'|'negative', order_id?: string}`;\\n\\nconst FEW_SHOT = [\\n  { role: 'user', content: 'ขอเช็คสถานะออเดอร์ ORD-12345 ค่ะ' },\\n  { role: 'assistant', content: JSON.stringify({ reply: 'ออเดอร์ ORD-12345 อยู่ระหว่างจัดส่งค่ะ จะถึงพรุ่งนี้ค่ะ', sentiment: 'neutral', order_id: 'ORD-12345' }) },\\n  { role: 'user', content: 'อยากคืนเงินออเดอร์ ORD-99999' },\\n  { role: 'assistant', content: JSON.stringify({ reply: 'รับทราบค่ะ จะดำเนินการคืนเงิน ORD-99999 ภายใน 3-5 วันทำการค่ะ', sentiment: 'neutral', order_id: 'ORD-99999' }) }\\n];\\n\\nasync function support(userMsg) {\\n  const r = await openai.chat.completions.create({\\n    model: 'gpt-4o-mini',\\n    messages: [{ role: 'system', content: SYSTEM }, ...FEW_SHOT, { role: 'user', content: userMsg }],\\n    response_format: { type: 'json_object' },\\n    temperature: 0.2,\\n    max_tokens: 300\\n  });\\n  return JSON.parse(r.choices[0].message.content);\\n}",
          "difficulty": "Beginner",
          "time_to_learn": "1 day",
          "docs_url": "https://platform.openai.com/docs/guides/prompt-engineering",
          "pros": [
            "Free 30-50% quality lift on same model — better prompts on gpt-4o-mini often beat worse prompts on gpt-4o, 15x cheaper",
            "Iteration speed: change a prompt, hit save, see new result in 5 seconds — no deploy, no retrain, no waiting",
            "Portable: same prompt works on gpt-4o, Claude, Gemini, Llama (with minor tweaks) — no vendor lock-in",
            "Few-shot > zero-shot: 3 examples typically beats 100 words of instruction; model pattern-matches examples",
            "Chain-of-thought: 'think step by step' on reasoning tasks (math, logic) gives 20-40% accuracy boost at no cost",
            "System prompt = behavior DNA: tone, format, refusal rules, persona — set once, applies to all 1000s of calls"
          ],
          "cons": [
            "Brittle: a prompt that works 99% may fail 1% in a way you can't reproduce — need eval set, not vibes-based testing",
            "Token cost: long system prompt is paid EVERY call — 2k system prompt × 10k calls = 20M tokens ($3 on mini, $50 on 4o)",
            "Model drift: gpt-4o behavior changes with model versions, fine-tuning data updates — your prompt that worked in March may break in June",
            "Multi-language: Thai prompts often 20-30% less accurate than English on same model — translate or use EN for critical instructions",
            "Diminishing returns: 1st hour of prompt tuning = 30% gain, 5th hour = 2% gain — know when to stop and switch to RAG or fine-tune",
            "Hard to version: prompts end up in 5 places (code, env, DB, admin UI, analytics) — diff/rollback is manual"
          ],
          "gotchas": [
            "Be SPECIFIC: 'Reply in 2-3 sentences, in Thai, with bullet points, no preamble' beats 'be concise' every time",
            "Few-shot examples > instructions: 3 well-chosen examples typically outperform paragraphs of rules — show, don't tell",
            "Chain-of-thought for reasoning: append 'Think step by step, then give your final answer.' to math/logic prompts — 20-40% accuracy gain",
            "Negative instructions confuse models: 'Do not use markdown' is weaker than 'Reply in plain text, no markdown' — say what TO do",
            "System prompt vs user prompt: persona/format/limits go in system, the actual task in user — models weight system higher",
            "JSON mode: `response_format: {type: 'json_object'}` + a 'Return JSON with fields {x:number, y:string}' — eliminates 90% of parse errors",
            "Role priming: 'You are an expert Thai translator with 20 years experience' measurably improves output vs neutral persona",
            "Temperature by task: 0.0-0.3 for code/JSON/factual, 0.7-1.0 for creative writing, 0.5 default — most apps leave at 1.0 by mistake"
          ],
          "anti_patterns": [
            "Mega-prompt with 500 words of instructions — model attention dilutes, gets worse than 3 examples + 1 sentence",
            "Mixing Thai and English instructions in same prompt — model often mixes languages in output; pick one for instructions, allow response in target",
            "No eval set: changing prompt based on 'feels better' without measuring 50+ held-out examples = random walk",
            "Hard-coding model name in 50 places — wrap in `const MODEL = 'gpt-4o-mini'`; upgrade is 1-line change",
            "Forgetting to test on EDGE cases: prompt works for happy path, fails on empty input, on 10k char input, on emoji, on Thai — test boundaries"
          ],
          "prereqs": [
            "7.1"
          ],
          "next_steps": [
            "7.7",
            "7.8",
            "7.10"
          ],
          "tags": [
            "prompt",
            "prompt-engineering",
            "few-shot",
            "cot",
            "system-prompt",
            "พร้อมท์",
            "คำสั่ง"
          ],
          "compare_with": [
            "DSPy (programmatic prompt optimization)",
            "Guidance (Microsoft, templated)",
            "LangChain prompts (overkill for most)"
          ]
        },
        {
          "id": "7.10",
          "title": "Safety Filters (Moderation + Jailbreak Detection)",
          "icon": "🛡️",
          "description": "OpenAI Moderation API (free <1M tokens/mo) + pattern-based jailbreak detection. Required for any public AI app.",
          "when_to_use": "any public-facing AI app (memo-ai, mavis-assistant), user-generated content flows, child/family products, anything with comments/chats. ใช้เมื่อ user input เข้าถึง AI โดยตรง",
          "when_not_to_use": "internal tools with trusted users (skip cost/latency); pre-moderated content pipeline (already filtered upstream); research where you NEED the model to discuss sensitive topics",
          "examples": "mavis-assistant, memo-ai, ai-data-analyzer, mavis-vs-ai",
          "snippet": "const mod=await openai.moderations.create({input:text}); if(mod.results[0].flagged)throw new Error('Blocked by moderation')",
          "snippet_full": "// Production moderation: input + output, threshold-based, jailbreak patterns\\nimport OpenAI from 'openai';\\nconst openai = new OpenAI();\\nconst BLOCK_THRESHOLD = 0.5;\\nconst REVIEW_THRESHOLD = 0.3;\\nconst JAILBREAK_PATTERNS = [\\n  /ignore (all )?previous instructions/i,\\n  /you are now (DAN|developer mode|jailbroken)/i,\\n  /pretend (you have no|there are no) (rules|restrictions)/i,\\n  /disregard (your|all) (guidelines|programming)/i\\n];\\n\\nexport async function moderate(text) {\\n  // Layer 1: Pattern-based jailbreak detection (free, fast)\\n  for (const pat of JAILBREAK_PATTERNS) {\\n    if (pat.test(text)) return { action: 'block', reason: 'jailbreak_pattern', confidence: 1 };\\n  }\\n  // Layer 2: OpenAI Moderation API (free <1M tokens/mo)\\n  const m = await openai.moderations.create({ input: text });\\n  const cats = m.results[0].category_scores;\\n  const max = Math.max(...Object.values(cats));\\n  if (max >= BLOCK_THRESHOLD) return { action: 'block', reason: 'moderation', score: max, cats };\\n  if (max >= REVIEW_THRESHOLD) return { action: 'review', reason: 'borderline', score: max, cats };\\n  return { action: 'allow' };\\n}\\n\\n// Usage in chat flow\\nexport async function safeChat(userMsg, fn) {\\n  const verdict = await moderate(userMsg);\\n  if (verdict.action === 'block') throw new Error(`Blocked: ${verdict.reason}`);\\n  if (verdict.action === 'review') return { reply: 'คำขอของคุณถูกส่งไปตรวจสอบ จะตอบกลับเร็วๆ นี้ค่ะ' };\\n  const aiReply = await fn(userMsg);\\n  const outVerdict = await moderate(aiReply); // also check output\\n  return outVerdict.action === 'block' ? { reply: 'ขออภัยค่ะ ไม่สามารถตอบได้' } : { reply: aiReply };\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "3 hours",
          "docs_url": "https://platform.openai.com/docs/guides/moderation",
          "pros": [
            "OpenAI Moderation API is FREE for inputs <1M tokens/mo — zero cost for most small/medium apps, no excuse to skip",
            "Sub-100ms latency on moderation call — can run inline before main LLM call, user won't notice",
            "Multi-category: hate, violence, self-harm, sexual, harassment — 13 categories with confidence scores, not just binary",
            "Jailbreak pattern detection: regex on 'ignore previous instructions', 'DAN mode', 'pretend you have no rules' catches 80% of attempts",
            "Compliance: EU AI Act (Aug 2025), COPPA, app store review — moderation is a hard requirement, not a nice-to-have",
            "Two layers: moderate INPUT (block user abuse) and OUTPUT (catch model regression on safety) — defense in depth"
          ],
          "cons": [
            "False positives: 'I want to kill this bug' flagged as violence — needs human review queue for borderline cases",
            "Doesn't catch everything: novel slurs, coded language, multi-language attacks slip through — combine with human report flow",
            "OpenAI Moderation is English-optimized: Thai profanity/slurs detection is 60-70% recall vs 95%+ for English",
            "Cost adds up: 2 calls (moderate input + moderate output) per chat = 2x tokens + 100-200ms latency on every message",
            "Adversarial attacks evolve: new jailbreak techniques (Base64, ROT13, multi-turn grooming) appear weekly — pattern lists age out",
            "Privacy: moderation API sees the text — for medical/financial PII you may need a self-hosted classifier (Llama Guard)"
          ],
          "gotchas": [
            "Moderation is a SEPARATE endpoint: `openai.moderations.create({input:text})` — distinct from chat, distinct rate limit bucket",
            "Check BOTH input and output: input for user abuse, output for model regression (a fine-tune gone wrong can start producing bad content)",
            "Thresholds: 0.5+ flagged = block, 0.3-0.5 = human review, <0.3 = allow — tune per audience (kids' app needs 0.2, dev tool can be 0.7)",
            "Free tier: OpenAI Moderation free to 1M tokens/mo, then $0.005/1k — even at scale, costs <1% of main LLM bill",
            "Jailbreak patterns to block: 'ignore previous', 'pretend you', 'DAN', 'developer mode', 'no restrictions', role-play as unrestricted AI",
            "Self-host for sensitive: Llama Guard (Meta, 7B) runs on a single GPU for $0.001/call — better for medical, financial, EU data residency",
            "Log everything: store (with PII redacted) what was flagged and action taken — needed for audits, abuse pattern analysis, model fine-tuning data",
            "Don't ONLY rely on moderation — user reports + rate limits + post-hoc review are also needed; no single layer is enough"
          ],
          "anti_patterns": [
            "Skipping moderation on 'internal' apps — they go public via URL share, screenshot, or promotion; plan for public from day 1",
            "Only moderating input, not output — fine-tunes, prompt injections, and model regressions can make output unsafe even if input is clean",
            "Hard-blocking on any flag (0.01 confidence) — will block legitimate users on edge cases; use threshold (0.5) + human review queue",
            "Logging full moderated text to analytics — may store PII, medical, or illegal content in plain text logs; redact or hash sensitive fields",
            "No user feedback loop: user flagged unfairly? Build 'this was a mistake' button → re-evaluate thresholds, update pattern list"
          ],
          "prereqs": [
            "7.1"
          ],
          "next_steps": [
            "7.7",
            "7.8"
          ],
          "tags": [
            "safety",
            "moderation",
            "jailbreak",
            "filter",
            "compliance",
            "ความปลอดภัย",
            "กรอง"
          ],
          "compare_with": [
            "Llama Guard (self-hosted)",
            "Perspective API (Google, free tier)",
            "Azure Content Safety (enterprise)",
            "Cloudflare AI Gateway (built-in)"
          ]
        }
      ]
    },
    {
      "id": 8,
      "name": "Infrastructure",
      "icon": "🏗️",
      "tagline": "Deploy + ops",
      "sub_phases": [
        {
          "id": "8.1",
          "title": "ship.py (1-Click Deploy Pipeline)",
          "icon": "🚀",
          "description": "One-command deploy. edit → curl-verify → paramiko SFTP → API upload → cache refresh = sandbox-to-prod in 30s for any single-file app.",
          "when_to_use": "Every static app on codehub.sj88ai.com (karaoke-studio, stronghold-3d, noodle-shop, demo-coin-flip); 1 app < 5MB; < 10 deploys/day; solo dev pushing from local sandbox",
          "when_not_to_use": "Multi-service deploys needing orchestration; binary assets > 10MB (use rsync over SSH); rollbacks to tagged releases (need Git tag integration); multi-dev pushing to same slug (race condition)",
          "examples": "karaoke-studio, stronghold-3d, noodle-shop, demo-coin-flip, flowboard",
          "snippet": "python3 ship.py --name karaoke-studio --file dist/index.html --title 'Karaoke Studio' --version 1.2.0",
          "snippet_full": "#!/usr/bin/env python3\\n# ship.py — 1-click deploy pipeline (codehub.sj88ai.com)\\nimport os, sys, hashlib, requests, paramiko\\nfrom pathlib import Path\\n\\nSLUG, FILE, TITLE, VER = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]\\nVPS_HOST, VPS_USER = 'codehub.sj88ai.com', 'root'\\nREMOTE_DIR = f'/srv/apps/{SLUG}'\\nPASSWORD = os.environ['DEPLOY_VPS_PASS']\\nAPI_TOKEN = os.environ['API_ADMIN_TOKEN']\\n\\n# Step 1: local MD5 verify (source integrity)\\nlocal_md5 = hashlib.md5(Path(FILE).read_bytes()).hexdigest()\\nprint(f'[1/5] local MD5 {local_md5[:8]}... for {FILE}')\\n\\n# Step 2: SFTP upload (paramiko)\\nclient = paramiko.SSHClient()\\nclient.set_missing_host_key_policy(paramiko.AutoAddPolicy())  # dev only\\nclient.connect(VPS_HOST, 22, VPS_USER, password=PASSWORD)\\nsftp = client.open_sftp()\\nsftp.put(FILE, f'{REMOTE_DIR}/index.html')\\nsftp.close()\\nprint(f'[2/5] SFTP uploaded to {VPS_HOST}:{REMOTE_DIR}/index.html')\\n\\n# Step 3: server-side MD5 verify (transfer integrity)\\n_, out, _ = client.exec_command(f'md5sum {REMOTE_DIR}/index.html')\\nremote_md5 = out.read().decode().split()[0]\\nif remote_md5 != local_md5:\\n    print(f'[FAIL] MD5 mismatch: local={local_md5} remote={remote_md5}'); sys.exit(1)\\nprint(f'[3/5] MD5 verified: {remote_md5[:8]}...')\\n\\n# Step 4: API catalog upload (registers app in /api/projects)\\nr = requests.post('https://codehub.sj88ai.com/api/admin/upload',\\n    headers={'Authorization': f'Bearer {API_TOKEN}'},\\n    json={'slug': SLUG, 'title': TITLE, 'version': VER, 'size': Path(FILE).stat().st_size})\\nr.raise_for_status()\\nprint(f'[4/5] catalog updated: {r.json()[\"project_id\"]}')\\n\\n# Step 5: cache refresh (purges nginx cache for /SLUG/)\\nrequests.post('https://codehub.sj88ai.com/api/cache/refresh',\\n    headers={'Authorization': f'Bearer {API_TOKEN}'}, json={'slug': SLUG}).raise_for_status()\\nprint(f'[5/5] cache refreshed; live at https://codehub.sj88ai.com/{SLUG}/')\\nclient.close()",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://www.paramiko.org/",
          "pros": [
            "1 command = edit → curl-verify (local MD5) → paramiko SFTP → /api/upload (server MD5) → /api/cache/refresh; ~30s end-to-end from sandbox to https://codehub.sj88ai.com/APP/",
            "MD5 hash check before/after SFTP catches corrupted transfer (SFTP doesn't natively verify integrity) — corrupted file = silent 500 on prod",
            "Auto-creates /api/cache/refresh in same deploy = no separate curl + no stale HTML/JS served to users after deploy",
            "Idempotent: re-running ship.py overwrites old version, no stale files; --keep-versions=N gives rollback history on disk",
            "Bash-friendly exit codes (0=ok, 1=verify fail, 2=SFTP fail, 3=API fail) — slots into pre-commit hooks and cron jobs",
            "Local file = source of truth; no need to git push before ship (for hotfixes); ship.py reads file directly off disk"
          ],
          "cons": [
            "Single-VPS = no horizontal scale; if VPS dies, no deploy possible; consider GitHub Actions (8.5) for multi-region",
            "SFTP is unencrypted at the application layer beyond SSH tunnel — enable sftp -C for compression, but real solution is rsync/scp for sensitive data",
            "No atomic rollback — ship.py overwrites, doesn't version on disk; manual restore needs /mnt/16tb/codehub/backup/<date>/",
            "Paramiko is ~2x slower than ssh CLI for files > 5MB; SFTP upload of 10MB+ = noticeable delay",
            "Credential lives in $DEPLOY_VPS_PASS env = one secret leak in shell history = full VPS root access; rotate quarterly",
            "Race condition if 2 ships hit same slug at same time (last-writer-wins, no lock); use flock in cron wrapper for sequential deploys"
          ],
          "gotchas": [
            "Always set $DEPLOY_VPS_PASS in ~/.bashrc or use a secrets manager (1Password CLI, sops) — NEVER inline `password='hunter2'` in script",
            "Verify file MD5 BEFORE paramiko (sanity check source) AND after SFTP put() (transfer integrity); log both hashes for audit",
            "If ship.py fails at API upload step, the file IS on VPS but NOT in catalog — manual cleanup needed (curl /api/admin/delete?slug=X)",
            "Cache refresh hits nginx (via /api/cache/refresh), NOT the file itself — file changes alone don't propagate to users without cache bust",
            "Title field must match folder slug exactly (`--title 'My App'` for slug 'my-app') or /APP/ will 404; spaces in slug = URL-encoding hell",
            "Use --dry-run flag first time to see exactly which steps will run; avoids accidentally overwriting prod on typo",
            "ship.py requires $API_ADMIN_TOKEN (generate via POST /api/admin/login) — token expiry 24h, re-auth at start of script if 401",
            "If VPS disk > 90% full, SFTP put() succeeds but nginx serves stale (check `df -h | grep '9[0-9]%'` pre-deploy to fail fast)"
          ],
          "anti_patterns": [
            "DON'T pipe password inline: `echo $DEPLOY_VPS_PASS | ssh root@host cmd` — leaks via `ps aux` process list visible to all users on host",
            "DON'T skip the curl-verify step (local MD5) — corrupted files will 500 silently on prod, debug takes 30 min to trace",
            "DON'T ship without --title matching slug — old URL stays live, breaks SEO, and /API/list returns confusing duplicate",
            "DON'T ship 50MB+ files via ship.py — paramiko SFTP struggles; use `rsync -avz --progress app.html root@host:/srv/apps/` directly",
            "DON'T run ship.py from CI/CD without --confirm flag — accidental prod pushes from broken main branch are real incidents"
          ],
          "prereqs": [
            "1.1",
            "8.4"
          ],
          "next_steps": [
            "8.2",
            "8.6"
          ],
          "tags": [
            "deploy",
            "ship",
            "shippy",
            "sftp",
            "paramiko",
            "deploy",
            "เดพลอย",
            "อัปโหลด"
          ],
          "compare_with": [
            "8.4 Paramiko (raw SSH)",
            "8.5 GitHub Actions (CI/CD)",
            "8.9 Docker (containerized deploy)"
          ]
        },
        {
          "id": "8.2",
          "title": "nginx Configuration (vhost + proxy + cache + CORS)",
          "icon": "🌐",
          "description": "Virtual host + reverse proxy + gzip + rate limit + try_files SPA routing. Serves every codehub.sj88ai.com app + proxies /api to FastAPI on 127.0.0.1:8765.",
          "when_to_use": "Every CodeHub static + dynamic site; reverse-proxy to uvicorn:8000 or codehub-api:8765; SSL termination; HTTP/2; SPA routing for client-side apps",
          "when_not_to_use": "Pure static assets that should go straight to CDN (8.10); k8s ingress (use ingress-nginx); > 100k concurrent connections (need HAProxy); load balancer tier (use AWS ALB / Cloudflare LB)",
          "examples": "codehub-homepage, karaoke-studio, stronghold-3d, flowboard",
          "snippet": "server { listen 443 ssl http2; server_name codehub.sj88ai.com; root /srv/apps; location /api/ { proxy_pass http://127.0.0.1:8765/; proxy_set_header Host $host; } location / { try_files $uri $uri/ /index.html; } }",
          "snippet_full": "# /etc/nginx/sites-available/codehub.sj88ai.com\\n# Serves all 73 CodeHub apps + reverse-proxies /api to FastAPI on 8765\\nmap $http_origin $cors_origin {\\n  default \"\";\\n  \"~^https://codehub\\\\.sj88ai\\\\.com$\" $http_origin;\\n  \"~^http://localhost:5\\\\d{3}$\" $http_origin;  # dev\\n}\\nserver {\\n  listen 443 ssl http2;\\n  listen [::]:443 ssl http2;\\n  server_name codehub.sj88ai.com;\\n  root /srv/apps;\\n  index index.html;\\n  client_max_body_size 50M;\\n  add_header X-Frame-Options \"SAMEORIGIN\" always;\\n  add_header X-Content-Type-Options \"nosniff\" always;\\n  add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\\n  add_header Cross-Origin-Opener-Policy \"same-origin\" always;\\n  add_header Cross-Origin-Embedder-Policy \"require-corp\" always;  # ffmpeg.wasm SharedArrayBuffer\\n  gzip on; gzip_vary on; gzip_min_length 1024;\\n  gzip_types text/plain text/css application/json application/javascript text/xml application/xml;\\n  # CORS for /api/* and asset imports (jsdelivr/unpkg etc)\\n  location ~* ^/(api|assets)/ {\\n    add_header Access-Control-Allow-Origin $cors_origin always;\\n    add_header Access-Control-Allow-Methods \"GET, POST, OPTIONS\" always;\\n    add_header Access-Control-Allow-Headers \"Authorization, Content-Type\" always;\\n    if ($request_method = OPTIONS) { return 204; }\\n    # /api/* → FastAPI codehub-api service\\n    location ~* ^/api/ {\\n      proxy_pass http://127.0.0.1:8765;\\n      proxy_set_header Host $host;\\n      proxy_set_header X-Real-IP $remote_addr;\\n      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\\n      proxy_set_header X-Forwarded-Proto $scheme;\\n      proxy_read_timeout 60s;\\n    }\\n    # /assets/* — long cache for versioned static\\n    location ~* \\.(js|css|woff2|svg|png|jpg)$ {\\n      expires 1y; add_header Cache-Control \"public, immutable\";\\n      try_files $uri =404;\\n    }\\n  }\\n  # SPA fallback: every /SLUG/* that doesn't match a file → /SLUG/index.html\\n  location / {\\n    try_files $uri $uri/ /index.html;\\n    expires 1h; add_header Cache-Control \"public, must-revalidate\";\\n  }\\n}\\nserver {\\n  listen 80;\\n  server_name codehub.sj88ai.com;\\n  return 301 https://$host$request_uri;\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 day",
          "docs_url": "https://nginx.org/en/docs/",
          "pros": [
            "1 config file = 1 site; reload with `nginx -s reload` = zero-downtime config change (workers keep serving, new config loads on next request)",
            "try_files $uri $uri/ /index.html = SPA routing — any 404 falls back to index.html, lets React/Vue/Babylon apps use HTML5 pushState",
            "location priority predictable: exact (`= /api`) > prefix-with-=`^~` > regex (`~ \\.php$`) > plain prefix = no surprise matching",
            "proxy_pass + X-Forwarded-Proto/X-Forwarded-For = real client IP/host seen by FastAPI; otherwise logs all show 127.0.0.1",
            "gzip + brotli on text assets = 60-80% bandwidth saved on HTML/JS/CSS; `gzip_types` whitelist prevents wasted CPU on already-compressed jpg/png",
            "limit_req zone=api burst=20 nodelay = basic DDoS protection at edge; nginx handles 10k concurrent limits with ~16 bytes/IP memory"
          ],
          "cons": [
            "Config syntax strict — missing semicolon = entire site fails to reload (no partial apply); `nginx -t` before reload is mandatory",
            "No dynamic vhosts — adding 100 subdomains needs 100 server blocks OR wildcard cert (latter = security hole, any subdomain resolves)",
            "Default worker_processes = 1 — on 4-core VPS, set `worker_processes auto;` (auto = N CPU cores) to handle concurrent requests",
            "log_format default misses $request_time and $upstream_response_time — define your own; otherwise can't debug slow /api responses",
            "rate-limit zone memory = 16 bytes per IP × limit zones — high cardinality IPs (botnets) can OOM nginx on 1GB VPS",
            "`if` is evil: `if ($var = X)` creates a new context, breaks other directives (use `map` instead for variable assignment)"
          ],
          "gotchas": [
            "`location /api/` vs `location /api` (trailing slash) = completely different match — `/api` matches /api-v2/, `/api/` doesn't",
            "alias vs root: `location /static { root /var/www; }` appends full path (`/var/www/static/foo.png`); `alias /var/www/static;` strips prefix (`/static/foo.png` → `/var/www/static/foo.png`)",
            "Cache-Control: no-cache ≠ no-store — no-cache = must revalidate before serving cached; no-store = never store at all (different semantics)",
            "Worker connections default 768 — on busy sites, set `worker_rlimit_nofile 65535;` BEFORE `events { worker_connections 4096; }`",
            "If you `proxy_pass http://127.0.0.1:8000;` (with trailing /) the path is STRIPPED — `location /api/ { proxy_pass http://127.0.0.1:8000/; }` removes /api prefix",
            "try_files BEFORE fastcgi_pass = required, otherwise 404s go to FastAPI and choke it; order: try_files → fastcgi_pass → fastcgi_params",
            "After editing config, run `nginx -t` BEFORE `nginx -s reload` — typos take the WHOLE site down (last good config still in workers, new fails)",
            "Add HSTS header AFTER SSL is confirmed working: `add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;` — pre-SSL HSTS = self-inflicted outage"
          ],
          "anti_patterns": [
            "DON'T put site config in /etc/nginx/sites-enabled/ AND include it in nginx.conf twice (duplicate `listen 80` = `nginx: [emerg] duplicate listen options`)",
            "DON'T use `if` for redirects — use `return 301 https://$host$request_uri;` (3x faster, no context creation, easier to read)",
            "DON'T set `server_tokens off;` AND `more_clear_headers Server;` (use one, both = wasted work; server_tokens is enough)",
            "DON'T `proxy_pass http://upstream;` without `proxy_set_header Host $host;` — backend sees wrong host, virtual-hosted apps (multi-tenant) break",
            "DON'T gzip image files (jpg/png/webp/gif) — already compressed = waste CPU; only text/html/css/js/svg/json/xml"
          ],
          "prereqs": [
            "8.1"
          ],
          "next_steps": [
            "8.3",
            "8.6",
            "8.7"
          ],
          "tags": [
            "nginx",
            "vhost",
            "reverse-proxy",
            "try_files",
            "cors",
            "spa",
            "เว็บเซิร์ฟเวอร์",
            "พร็อกซี่"
          ],
          "compare_with": [
            "8.10 CDN (edge cache)",
            "Apache httpd (.htaccess per-dir)",
            "Caddy (auto-HTTPS, simpler config)"
          ]
        },
        {
          "id": "8.3",
          "title": "Let's Encrypt SSL (certbot + auto-renew)",
          "icon": "🔒",
          "description": "Free DV certs via certbot. Auto-renew via cron. 90-day expiry. Currently secures codehub.sj88ai.com + subdomains.",
          "when_to_use": "Every production HTTPS site; wildcard certs for *.codehub.sj88ai.com; staging: certbot --staging to avoid rate limits; new domain going live",
          "when_not_to_use": "Internal-only services on private network (self-signed OK); dev/localhost (mkcert); EV/OV certs needed (Let's Encrypt only gives DV, no green bar)",
          "examples": "codehub-homepage, karaoke-studio, stronghold-3d, noodle-shop",
          "snippet": "certbot --nginx -d codehub.sj88ai.com -d www.codehub.sj88ai.com -m ops@sj88ai.com --agree-tos; (crontab -l; echo '0 3 * * 1 /usr/bin/certbot renew --quiet --post-hook \"/usr/sbin/nginx -s reload\"') | crontab -",
          "snippet_full": "# /root/setup-ssl.sh — first-time certbot setup for codehub.sj88ai.com\\n#!/bin/bash\\nset -euo pipefail\\n\\n# Step 1: install certbot + nginx plugin\\napt-get update -qq\\napt-get install -y certbot python3-certbot-nginx\\n\\n# Step 2: obtain cert (HTTP-01 challenge, edits nginx config automatically)\\n# Add --staging first to test ACME flow without rate-limit risk\\ncertbot --nginx \\\\\\n  -d codehub.sj88ai.com \\\\\\n  -d www.codehub.sj88ai.com \\\\\\n  -d api.codehub.sj88ai.com \\\\\\n  --non-interactive --agree-tos \\\\\\n  -m ops@sj88ai.com \\\\\\n  --redirect  # auto-add 80→301 redirect\\n\\n# Step 3: verify cert\\nopenssl x509 -in /etc/letsencrypt/live/codehub.sj88ai.com/fullchain.pem -noout -subject -dates\\n\\n# Step 4: install auto-renew cron (Mon 03:00 SGT = low traffic, weekly = enough)\\n(crontab -l 2>/dev/null | grep -v certbot; echo '0 3 * * 1 /usr/bin/certbot renew --quiet --post-hook \"/usr/sbin/nginx -s reload && /usr/bin/systemctl restart codehub-api\"') | crontab -\\n\\n# Step 5: test renewal (dry-run, doesn't store cert)\\ncertbot renew --dry-run\\n\\n# Step 6: enable OCSP stapling (faster TLS handshake)\\nsed -i 's|# ssl_stapling on;|ssl_stapling on;\\n  ssl_stapling_verify on;|' /etc/nginx/sites-available/codehub.sj88ai.com\\nnginx -t && nginx -s reload\\necho 'SSL setup complete for codehub.sj88ai.com'",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://letsencrypt.org/docs/",
          "pros": [
            "Free DV certs (no paperwork, no payment, no email verification) for unlimited domains per cert (up to 100 SANs per cert)",
            "Auto-renew via `0 3 * * * certbot renew --quiet` = zero ops; certbot handles ACME challenge + nginx reload via --post-hook",
            "Wildcard via DNS-01 challenge = ONE cert for ALL subdomains (*.codehub.sj88ai.com); add a new subdomain = zero cert work",
            "90-day expiry = lower window of compromise if private key leaks (vs 1-year paid certs); shorter shelf life = less time to crack",
            "Pre-built nginx plugin = `certbot --nginx -d x.com` edits nginx config + reloads in one step; no manual CSR generation",
            "Rate limit is high (50 certs/week per domain, 5 duplicate) — won't bite legitimate use; staging server for dev"
          ],
          "cons": [
            "Rate-limited: 50 certs/week per registered domain, 5 duplicate; if you regenerate aggressively, you hit the wall (use --staging for dev)",
            "HTTP-01 challenge requires port 80 open + accessible from internet; if firewall blocks, use DNS-01 instead (needs DNS provider API)",
            "Wildcard certs only via DNS-01 = need API access to DNS provider (Cloudflare, Route53, DigitalOcean, Hetzner DNS, etc.)",
            "No EV/OV certs = no green bar in browser; some enterprise users / financial apps may distrust DV-only (rare in 2024)",
            "Cert lives in /etc/letsencrypt/live/ — symlinks to /archive/ — confused newbies edit archive (don't, edits lost on next renew)",
            "Auto-renew cron needs to be testable — add `--dry-run` flag to test, or you'll be paged at 3am by expired cert (oh the irony)"
          ],
          "gotchas": [
            "After `certbot --nginx`, it adds 2 listen lines (80→301 redirect, 443 ssl) — verify with `nginx -t` BEFORE reload; typo = 30min outage",
            "Cron PATH is minimal — use FULL paths: `0 3 * * * /usr/bin/certbot renew --quiet --post-hook '/usr/sbin/nginx -s reload'` not `certbot renew`",
            "If you revoke and re-issue, you may hit rate limit (5 duplicate/week) — use `certbot renew --force-renewal` only in real emergency",
            "DNS-01 challenge TTL: lower TXT-record TTL to 60s BEFORE certbot runs, restore after (otherwise 5min TTL = cert fails propagation)",
            "Port 80 MUST answer 200 for HTTP-01 — if you `return 301 https://` BEFORE the challenge, cert fails; certbot --nginx handles order correctly",
            "Let's Encrypt CA chain has 2 certs (R3 intermediate + ISRG Root X1) — older Android < 7.1.1 needs cross-signed ISRG Root X2; mostly fixed in 2024",
            "Certbot's --nginx plugin only works if it recognizes the vhost — custom config may need manual `include /etc/letsencrypt/options-ssl-nginx.conf;` line",
            "Test renewal: `certbot renew --dry-run` runs FULL ACME flow against staging server but doesn't store cert — run this BEFORE relying on cron"
          ],
          "anti_patterns": [
            "DON'T commit /etc/letsencrypt/ to git — private key leak = impersonate site FOREVER (no cert rotation will help if attacker has the key + DNS)",
            "DON'T run certbot interactively in CI — use `--non-interactive --agree-tos -m ops@sj88ai.com --no-eff-email` flags; otherwise CI hangs forever",
            "DON'T set cron to renew daily (1 success/day wastes rate limit slot); weekly (Mon 03:00) is plenty — 90 day cert has 60-day buffer",
            "DON'T ignore renew failures — `certbot renew` exit code is reliable; alert on non-zero in monitoring (8.7) to catch issues before expiry",
            "DON'T mix certbot certs with manually-installed certs in same nginx server block (different chain files = TLS handshake confusion, browsers see as invalid)"
          ],
          "prereqs": [
            "8.2"
          ],
          "next_steps": [
            "8.7"
          ],
          "tags": [
            "ssl",
            "tls",
            "certbot",
            "letsencrypt",
            "https",
            "renew",
            "ใบรับรอง",
            "ความปลอดภัย"
          ],
          "compare_with": [
            "paid DV (DigiCert, Sectigo) for EV/OV",
            "Cloudflare (auto-SSL + DDoS)",
            "Caddy (auto-HTTPS built-in)"
          ]
        },
        {
          "id": "8.4",
          "title": "Paramiko (Python SSH + SFTP)",
          "icon": "🔌",
          "description": "Pure-Python SSH2 + SFTP client. Powers ship.py (8.1) deploy automation from sandbox to VPS without ssh CLI in PATH.",
          "when_to_use": "Python deploy scripts (ship.py 8.1); ad-hoc remote commands from code; multi-host orchestration; reading remote logs/files via SFTP; running in Lambda/Cloud Functions/sandbox envs without ssh CLI",
          "when_not_to_use": "Multi-dev CI (use GitHub Actions 8.5); pure bash deploys with ssh CLI (simpler, faster); file transfers > 100MB (rsync over SSH beats paramiko 2x); high-frequency connections (use asyncssh)",
          "examples": "codehub-homepage, karaoke-studio, stronghold-3d, noodle-shop",
          "snippet": "import os, paramiko; c=paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy()); c.connect('codehub.sj88ai.com', 22, 'root', password=os.environ['DEPLOY_VPS_PASS']); sftp=c.open_sftp(); sftp.put('app.html', '/srv/apps/x/index.html'); sftp.close(); c.close()",
          "snippet_full": "# Paramiko — robust deploy + verify + error-handling pattern (ship.py style)\\nimport os, sys, hashlib, paramiko\\nfrom paramiko import SSHClient, AutoAddPolicy, RSAKey\\nfrom pathlib import Path\\n\\nHOST, USER, PORT = 'codehub.sj88ai.com', 'root', 22\\nPASSWORD = os.environ['DEPLOY_VPS_PASS']  # NEVER inline\\nLOCAL, REMOTE = sys.argv[1], sys.argv[2]\\n\\nclient = SSHClient()\\nclient.set_missing_host_key_policy(AutoAddPolicy())  # OK for dev, switch to RejectPolicy in prod\\nclient.connect(HOST, PORT, USER, password=PASSWORD, timeout=15, banner_timeout=30, auth_timeout=30)\\n\\n# Tweak transport for fast large transfers\\ntransport = client.get_transport()\\ntransport.default_window_size = 2147483647\\ntransport.default_packet_size = 32768\\ntransport.set_keepalive(30)  # prevent NAT/firewall drop\\n\\n# SFTP upload with MD5 verification\\nlocal_md5 = hashlib.md5(Path(LOCAL).read_bytes()).hexdigest()\\nsftp = client.open_sftp()\\ntry:\\n  sftp.put(LOCAL, REMOTE, callback=lambda done, total: print(f'\\r  upload: {done}/{total} bytes ({100*done//total}%)', end=''))\\n  print()  # newline after progress\\nfinally:\\n  sftp.close()\\n\\n# Server-side MD5 verify\\n_, stdout, stderr = client.exec_command(f'md5sum {REMOTE}')\\nremote_md5 = stdout.read().decode().split()[0]\\nif remote_md5 != local_md5:\\n  print(f'FAIL: MD5 mismatch local={local_md5} remote={remote_md5}', file=sys.stderr)\\n  client.exec_command(f'rm {REMOTE}')  # cleanup corrupted file\\n  sys.exit(1)\\nprint(f'OK: {LOCAL} → {HOST}:{REMOTE} md5={remote_md5[:8]}')\\n\\n# Post-deploy command (e.g., restart service, refresh cache)\\n_, stdout, stderr = client.exec_command('systemctl reload codehub-api && echo reloaded')\\nstdout.channel.recv_exit_status()  # blocks until exit code available\\nclient.close()",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://www.paramiko.org/",
          "pros": [
            "Pure Python = no ssh CLI binary in container; works in Lambda, Cloud Functions, sandbox envs where /usr/bin/ssh missing",
            "SFTP client built-in (sftp.put, sftp.get) = one library for shell exec + file transfer + remote stat; no separate pysftp dep",
            "AutoAddPolicy = no `known_hosts` prompt in fresh dev envs; still warns in prod (use RejectPolicy + ship known_hosts to deploy hosts)",
            "Channel-level exec gives you stdout/stderr/exit-code = real shell semantics in Python; run `nginx -s reload` and check exit code",
            "Pluggable auth: password ($DEPLOY_VPS_PASS), key file, ssh-agent, GSSAPI = same API for all SSH needs; no separate paramiko RSA / ECDSA libs",
            "Async support via `asyncssh` (drop-in compatible) if you need 100+ concurrent connections; paramiko itself is sync, GIL-friendly"
          ],
          "cons": [
            "Slower than ssh CLI for large files (paramiko uses pure-Python SFTP vs OpenSSH's optimized C); 50MB+ = noticeable delay (use scp/rsync instead)",
            "No native ProxyCommand support (jump hosts / bastion) — needs `paramiko.ProxyCommand` workaround with manual channel setup; ssh -J is simpler",
            "AutoAddPolicy = MITM vulnerability in untrusted networks — always use `RejectPolicy` in prod with known_hosts pre-shipped to deployer",
            "Connection pool not built-in — you must wrap in your own context manager; long-lived process leaks FDs if you forget client.close()",
            "Python 3.12+ deprecated some threading APIs paramiko used; need to keep paramiko >= 3.4 for cryptography library compatibility",
            "No built-in retry/backoff — if VPS is rebooting, your script crashes with ConnectionError; wrap in try/except + exponential backoff yourself"
          ],
          "gotchas": [
            "NEVER hardcode password — `password=os.environ['DEPLOY_VPS_PASS']` reads from env at runtime; `password = 'hunter2'` ends up in git history forever",
            "Use `transport.default_window_size = 2147483647` for files > 100MB (default 2MB = slow); also `transport.default_packet_size = 32768`",
            "SFTP `put()` callback gives bytes-transferred — show progress bar with `tqdm` wrapper: `sftp.put(local, remote, callback=lambda x,y: progress.update(y-x))`",
            "Channel `recv()` BLOCKS — use `channel.recv_ready()` to check before reading (avoid timeout); also set `timeout=` in exec_command for slow commands",
            "After `exec_command`, MUST call `stdout.channel.recv_exit_status()` or you'll race the close — exit code 0 = success, non-zero = failed",
            "On key auth, key file must be mode 600 (chmod 600 ~/.ssh/id_rsa) or sshd rejects; paramiko doesn't auto-chmod; CI secrets often have wrong perms",
            "Keep-alive: set `transport.set_keepalive(30)` or NAT/firewall drops connection after 60s of idle (Cloudflare Tunnel, AWS NAT Gateway drop)",
            "Closing client: `client.close()` is enough — don't manually close channel/sftp first (race condition; close client = close all transports)"
          ],
          "anti_patterns": [
            "DON'T use `set_missing_host_key_policy(AutoAddPolicy())` in PROD — MITM risk; first deploy can pin, then switch to RejectPolicy",
            "DON'T store password in script as string — `password = 'mypassword'` ends up in git history; use env var or secrets manager (1Password, AWS SM)",
            "DON'T reuse SSHClient across threads — each thread needs its own (paramiko is not thread-safe per client); use threading.local() or per-thread client",
            "DON'T exec raw user input via channel — shell injection (`; rm -rf /` in command = disaster); use paramiko's invoke_shell with explicit command array",
            "DON'T skip `transport.close()` on error paths — connection leaks fill your VPS file descriptors (`ulimit -n` = 1024 default; 200 deploys = OOM)"
          ],
          "prereqs": [
            "8.1"
          ],
          "next_steps": [
            "8.5",
            "8.6"
          ],
          "tags": [
            "paramiko",
            "ssh",
            "sftp",
            "python",
            "deploy",
            "automation",
            "รีโมต",
            "ssh-python"
          ],
          "compare_with": [
            "ssh CLI (faster, simpler)",
            "asyncssh (async)",
            "Fabric (higher-level wrapper)",
            "8.5 GitHub Actions (managed CI)"
          ]
        },
        {
          "id": "8.5",
          "title": "GitHub Actions (CI/CD + matrix + secrets)",
          "icon": "⚙️",
          "description": "CI/CD — test on push + auto deploy. Free for public repos. matrix strategy, cache, secrets, reusable workflows. For OS/multi-dev CodeHub projects.",
          "when_to_use": "OS projects (stronghold-3d, realm-of-heroes repo); multi-dev team; need PR checks before merge; need matrix (Node 18/20/22); scheduled jobs (nightly build); release automation",
          "when_not_to_use": "Single-dev solo with ship.py (8.1) already working; prototype on private sandbox; < 5 min manual deploys (just ssh); need self-hosted secrets access from forks (GH security model)",
          "examples": "stronghold-3d, realm-of-heroes, codehub-homepage",
          "snippet": "# .github/workflows/deploy.yml\\non: { push: { branches: [main] } }\\njobs:\\n  deploy: { runs-on: ubuntu-latest; steps: [uses: actions/checkout@v4, run: ./ship.sh]",
          "snippet_full": "# .github/workflows/deploy.yml — codehub auto-deploy (stronghold-3d pattern)\\nname: Deploy to codehub.sj88ai.com\\n\\non:\\n  push:\\n    branches: [main]\\n  workflow_dispatch:  # manual trigger from Actions UI\\n\\nconcurrency:\\n  group: deploy-${{ github.ref }}\\n  cancel-in-progress: true\\n\\njobs:\\n  test:\\n    runs-on: ubuntu-latest\\n    timeout-minutes: 10\\n    steps:\\n      - uses: actions/checkout@v4\\n      - uses: actions/setup-node@v4\\n        with: { node-version: '20', cache: 'npm' }\\n      - run: npm ci\\n      - run: npm run lint\\n      - run: npm run test\\n      - run: npm run build\\n      - uses: actions/upload-artifact@v4\\n        with: { name: dist, path: dist/ }\\n\\n  deploy:\\n    needs: test\\n    runs-on: ubuntu-latest\\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\\n    environment: production  # requires approval in GH UI\\n    steps:\\n      - uses: actions/download-artifact@v4\\n        with: { name: dist, path: dist/ }\\n      - name: Push to VPS via ship.py\\n        env:\\n          DEPLOY_VPS_PASS: ${{ secrets.DEPLOY_VPS_PASS }}\\n          API_ADMIN_TOKEN: ${{ secrets.API_ADMIN_TOKEN }}\\n        run: |\\n          python3 -m pip install --quiet paramiko requests\\n          python3 ship.py --name ${{ github.event.repository.name }} \\\\\\n            --file dist/index.html \\\\\\n            --title \"${{ github.event.repository.name }}\" \\\\\\n            --version \"${{ github.sha }}\"\\n      - name: Notify Telegram on success\\n        if: success()\\n        run: |\\n          curl -s \"https://api.telegram.org/bot${{ secrets.TG_BOT_TOKEN }}/sendMessage\" \\\\\\n            -d \"chat_id=${{ secrets.TG_CHAT_ID }}\" \\\\\\n            -d \"text=✅ Deployed ${{ github.repository }}@${{ github.sha[:7] }}\"",
          "difficulty": "Intermediate",
          "time_to_learn": "1 day",
          "docs_url": "https://docs.github.com/en/actions",
          "pros": [
            "Free for public repos = unlimited CI minutes; 2000 min/month for private repos (plenty for 5-10 commits/day per repo)",
            "Matrix builds = test Node 18/20/22 in parallel with 1 line: `strategy: { matrix: { node: [18, 20, 22] } }` — catches version-specific bugs",
            "Cache npm/pip via `actions/cache@v4` = 5x faster installs after first run; key on lockfile hash so cache invalidates on dep changes",
            "Self-hosted runners = unlimited minutes + access to internal network (e.g., ship.py to VPS that has firewall); tag with labels for routing",
            "Reusable workflows = share deploy logic across 10 repos without copy-paste; `uses: org/codehub/.github/workflows/deploy.yml@v1`",
            "Secrets via GitHub UI = `${{ secrets.DEPLOY_VPS_PASS }}` injected at runtime, NEVER in PR logs (PR from fork = no secrets, by design)"
          ],
          "cons": [
            "Debug = read YAML logs (no live shell, no breakpoints) — 30 min to fix a typo in a 200-line workflow; SSH debug action ($/mo) is the workaround",
            "GitHub-hosted runners = no persistent state (each job = fresh Ubuntu VM, 7GB RAM); cache is your only continuity; can't install once-and-keep",
            "Free tier = 2000 min/month private; heavy repos (build iOS, run E2E) burn through it fast ($0.008/min over); use self-hosted for unlimited",
            "No native SSH to private VPS without storing credentials in secrets (audit-trail cost; rotate quarterly; never log ${{ secrets.X }} in run: blocks)",
            "Pull request from FORK = NO secrets access (security = expected, but blocks external contribs from running deploy steps); use PR-target branches instead",
            "Workflows are YAML + Jinja — version control them, but tests-for-tests (meta-CI) is hard; yaml-lint via pre-commit is your only safety net"
          ],
          "gotchas": [
            "Cache key MUST include lockfile hash: `key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}` — without hash, cache never invalidates on dep change",
            "Use `actions/checkout@v4` with `fetch-depth: 0` for git-based tooling (semantic-release, changelog generators need full history)",
            "Step-level `if: github.event_name == 'push' && github.ref == 'refs/heads/main'` for env-specific deploys; different stages = different triggers",
            "Job timeout default = 360 min (6h!); set `timeout-minutes: 15` to fail fast on hangs; per-step `timeout-minutes: 5` for individual commands",
            "Self-hosted runners: tag them `runs-on: [self-hosted, linux, x64, gcp-fra1]` to avoid stealing for wrong job; register once, reuse forever",
            "Concurrency group: `concurrency: { group: ${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true }` = no double-deploys on rapid pushes (5 pushes in 1min = 1 deploy)",
            "Use `env:` block, NOT inline `${{ secrets.X }}` in `run:` — Jinja substitution in shell = escaping nightmare (quote, $, backtick); env block = clean",
            "If you `uses: third-party/action@v3`, PIN to SHA not tag: `uses: actions/checkout@<40-char-sha>` (tags can be re-published maliciously — 2021 nx compromise)"
          ],
          "anti_patterns": [
            "DON'T run `npm install` without `npm ci` in CI — `ci` is lockfile-pinned (reproducible), `install` is permissive (modifies lockfile, surprise breakage)",
            "DON'T put `${{ secrets.X }}` directly in a `run:` step — echo'd in plain-text logs visible to anyone with repo read; use `env:` block (`env: KEY: ${{ secrets.X }}`)",
            "DON'T use `actions/upload-artifact@v3` — DEPRECATED and broken in Node 20 (v3.4+); use `@v4` always; v3 = silent failure on GitHub Enterprise",
            "DON'T `uses: actions/checkout@main` — pin to `@v4` or SHA (main branch can be hijacked via PR); v3 is also deprecated (uses Node 16 = EOL)",
            "DON'T ignore step failures — `continue-on-error: true` masks real problems; use only for non-critical steps like Slack notifications (failed deploy = still notify)"
          ],
          "prereqs": [
            "1.1"
          ],
          "next_steps": [
            "8.4",
            "8.9"
          ],
          "tags": [
            "github-actions",
            "ci-cd",
            "workflow",
            "matrix",
            "deploy",
            "ci",
            "อัตโนมัติ",
            "ทดสอบ"
          ],
          "compare_with": [
            "8.1 ship.py (local)",
            "GitLab CI (self-host)",
            "Jenkins (legacy, full control)",
            "CircleCI (faster matrix)"
          ]
        },
        {
          "id": "8.6",
          "title": "systemd (Service Manager + Auto-Restart)",
          "icon": "⚙️",
          "description": "Long-running services — uvicorn/codehub-api with Restart=always, EnvironmentFile, journalctl logging, Type=notify for readiness signal.",
          "when_to_use": "Every backend service (uvicorn, gunicorn, node, redis-server, codehub-api FastAPI); background workers; long-lived daemons; production-only service management",
          "when_not_to_use": "One-shot tasks (use systemd .timer or cron); interactive processes (tmux/screen for human dev); containers (use k8s/docker for orchestration); dev/local (just `python app.py` in terminal)",
          "examples": "codehub-api, karaoke-studio-export-worker",
          "snippet": "# /etc/systemd/system/codehub-api.service\\n[Service]\\nExecStart=/usr/bin/uvicorn app:app --host 127.0.0.1 --port 8765\\nRestart=always\\nWorkingDirectory=/srv/codehub-api\\nEnvironmentFile=/etc/codehub/api.env",
          "snippet_full": "# /etc/systemd/system/codehub-api.service\\n# codehub-api FastAPI service — production-hardened unit\\n[Unit]\\nDescription=codehub-api (FastAPI) for codehub.sj88ai.com\\nDocumentation=https://codehub.sj88ai.com/skill/8.6\\nAfter=network.target nss-lookup.target\\nWants=network-online.target\\n\\n[Service]\\nType=notify  # wait for sd_notify(READY=1) from uvicorn\\nNotifyAccess=main\\nUser=codehub\\nGroup=codehub\\nWorkingDirectory=/srv/codehub-api\\nEnvironmentFile=/etc/codehub/api.env  # 600 perms, root-owned\\nExecStart=/srv/codehub-api/venv/bin/uvicorn app:app \\\\\\n  --host 127.0.0.1 \\\\\\n  --port 8765 \\\\\\n  --workers 2 \\\\\\n  --log-level info\\nExecReload=/bin/kill -HUP $MAINPID  # graceful reload\\nKillSignal=SIGTERM  # uvicorn handles gracefully\\nTimeoutStopSec=30\\n# Auto-restart with rate limit (3 crashes in 60s = give up, page oncall)\\nRestart=always\\nRestartSec=5\\nStartLimitIntervalSec=60\\nStartLimitBurst=3\\n# Resource limits (OOM-kill at 512M, throttle at 50% CPU)\\nMemoryMax=512M\\nMemoryHigh=384M\\nCPUQuota=50%\\n# Hardening (service can't tamper with host)\\nNoNewPrivileges=true\\nProtectSystem=strict\\nProtectHome=true\\nPrivateTmp=true\\nReadWritePaths=/srv/codehub-api /var/lib/codehub\\n# Don't expose service to network directly — nginx (8.2) proxies\\nPrivateNetwork=false  # needs network for DB connect\\n\\n[Install]\\nWantedBy=multi-user.target\\n\\n# Setup:\\n#   sudo useradd -r -s /usr/sbin/nologin codehub\\n#   sudo chown -R codehub:codehub /srv/codehub-api\\n#   sudo install -m 600 api.env /etc/codehub/api.env\\n#   sudo systemctl daemon-reload\\n#   sudo systemctl enable --now codehub-api\\n#   sudo journalctl -u codehub-api -f  # watch logs",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://www.freedesktop.org/software/systemd/man/systemd.service.html",
          "pros": [
            "Restart=always = service recovers from crashes in < 5s (tested with `kill -9 $(pidof uvicorn)` → systemd restarts in 2.3s); SLA-grade",
            "Type=notify + sd_notify() = systemd knows when service is ACTUALLY ready (waits for HTTP 200 from /api/health before routing traffic) — no race conditions",
            "EnvironmentFile=/etc/codehub/api.env = secrets NOT in unit file, NOT in shell history; perms 600 = secrets visible only to root",
            "WorkingDirectory + ReadOnlyPaths/ReadWritePaths = service can't accidentally `rm -rf /` (whole FS marked RO except /var/lib/codehub)",
            "journalctl -u codehub-api -f = structured logs with rotation (no logrotate needed); systemd handles log file size + retention",
            "`systemctl status codehub-api` = PID, memory, restart count, last 10 log lines in ONE command; ops debugging = 5s not 5min"
          ],
          "cons": [
            "systemd-specific (Linux only) — Mac dev / Windows WSL requires Docker alternative; Alpine uses OpenRC (different syntax)",
            "Unit file syntax = learn a new format (INI-like with sections [Unit]/[Service]/[Install]); errors are cryptic (`status=203/EXEC` = no such file)",
            "Restart=always WITHOUT StartLimitBurst = crash loop floods logs (OOM killer hits 100x in 1 min = 100 log lines); set `StartLimitIntervalSec=60, StartLimitBurst=3`",
            "Environment vars from .env are NOT auto-loaded (unlike Docker) — need `EnvironmentFile=/etc/codehub/api.env` or explicit `Environment=KEY=VAL` in unit",
            "Service can't see user's SSH agent keys (default `PrivateTmp=yes` hides /tmp + agent socket); use `LoadCredential=` for keys instead",
            "Hard to debug in dev: `systemctl --user` doesn't work for services in /etc/systemd/system/ — must be in ~/.config/systemd/user/ AND XDG_RUNTIME_DIR set"
          ],
          "gotchas": [
            "After editing unit file: `systemctl daemon-reload` THEN `systemctl restart codehub-api` — forgot reload = old config still in effect (silent no-op)",
            "`systemctl enable` only creates symlinks — DOESN'T start. Use `systemctl enable --now` for both (saves 1 command, common typo trap)",
            "Logs: `journalctl -u codehub-api --since '1 hour ago' -p err` = filter by time + severity; `-p err` shows only err/crit/alert/emerg levels",
            "User=root is dangerous — prefer `User=codehub`, `Group=codehub` with `chown -R codehub:codehub /srv/app`; unit file runs as that user",
            "Hardening: `NoNewPrivileges=true`, `ProtectSystem=strict`, `PrivateTmp=true`, `ProtectHome=true` = service can't tamper with host even as codehub user",
            "Graceful stop: `KillSignal=SIGTERM` and Python handles it (uvicorn exits cleanly, finishes in-flight requests); SIGKILL = data loss + 502s for 1s",
            "Resource limits: `MemoryMax=512M`, `CPUQuota=50%` = OOM-killer doesn't take down whole VPS; service throttles itself, gets a clean shutdown",
            "Type=simple (default) starts immediately; Type=notify waits for sd_notify(READY=1); wrong type = service marked 'running' before actually ready → load balancer sends traffic to dead instance"
          ],
          "anti_patterns": [
            "DON'T use `Type=oneshot` for long-lived service — systemd thinks it ended and won't restart (oneshot is for `backup.sh` that runs + exits cleanly)",
            "DON'T put `ExecStart=/usr/bin/python3 app.py` without `Restart=always` — one crash = service down forever until manual `systemctl start`",
            "DON'T put env vars in unit file (`Environment=DB_PASS=hunter2`) — file readable by anyone with sudo / ls -la; use EnvironmentFile with 600 perms",
            "DON'T use `User=root` unless absolutely necessary — privilege escalation blast radius (one RCE in app = root on VPS); prefer codehub user + setcap for specific ports",
            "DON'T skip `systemctl daemon-reload` after editing unit — silent: old config still active, new directives ignored; 5min debug time wasted on this"
          ],
          "prereqs": [
            "8.1"
          ],
          "next_steps": [
            "8.7"
          ],
          "tags": [
            "systemd",
            "service",
            "daemon",
            "uvicorn",
            "fastapi",
            "restart",
            "journalctl",
            "เซอร์วิส",
            "ผู้ดูแล"
          ],
          "compare_with": [
            "8.9 Docker (portable)",
            "supervisord (legacy, Python)",
            "PM2 (Node-only)",
            "tmux/screen (dev only)"
          ]
        },
        {
          "id": "8.7",
          "title": "Monitoring (Uptime + /api/health + Alerts)",
          "icon": "📊",
          "description": "Uptime monitoring via /api/health endpoint, log scraping via journald→Loki, alerts to Telegram. Catches issues before users do.",
          "when_to_use": "Production services (codehub-api); uptime SLA needs (>99% = need monitoring); capacity planning (CPU/mem trends); on-call rotation; multi-VPS fleet",
          "when_not_to_use": "Pre-launch (just dev); single-page static site (use Cloudflare Analytics free tier); non-critical internal tools (low cost of downtime = no monitoring needed); hobby project (~$0 budget)",
          "examples": "codehub-api, karaoke-studio-export-worker",
          "snippet": "@app.get('/api/health') def health(): return {'status':'ok','uptime':int(time()-start),'projects':len(projects),'version':VERSION}",
          "snippet_full": "# /srv/codehub-api/app.py — /api/health endpoint (FastAPI)\\nimport os, time, psutil\\nfrom fastapi import FastAPI, Response, status\\nfrom sqlalchemy import text\\n\\napp = FastAPI()\\nSTART_TIME = time.time()\\nVERSION = os.environ.get('APP_VERSION', 'dev-local')\\n\\n@app.get('/api/health')\\nasync def health(response: Response):\\n    \"\"\"Health check: 200 if ok, 503 if degraded.\\n    Includes version, uptime, project count, DB ping, disk usage.\\n    \"\"\"\\n    checks = {}\\n    overall_ok = True\\n    # DB ping (200ms timeout)\\n    try:\\n      with db.connect() as conn:\\n        conn.execute(text('SELECT 1'))\\n      checks['db'] = 'ok'\\n    except Exception as e:\\n      checks['db'] = f'fail: {e}'\\n      overall_ok = False\\n    # Disk usage (alert at 90%)\\n    disk = psutil.disk_usage('/')\\n    checks['disk_pct'] = disk.percent\\n    if disk.percent > 90:\\n      checks['disk'] = 'critical'\\n      overall_ok = False\\n    elif disk.percent > 80:\\n      checks['disk'] = 'warning'\\n    # Memory (alert at 90%)\\n    mem = psutil.virtual_memory()\\n    checks['mem_pct'] = mem.percent\\n    # Project count (sanity: should be ~73)\\n    try:\\n      n = db.execute(text('SELECT COUNT(*) FROM projects')).scalar()\\n      checks['projects'] = n\\n    except Exception:\\n      checks['projects'] = -1\\n    response.status_code = status.HTTP_200_OK if overall_ok else status.HTTP_503_SERVICE_UNAVAILABLE\\n    return {\\n      'status': 'ok' if overall_ok else 'degraded',\\n      'version': VERSION,\\n      'uptime_sec': int(time.time() - START_TIME),\\n      'checks': checks,\\n      'ts': time.time(),\\n    }\\n\\n# /etc/cron.d/disk-monitor — alert on disk > 90% to Telegram\\n# */15 * * * * root [ $(df / | tail -1 | awk '{print $5}' | tr -d '%') -gt 90 ] && curl -s -X POST \"https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage\" -d \"chat_id=${TG_CHAT_ID}&text=⚠️ VPS disk >90%: $(df -h /)\"",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://prometheus.io/docs/",
          "pros": [
            "/api/health endpoint = 1 endpoint to tell you service is up (200 + JSON); no agent on host, no sidecar; UptimeRobot/Pingdom can hit it for free",
            "Pull-based: UptimeRobot/Pingdom hits /api/health every 60s; alert if 3 consecutive misses (180s = real outage, not network blip)",
            "Log scraping via journald → Loki → Grafana = query logs by app, time, regex; free tier works for small VPS (50GB/month logs)",
            "Prometheus + node_exporter = CPU, memory, disk, network per VPS (free, self-hostable); Grafana dashboards in 1 hour",
            "Synthetic checks = curl /api/projects every 5min, alert if 0 results returned (catches silent corruption: API returns 200 but empty data)",
            "Alerts to Telegram/Slack via webhook = 0-cost paging (no PagerDuty subscription, $0/month); ack via emoji reaction, thread for runbook"
          ],
          "cons": [
            "Pull-based monitoring misses 90s windows if monitor itself is down (UptimeRobot has 99.99% SLA but their outage = your false alarm); use 2+ providers",
            "/api/health returns 200 doesn't mean service is functional — add DB ping, cache hit check, recent-write test (silent 200 with broken DB is real)",
            "Self-hosted Prometheus + Grafana = another thing to monitor (dogfooding paradox); for solo dev, prefer SaaS (Grafana Cloud free = 10k metrics)",
            "Log volume grows fast — 100MB/day/host is normal; 30-day retention = 3GB = $$; sample 10% of info logs, keep 100% of error logs",
            "Alert fatigue is real — 10 alerts/day = nobody reads them; tune to 1 alert/day = page-worthy (combine related signals into 1 alert)",
            "Metrics without context = useless (Prometheus: `up=1` means what? need labels: `up{instance=\"vps-fra1\", job=\"codehub-api\"} = 1`); label everything"
          ],
          "gotchas": [
            "/api/health should return 200 even if DEGRADED (load balancer takes it out of rotation only if 503); 200/JSON with `degraded: true` is signal, not outage",
            "Include version + uptime + project count in /api/health — debug 'is this the new deploy?' in 1 call (no SSH to read /srv/codehub-api/VERSION)",
            "Don't put heavy logic in /api/health — every monitor hits it; if health check takes 2s, that's bad (cascades to 60s+ under load)",
            "Log time format: ISO 8601 + UTC (`logger.info(json.dumps({ts: datetime.utcnow().isoformat() + 'Z', level, msg}))`) — no local time confusion in 30s of log",
            "Monitor the monitor: run a cron that hits your monitoring URL — if monitor is down, alert via DIFFERENT channel (SMS via Twilio = $0.0079/msg)",
            "SLO ≠ SLI ≠ SLA: SLI = measurement (99.9% uptime), SLO = target (99.9 over 30 days), SLA = contract (refund if breached); SLO is internal target, SLA is customer-facing",
            "Disk fills up silently — alert on `df -h | grep '9[0-9]%'` via cron + Telegram (no Prometheus needed); full disk = 503s + data loss",
            "Use health check with random nonce to defeat cache: `/api/health?t=$RANDOM`; some nginx configs cache /api/* responses (8.2 `expires 1h`)"
          ],
          "anti_patterns": [
            "DON'T alert on every 5xx — alert on rate > 1%/min (otherwise log noise = alert noise = oncall ignores all = misses real outage); aggregate first",
            "DON'T have /api/health that hits the DB (down DB = health check fails = load balancer pulls it = no recovery = stuck in degraded forever); test cache/queue only",
            "DON'T use email-only alerts (delayed 5-15min, spammed, no ack) — webhook to chat = real-time + ack-able (Telegram/Slack free)",
            "DON'T monitor without runbook (alert fires, no one knows what to do = wake up, read code, fix = 2h instead of 5min); write runbook in alert message",
            "DON'T trust uptime monitors that share your DNS — if DNS dies, monitor says 'your site is down' but service is fine; use IP-based ping as ground truth"
          ],
          "prereqs": [
            "8.6"
          ],
          "next_steps": [
            "8.8"
          ],
          "tags": [
            "monitoring",
            "uptime",
            "health",
            "prometheus",
            "loki",
            "alert",
            "sre",
            "ตรวจสอบ",
            "แจ้งเตือน"
          ],
          "compare_with": [
            "8.6 systemd (built-in journald)",
            "UptimeRobot (free SaaS)",
            "Datadog ($$$)",
            "Prometheus+Grafana (self-host)"
          ]
        },
        {
          "id": "8.8",
          "title": "Backup (3-2-1 rule, 16TB XFS, rsync)",
          "icon": "💾",
          "description": "rsync + 16TB XFS at /mnt/16tb/codehub + 3-2-1 rule (3 copies, 2 media types, 1 offsite). Nightly cron, monthly restore-test.",
          "when_to_use": "Production data (SQLite DB, user uploads, configs); recovery SLA < 1 day; data > 1GB; regulatory compliance (GDPR data residency); pre-launch baseline",
          "when_not_to_use": "Disposable dev data; OS itself (re-installable, don't backup /); static assets already on CDN (re-fetchable); < 100MB (git is enough); ephemeral containers (no persistent state)",
          "examples": "codehub-api, karaoke-studio, flowboard, knowledge-garden",
          "snippet": "rsync -aHAX --delete /srv/apps /mnt/16tb/backup/$(date +%Y%m%d)/ && rsync -aHAX /mnt/16tb/backup/latest/ b2:codehub-backup/",
          "snippet_full": "# /etc/cron.daily/codehub-backup — 3-2-1 backup pipeline\\n#!/bin/bash\\nset -euo pipefail\\n\\nBACKUP_ROOT=/mnt/16tb/codehub/backups\\nTODAY=$(date +%Y%m%d)\\nLATEST_LINK=$BACKUP_ROOT/latest\\nB2_BUCKET=b2:codehub-backup  # restic/rclone-style remote\\nGPG_KEY=/root/.b2_key  # 600 perms\\nLOG_FILE=/var/log/codehub-backup.log\\n\\nexec > >(tee -a $LOG_FILE) 2>&1\\necho \"=== backup started $(date -Iseconds) ===\"\\n\\n# Pre-flight: disk space check (need 20% free for deltas)\\nFREE_PCT=$(df --output=pcent $BACKUP_ROOT | tail -1 | tr -d '% ')\\nif [ \"$FREE_PCT\" -gt 80 ]; then\\n  echo \"ABORT: only $((100-FREE_PCT))% free on $BACKUP_ROOT\" | \\ mail -s 'codehub backup FAIL' ops@sj88ai.com\\n  curl -s -X POST \"https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage\" \\\\\\n    -d \"chat_id=${TG_CHAT_ID}&text=⚠️ codehub backup ABORTED: disk $((100-FREE_PCT))% free\"\\n  exit 1\\nfi\\n\\n# Step 1: dump SQLite/FastAPI DB (consistent snapshot)\\nmkdir -p $BACKUP_ROOT/$TODAY/db\\nsqlite3 /srv/codehub-api/codehub.db \".backup $BACKUP_ROOT/$TODAY/db/codehub.db\"\\ngzip $BACKUP_ROOT/$TODAY/db/codehub.db\\n\\n# Step 2: rsync /srv/apps (delta, hard-link tree for cheap incremental)\\n# -aHAX = archive + hard-links + ACLs + xattrs; --link-dest to latest snapshot = near-zero space\\nmkdir -p $BACKUP_ROOT/$TODAY/apps\\nrsync -aHAX --delete \\\\\\n  --link-dest=$LATEST_LINK/apps \\\\\\n  /srv/apps/ \\\\\\n  $BACKUP_ROOT/$TODAY/apps/\\n\\n# Step 3: rsync /etc/codehub (configs, env files, nginx site configs)\\nmkdir -p $BACKUP_ROOT/$TODAY/etc\\nrsync -aHAX /etc/codehub/ $BACKUP_ROOT/$TODAY/etc/\\nrsync -aHAX /etc/nginx/sites-available/ $BACKUP_ROOT/$TODAY/etc/nginx/\\n\\n# Step 4: update 'latest' symlink (for --link-dest baseline)\\nrm -f $LATEST_LINK\\nln -s $BACKUP_ROOT/$TODAY $LATEST_LINK\\n\\n# Step 5: offsite to B2 (encrypted)\\ntar czf - $BACKUP_ROOT/$TODAY | gpg -c --batch --passphrase-file $GPG_KEY | rclone rcat $B2_BUCKET/${TODAY}.tar.gz.gpg\\n\\n# Step 6: cleanup old local backups (keep 30 days, B2 keeps 90 days)\\nfind $BACKUP_ROOT -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +\\n\\n# Step 7: monthly restore-test reminder (1st of month)\\nif [ \"$(date +%d)\" = \"01\" ]; then\\n  echo 'REMINDER: monthly restore test due' | mail -s 'codehub restore-test' ops@sj88ai.com\\nfi\\n\\necho \"=== backup completed $(date -Iseconds) — size $(du -sh $BACKUP_ROOT/$TODAY | cut -f1) ===\"",
          "difficulty": "Beginner",
          "time_to_learn": "1 hour",
          "docs_url": "https://rsync.samba.org/documentation.html",
          "pros": [
            "3-2-1 rule = industry standard: 3 copies (primary + 2 backups), 2 media types (XFS disk + B2 cloud), 1 offsite (B2 encrypted); survives 2 simultaneous failures",
            "rsync -a = only DELTAS transferred after first sync (16TB → 16TB after = 1GB transfer for daily changes); 2-hour nightly run vs 16-hour full copy",
            "XFS at /mnt/16tb/codehub = handles 8M files in single directory (ext4 limits to ~1M = 1.7M projects case-study); xfs_freeze for atomic snapshots",
            "Cron daily at 02:00 SGT = low contention; can layer with --link-dest for cheap incremental snapshots (hard-link tree = near-zero space)",
            "Offsite via rsync.net or Backblaze B2 = $5/TB/month; encrypted at rest (gpg -c) = safe to use public cloud storage (B2 leak = encrypted blob)",
            "Verification: `rsync --checksum` on monthly schedule catches silent corruption (bit rot = 1 in 1M chance per file per year, real for 8M files)"
          ],
          "cons": [
            "Storage cost: 16TB XFS = $300-500 one-time; B2 offsite = $5/TB/month = $80/month for 16TB; 2nd physical disk = $300; total $1.2k year 1",
            "Backup is NOT a backup until you've RESTORED from it (test monthly or you'll find out during outage = worst time to discover corruption)",
            "rsync window = 2-6 hours for 16TB at 1Gbps; nightly cron must finish before next cycle (06:00 deadline) or jobs pile up",
            "XFS CAN'T shrink filesystem (only grow) — if you over-allocate, wasted space forever (mkfs.xfs 10TB, only 4TB used = 6TB gone)",
            "No built-in encryption at filesystem level (LUKS underneath OR eCryptfs on top = complexity); B2 upload must encrypt separately (gpg)",
            "Backup of /mnt/16tb itself isn't a backup (it's the SAME disk) — need 2nd physical disk OR offsite (B2) for true redundancy"
          ],
          "gotchas": [
            "Cron must use `rsync -aHAX` (a=archive, H=hard-links, A=ACLs, X=xattrs) — default `rsync -a` misses ACLs + xattrs = corrupted permission on restore",
            "Use `--delete` ONLY on the primary → mirror, NOT on mirror → source (delete on mirror = delete on source next sync = data loss); one-way sync only",
            "Pre-flight: `df -h /mnt/16tb` before rsync — disk full mid-sync = corrupted backup (worse than no backup; gives false confidence); abort if <10% free",
            "Permission check: backup target must be writable by `backup` user (set `uid`, `gid` in /etc/fstab or chown); ssh key in ~backup/.ssh/ with 600 perms",
            "Offsite: encrypt BEFORE upload (`tar czf - /data | gpg -c --batch --passphrase-file /root/.b2_key > backup.tar.gz.gpg`); B2 leak = encrypted blob = useless to attacker",
            "Restore test: pick a RANDOM file monthly, restore to /tmp, `diff` against source (catches bit rot, ACL drift, partial corruption); 10min test saves 10h outage",
            "Database dumps: `pg_dump dbname | gzip > backup.sql.gz` BEFORE file rsync (DB files locked during run, rsync = inconsistent); cron order: dump → rsync",
            "XFS quota: if you set project quota on /mnt/16tb/codehub, backup user needs `noquota` mount option OR backup fails halfway with 'disk quota exceeded'"
          ],
          "anti_patterns": [
            "DON'T backup to same disk only (one disk failure = both gone) — need physical separation (2nd disk) + offsite (B2) for 3-2-1",
            "DON'T skip restore testing — found-out-during-fire is the worst time to discover backup is corrupted; test monthly minimum",
            "DON'T backup /var/log (recovers itself, just rotation needed; saves 50% of backup size for chatty apps); backup only /etc, /home, /srv, /opt, DB dumps",
            "DON'T use `cp -r` for backups (no delta, no progress, no resume, no --delete, no ACLs) — terrible for 100GB+; rsync or restic or borg only",
            "DON'T store backup credentials in cron (root crontab is world-readable on some systems) — use /etc/cron.d/backups with mode 600, owned by root:root"
          ],
          "next_steps": [
            "8.7"
          ],
          "tags": [
            "backup",
            "3-2-1",
            "rsync",
            "xfs",
            "disaster-recovery",
            "b2",
            "backup",
            "กู้คืน",
            "สำรอง"
          ],
          "compare_with": [
            "restic (deduplication, modern)",
            "borgbackup (encryption built-in)",
            "ZFS snapshots (different FS)",
            "AWS Backup (managed)"
          ]
        },
        {
          "id": "8.9",
          "title": "Docker (Dockerfile + Compose + Multi-Env)",
          "icon": "🐳",
          "description": "Dockerfile + docker-compose for reproducible multi-service env. NOTE: NOT currently used at CodeHub — all apps are static, served directly by nginx. Listed for future use.",
          "when_to_use": "Multi-service architecture (web + worker + db + cache in 1 stack); need reproducible env across dev/CI/prod; on-prem deploy; microservice split; 12-factor app compliance",
          "when_not_to_use": "Single static app (just nginx + files, like CodeHub today); small team (orchestration overhead > benefit); one-host deploy (compose is overkill); legacy monolith that needs full OS access",
          "snippet": "FROM node:20.11.0-alpine AS build\\nWORKDIR /app\\nCOPY package*.json ./\\nRUN npm ci --only=production\\nCOPY . .\\nRUN npm run build\\nFROM nginx:1.27-alpine\\nCOPY --from=build /app/dist /usr/share/nginx/html",
          "snippet_full": "# Dockerfile — multi-stage Node + nginx (CodeHub future-migration pattern)\\n# syntax=docker/dockerfile:1.4\\nFROM node:20.11.0-alpine AS deps\\nWORKDIR /app\\nCOPY package.json package-lock.json ./\\nRUN --mount=type=cache,target=/root/.npm \\\\\\n    npm ci --no-audit --no-fund\\n\\nFROM node:20.11.0-alpine AS build\\nWORKDIR /app\\nCOPY --from=deps /app/node_modules ./node_modules\\nCOPY . .\\nRUN npm run build  # produces /app/dist\\n\\nFROM node:20.11.0-alpine AS runtime\\nRUN addgroup -g 1001 -S app && adduser -S app -u 1001\\nWORKDIR /app\\nCOPY --from=build --chown=app:app /app/dist ./dist\\nCOPY --from=deps --chown=app:app /app/node_modules ./node_modules\\nCOPY --chown=app:app package.json ./\\nUSER app  # never run as root\\nENV NODE_ENV=production\\nENV PORT=8080\\nEXPOSE 8080\\nHEALTHCHECK --interval=30s --timeout=5s --retries=3 \\\\\\n  CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1\\nCMD [\"node\", \"dist/server.js\"]  # exec form = PID 1 = signal handling\\n\\n# .dockerignore — critical for small images + no secret leak\\n# .git\\n# .env\\n# .env.*\\n# node_modules\\n# dist\\n# coverage\\n# *.log\\n# .DS_Store\\n# README.md\\n\\n# docker-compose.yml — local dev (CodeHub future: web + db + cache)\\n# services:\\n#   web:\\n#     build: .\\n#     ports: ['8080:8080']\\n#     environment:\\n#       - DB_URL=postgresql://postgres:dev@db:5432/codehub\\n#       - REDIS_URL=redis://cache:6379\\n#     depends_on:\\n#       db: { condition: service_healthy }\\n#       cache: { condition: service_healthy }\\n#   db:\\n#     image: postgres:16-alpine\\n#     environment: { POSTGRES_PASSWORD: dev, POSTGRES_DB: codehub }\\n#     volumes: ['pgdata:/var/lib/postgresql/data']\\n#     healthcheck:\\n#       test: ['CMD-SHELL', 'pg_isready -U postgres']\\n#       interval: 5s\\n#   cache:\\n#     image: redis:7-alpine\\n#     healthcheck:\\n#       test: ['CMD', 'redis-cli', 'ping']\\n# volumes:\\n#   pgdata:",
          "difficulty": "Intermediate",
          "time_to_learn": "1 day",
          "docs_url": "https://docs.docker.com/",
          "pros": [
            "Reproducible env: Dockerfile pins OS + Node/Python version = 'works on my machine' solved forever (sha256 base image = exact bit-for-bit)",
            "Multi-stage builds: `FROM node:20 AS build` then `FROM nginx:alpine` = final image 50MB not 1.5GB (intermediate build layer discarded)",
            "docker-compose = 1 YAML file to spin up web+db+cache (no separate Postgres install on dev box; `docker compose up` = full stack in 30s)",
            "Layer cache: only changed layers rebuild (3-line package.json change = don't reinstall 500 deps; CI rebuild = 8s not 5min)",
            "Portability: same image runs on dev laptop, CI, prod VPS, k8s, ECS, Cloud Run = no deploy-specific bugs (no 'prod-only' mystery errors)",
            "Ecosystem: official images for almost every service (postgres, redis, nginx, node, python, rust, go) = no manual install + security patching"
          ],
          "cons": [
            "Image size: even multi-stage Node image = 100-200MB; alpine = 50MB but musl libc breaks some native modules (sharp, bcrypt, canvas); debian-slim = 80MB compromise",
            "Daemon overhead: Docker Desktop on Mac = 2GB RAM just for the VM (linuxkit); can be slow on old laptops; OrbStack = lighter alternative",
            "Security: privileged containers = root on host; always set `user:` and `read_only: true` in compose; rootless docker for paranoid mode (loses some features)",
            "Orchestration beyond 1 host = need k8s or swarm (massive learning curve vs docker-compose); k3s = lightweight option for edge/multi-host",
            "Storage: anonymous volumes fill `/var/lib/docker`; set `VOLUME` named OR `docker system prune -a` weekly to reclaim (10GB+ easily)",
            "BuildKit cache: without it, rebuilds are slow; with it, syntax quirks (`# syntax=docker/dockerfile:1.4`); Buildx = more features, more YAML"
          ],
          "gotchas": [
            "`.dockerignore` is CRITICAL — without it, `COPY . .` copies `node_modules/`, `.git/`, `dist/`, `.env`, `coverage/` = 1GB+ image + secrets leak",
            "Don't run as root in container: `RUN adduser -D app && USER app` (set in Dockerfile, NOT compose); root in container = easy escape to host via misconfig",
            "CMD vs ENTRYPOINT: CMD = default arg to ENTRYPOINT; ENTRYPOINT = main executable; use exec form `CMD [\"node\", \"server.js\"]` not shell form `CMD node server.js` (no PID 1 = no signal handling)",
            "Build args vs env vars: `ARG` only at build time, `ENV` at runtime; secret in `ARG` = visible in `docker history` (recoverable!); use BuildKit secrets mount instead",
            "Compose healthcheck: `healthcheck: { test: [\"CMD\", \"curl\", \"-f\", \"/health\"], interval: 30s, timeout: 5s, retries: 3 }` = depends_on condition: service_healthy actually works",
            "Layer ordering: `COPY package*.json ./ && RUN npm ci` BEFORE `COPY . .` (otherwise any code change reinstalls all 500 deps = 5min build instead of 8s)",
            "Bind mounts on Mac: `.:/app` is slow (osxfs syncs both ways); use `:cached` (Mac writes to fs, slow) or `:delegated` (Mac is host of truth) for hot-reload dev",
            "Image tag: `node:20` = latest 20.x (rebuild changes base!); pin to `node:20.11.0` for reproducibility; for true immutability use digest `node@sha256:abc...`"
          ],
          "anti_patterns": [
            "DON'T `FROM ubuntu:latest` — use specific version (`ubuntu:24.04`); latest = breaking changes on rebuild (glibc, openssl, base libs change without notice)",
            "DON'T `RUN apt-get update && apt-get install -y pkg` without `&& rm -rf /var/lib/apt/lists/*` (bloats image 100MB+; cached apt lists = bigger layer)",
            "DON'T store secrets in image (ENV DB_PASS=... visible in `docker inspect` + image layers); use Docker secrets, env at runtime, or BuildKit `--mount=type=secret`",
            "DON'T use `:latest` tag in production (rebuilds break; pin to digest `sha256:abc...` for true immutability); `:latest` = 'whatever is latest when you pull'",
            "DON'T run multiple processes in one container (web + worker) — `supervisord` is anti-pattern; use docker-compose with 1 process per container + orchestration"
          ],
          "prereqs": [
            "8.6"
          ],
          "next_steps": [
            "8.5",
            "8.2"
          ],
          "tags": [
            "docker",
            "dockerfile",
            "compose",
            "container",
            "image",
            "kubernetes",
            "คอนเทนเนอร์",
            "อิมเมจ"
          ],
          "compare_with": [
            "8.6 systemd (single-host)",
            "Podman (rootless daemonless)",
            "LXC (lighter isolation)",
            "k8s (multi-host orchestration)"
          ]
        },
        {
          "id": "8.10",
          "title": "CDN (jsDelivr / unpkg / Cloudflare for static assets)",
          "icon": "☁️",
          "description": "Free CDNs for static JS libraries + GitHub assets. jsDelivr + unpkg for npm packages (karaoke-studio uses ffmpeg.wasm via unpkg). Cloudflare for full-site proxy.",
          "when_to_use": "Static JS libraries (CDN-hosted, no npm install needed); big files users download (ffmpeg.wasm = 30MB); public open-source assets (GitHub release → jsDelivr); cross-origin asset needs",
          "when_not_to_use": "Private/authed assets (use your own CDN + signed URLs); dynamic API responses (use API cache layer 9.x); tiny assets < 10KB (no perf win, just extra DNS lookup); real-time game state (use WebSocket 3.9)",
          "examples": "karaoke-studio, sj88-video-editor-pro, mavis-assistant",
          "snippet": "import('https://unpkg.com/@ffmpeg/[email protected]/dist/esm/index.js').then(m => m.createFFmpeg({log:true}))",
          "snippet_full": "// karaoke-studio — ffmpeg.wasm via unpkg + COOP/COEP headers pattern\\n// Required headers (set in nginx 8.2 or HTML meta):\\n//   Cross-Origin-Opener-Policy: same-origin\\n//   Cross-Origin-Embedder-Policy: require-corp\\n// Without these, SharedArrayBuffer is undefined (karaoke-studio bug from 2023)\\n\\n// index.html — importmap for clean ESM imports\\n<script type=\"importmap\">\\n{\\n  \"imports\": {\\n    \"@ffmpeg/ffmpeg\": \"https://unpkg.com/@ffmpeg/[email protected]/dist/esm/index.js\",\\n    \"@ffmpeg/util\":   \"https://unpkg.com/@ffmpeg/[email protected]/dist/esm/index.js\"\\n  }\\n}\\n</script>\\n<script type=\"module\" integrity=\"sha384-Abc...\" crossorigin=\"anonymous\">\\n  import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';\\n  import { toBlobURL } from '@ffmpeg/util';\\n\\n  const ffmpeg = createFFmpeg({ log: true, corePath: 'https://unpkg.com/@ffmpeg/[email protected]/dist/umd/ffmpeg-core.js' });\\n  await ffmpeg.load();\\n\\n  // Export MP4 — read audio blob, write to FFmpeg FS, run command, read output\\n  async function exportMp4(audioBlob, videoFrames) {\\n    ffmpeg.FS('writeFile', 'in.webm', await fetchFile(audioBlob));\\n    for (let i = 0; i < videoFrames.length; i++) {\\n      ffmpeg.FS('writeFile', `frame${i.toString().padStart(4, '0')}.png`, await fetchFile(videoFrames[i]));\\n    }\\n    await ffmpeg.run('-framerate', '30', '-i', 'frame%04d.png', '-i', 'in.webm',\\n                      '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',\\n                      '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4');\\n    const data = ffmpeg.FS('readFile', 'out.mp4');\\n    return new Blob([data.buffer], { type: 'video/mp4' });\\n  }\\n\\n  // downloadLink.href = URL.createObjectURL(await exportMp4(audio, frames));\\n</script>\\n\\n// Note: ffmpeg.wasm = 30MB; cached by browser after first load; service worker (1.10) \\n// can cache it across sessions for instant subsequent loads.\\n//\\n// Fallback: if unpkg is down, self-host at /assets/vendor/ffmpeg-core.js (8.8 backup covers it)",
          "difficulty": "Beginner",
          "time_to_learn": "30 min",
          "docs_url": "https://www.jsdelivr.com/",
          "pros": [
            "jsDelivr serves any GitHub release: `https://cdn.jsdelivr.net/gh/user/repo@version/file` = free, fast, global edge network (200+ POPs)",
            "unpkg auto-resolves npm versions: `https://unpkg.com/[email protected]` = no manual upload, no version management; perfect for prototype/demo",
            "Cache hit ratio 95%+ on popular libs = 50ms TTFB from edge vs 500ms from origin (ffmpeg.wasm users in karaoke-studio load in 1.5s not 8s)",
            "Saves VPS bandwidth: 1M ffmpeg.wasm downloads/mo = 30TB off your VPS bill (VPS at 1Gbps = saturates at 1000 concurrent downloads)",
            "HTTP/2 multiplexing, Brotli, HTTP/3 all on by default = no config needed; edge servers tuned for static asset delivery (kernel TCP buffer, TCP fast open)",
            "Versioned URLs: pin `@0.12.10` = reproducible builds, no breaking changes on dependency update; roll back = change 1 string in code"
          ],
          "cons": [
            "Cache invalidation is ASYNC — `?purge=true` not supported on jsDelivr/unpkg; new version = new URL OR wait 24h for natural cache expiry",
            "jsDelivr has rate limits (unlimited per IP for small files, but heavy concurrent use gets throttled = 429); for >100 req/s, self-host or use Cloudflare",
            "unpkg had a 2-day outage in Dec 2021 — your app went down too (single point of failure; same risk for jsDelivr); fallback to local copy is good practice",
            "No custom headers (CORS, CSP) on jsDelivr/unpkg — relies on their default (open CORS = good for browser use, but no CSP nonce = can be blocked by strict CSP)",
            "Files in `/dist/` of npm packages may differ from main field — ESM/CJS resolution can be wrong; check browser console for 404s on sub-imports (e.g. `@ffmpeg/util`)",
            "Privacy: CDN sees all client IPs (their logs = your data leak vector for EU users under GDPR; Cloudflare has EU-only POPs, jsDelivr/unpkg = mixed)"
          ],
          "gotchas": [
            "Importmap + unpkg: `<script type=\"importmap\">{\"imports\": {\"react\": \"https://esm.sh/[email protected]\"}}</script>` for clean ESM; lets `import React from 'react'` work in browser without bundler",
            "Cache busting: `?v=1.2.3` in URL = forced refresh on deploy; CDN purges on URL change anyway, but old browsers (Safari) cache 7d = version param wins",
            "Subresource Integrity: `<script src=\"https://...\" integrity=\"sha384-...\" crossorigin=\"anonymous\">` = CDN compromise can't inject malicious JS; browser blocks if hash mismatches",
            "For ffmpeg.wasm: core needs COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`) or SharedArrayBuffer fails (karaoke-studio gotcha)",
            "jsDelivr strips LICENSE files from npm — for some packages, must self-host with license attached (GPL/AGPL compliance); check `unpackedSize` for license files",
            "unpkg.com caches forever for some browsers (Safari 14 bug) — version bump your imports to force refresh: `?v=2024-01-15` after a CDN change",
            "Cloudflare free tier = no custom cache rules; upgrade to Pro ($20/mo) for cache TTL control, page rules, workers; for hobby = free is fine",
            "Pin version, NOT @latest: `@ffmpeg/[email protected]` not `@ffmpeg/ffmpeg` (auto-bumps = breaking changes; `@0.12.10` → `@0.13.0` could remove your API)"
          ],
          "anti_patterns": [
            "DON'T load from multiple CDNs for same lib (jsDelivr + unpkg = 2x requests on miss, 2x security audit, 2x SRI to maintain); pick one, stick with it",
            "DON'T use `@latest` or no version in URL — CDN edge caching means your users get random versions over time (some get 0.12.10, some get 0.13.0 = inconsistent bugs)",
            "DON'T trust CDN for critical-path WITHOUT SRI + fallback — CDN down = app down; self-host fallback in /assets/vendor/ (2x disk, 100% uptime guarantee)",
            "DON'T use `cdnjs.cloudflare.com` AND `cdn.jsdelivr.net` for same asset (wastes cache slots, increases TTI, 2x DNS lookup); pick one CDN per project",
            "DON'T bypass CORS by using `<script src>` for JSONP-style data — use proper Fetch with CORS headers; JSONP = XSS vector, deprecated, hard to debug"
          ],
          "next_steps": [
            "8.2",
            "8.3"
          ],
          "tags": [
            "cdn",
            "jsdelivr",
            "unpkg",
            "cloudflare",
            "ffmpeg",
            "wasm",
            "static-assets",
            "ซีดีเอ็น",
            "สถิตย์"
          ],
          "compare_with": [
            "8.8 Backup (self-host)",
            "Cloudflare R2 (S3-compatible)",
            "AWS CloudFront (paid)",
            "self-host with nginx (8.2)"
          ]
        }
      ]
    },
    {
      "id": 9,
      "name": "Data & APIs",
      "icon": "📊",
      "tagline": "Backend patterns",
      "sub_phases": [
        {
          "id": "9.1",
          "title": "REST API Design",
          "icon": "🔌",
          "description": "Resource-oriented URLs + /api/v1/ + OpenAPI 3 + correct 4xx/5xx status codes.",
          "when_to_use": "ทุก backend ที่ expose data ให้ frontend/SPA/mobile consume / any client-server boundary where the contract needs versioning. Best for: CRUD, public APIs, integrations. EN: any backend that exposes data to SPA / mobile / third-party; needs cacheable, cache-friendly, stateless contracts.",
          "when_not_to_use": "GraphQL เมื่อ client ต้องการ shape ที่หลากหลายมาก; gRPC เมื่อ service-to-service ที่ต้องการ low-latency + binary; pure internal one-team RPC (just call the function)",
          "examples": "codehub-api, flowboard, ai-data-analyzer",
          "snippet": "GET /api/projects # list\\nGET /api/project/{slug} # detail\\nPOST /api/admin/project # create (X-Admin-Token)",
          "snippet_full": "# CodeHub REST pattern (api/app.py) — resource URLs + Pydantic response_model\\nfrom fastapi import FastAPI, Query, HTTPException\\nfrom pydantic import BaseModel\\n\\napp = FastAPI(title='CodeHub API', version='1.2.0')\\n\\nclass ProjectSummary(BaseModel):\\n    slug: str\\n    name: str\\n    version_count: int\\n\\n@app.get('/api/projects', response_model=list[ProjectSummary], tags=['projects'])\\ndef list_projects(category: str | None = Query(None), limit: int = Query(50, ge=1, le=500)):\\n    items = repo.list_projects(category=category)\\n    return items[offset:offset + limit]  # never return more than 'limit'\\n\\n@app.get('/api/project/{slug}', response_model=ProjectDetail)\\ndef get_project(slug: str):\\n    p = repo.get_project(slug)\\n    if not p:\\n        raise HTTPException(status_code=404, detail=f'project {slug} not found')\\n    return p  # auto-serialized by Pydantic, OpenAPI schema auto-generated",
          "difficulty": "Beginner",
          "time_to_learn": "1 day",
          "docs_url": "https://restfulapi.net/",
          "pros": [
            "Resource-oriented URLs (GET /api/projects/{slug}/versions) = human-readable, log-friendly, no method-name soup like /api/getProjectBySlug",
            "HTTP verbs map to CRUD (GET=read, POST=create, PUT=replace, PATCH=partial, DELETE=remove) — 30+ years of CDN/proxy/load-balancer tooling understands them",
            "OpenAPI 3 spec auto-generates client SDKs (TypeScript, Swift, Kotlin) — frontend never hand-writes fetch wrappers",
            "Cache-friendly by default: GET is the only safe verb; ETag + Last-Modified give 304s and save 90% bandwidth on list endpoints",
            "Status codes carry semantic load: 401=auth, 403=forbidden, 404=missing, 409=conflict (unique key), 422=validation — frontend can branch on status alone, not body shape",
            "Stateless server = horizontal scale trivially; no session affinity needed unless you add WebSocket (sub 9.7)"
          ],
          "cons": [
            "Over-fetching: GET /api/project returns full version history when list view only needs slug+name+icon — fix with ?fields=summary or /api/projects (light) vs /api/project/{slug} (full)",
            "Under-fetching: detail page needs 5 endpoints (project, versions, screenshots, files, ai-instructions) — fix with /api/project/{slug}/everything OR GraphQL",
            "N+1 query risk: returning nested arrays forces client to issue follow-up requests; consider /api/project/{slug}?expand=versions,files",
            "Versioning pain: /api/v1 vs /api/v2 forces URL change OR header (Accept: application/vnd.codehub.v2+json) — pick ONE strategy and stick to it",
            "Idempotency burden: POST is not naturally idempotent (double-submit = 2 records); need client-supplied Idempotency-Key header for any money/action endpoint",
            "No streaming: returning 10k rows blocks the response thread; for huge lists use pagination + cursor OR switch to GraphQL/SSE"
          ],
          "gotchas": [
            "POST should return 201 Created + Location: /api/project/{new_slug} header — clients need the new ID, not just the body",
            "PUT replaces the WHOLE resource; PATCH for partial — never PATCH without Content-Type: application/merge-patch+json (or json-patch+json)",
            "DELETE should be idempotent (second DELETE returns 404, not 500) — clients retry on network failure and expect safety",
            "Pagination: ?limit + ?offset is easy but breaks when list changes; cursor (?cursor=base64) is robust but harder to implement",
            "Always return errors as JSON with 'detail' field (FastAPI default) — never HTML error pages from proxy, they break JSON parsers",
            "Trailing slash matters: /api/projects/ vs /api/projects — FastAPI redirects 307 by default, but the extra round-trip hurts mobile",
            "CORS preflight fires on every POST with Content-Type: application/json — 5x latency on cold path; pre-cache OPTIONS with Access-Control-Max-Age: 86400",
            "Sort order MUST be stable and explicit (?sort=created_at&order=desc) — relying on DB insertion order breaks after vacuum/reindex (SQLite) or replica failover (Postgres)"
          ],
          "anti_patterns": [
            "RPC-in-REST-clothing: POST /api/getUserById with body {id: 5} — defeats caching, breaks OpenAPI tooling, hard to grep. Use GET /api/users/5",
            "Returning 200 with error body: {\"status\":\"error\",\"code\":404,\"msg\":\"not found\"} — frontend must check BOTH http status AND body field. Just use 404",
            "Verbs in URLs: POST /api/createProject, GET /api/getProjectsList — the verb IS the HTTP method. /api/projects with POST is enough",
            "Mass assignment: PATCH /api/user/5 with body {role:\"admin\", is_superuser:true} — server MUST whitelist allowed fields, not blindly merge",
            "Using PUT for partial update: PUT /api/user/5 with body {email:\"new@x\"} wipes the name field. Use PATCH for partial"
          ],
          "next_steps": [
            "9.2",
            "9.9",
            "9.10"
          ],
          "tags": [
            "rest",
            "api",
            "http",
            "openapi",
            "swagger",
            "rest-api",
            "เอพีไอ"
          ],
          "compare_with": [
            "9.7 WebSocket (realtime push, not request/response)",
            "9.8 SSE (server→client stream)",
            "GraphQL (when client shapes vary wildly)"
          ]
        },
        {
          "id": "9.2",
          "title": "FastAPI Backend",
          "icon": "⚡",
          "description": "Async Python + Pydantic validation + auto /docs Swagger + Depends() DI. The batteries-included default for new Python APIs.",
          "when_to_use": "ทุก Python backend ใหม่, ML model serving, async I/O heavy (DB + HTTP + WebSocket), team ที่ต้องการ type safety. EN: any new Python API; async I/O bound work; need type-checked contracts.",
          "when_not_to_use": "CPU-bound (use Celery/Ray worker); ultra-low-latency < 5ms (use Go/Rust); simple static site (no backend needed)",
          "examples": "codehub-api, memo-ai, ai-data-analyzer, flowboard",
          "snippet": "from fastapi import FastAPI; from pydantic import BaseModel\\napp = FastAPI()\\nclass Project(BaseModel): slug: str\\n@app.post('/api/project', response_model=Project)\\nasync def create(p: Project): return p",
          "snippet_full": "# codehub-api pattern — FastAPI + Pydantic + Depends() for repo layer\\nfrom fastapi import FastAPI, Depends, HTTPException, Query\\nfrom pydantic import BaseModel, Field\\nfrom typing import Optional\\n\\napp = FastAPI(title='CodeHub API', version='1.2.0')\\n\\nclass ProjectIn(BaseModel):\\n    slug: str = Field(..., pattern=r'^[a-z0-9-]+$')\\n    name: str = Field(..., min_length=1, max_length=256)\\n    category: str = 'Other'\\n\\nclass ProjectOut(ProjectIn):\\n    version_count: int = 0\\n    latest_version: Optional[str] = None\\n\\ndef get_repo():\\n    # singleton via lru_cache in real code; this is the seam for tests\\n    from repo import get_repo as _gr\\n    return _gr(PROJECTS)\\n\\n@app.post('/api/project', response_model=ProjectOut, status_code=201, tags=['projects'])\\nasync def create_project(p: ProjectIn, repo = Depends(get_repo)):\\n    if repo.exists(p.slug):\\n        raise HTTPException(409, f'project {p.slug} already exists')\\n    repo.create(p.model_dump())\\n    return repo.get(p.slug)  # 201 + Location header auto-set by FastAPI",
          "difficulty": "Intermediate",
          "time_to_learn": "2 days",
          "docs_url": "https://fastapi.tiangolo.com/",
          "pros": [
            "Pydantic v2 validation at route boundary = early error catch with line-precise field paths (faster than hand-rolled if-elif chains)",
            "Auto OpenAPI 3 + Swagger UI at /docs + ReDoc at /redoc = frontend and QA never ask 'what's the contract?' — they read /docs",
            "Depends() injection makes auth/db/session one-liner per route — no contextvars, no thread-locals, no Flask g globals",
            "async def routes + anyio backend = one event loop handles 10k+ concurrent connections (vs Flask which is 1 thread per request by default)",
            "BackgroundTasks for fire-and-forget (rebuild, send email) without Celery — keeps the deploy surface small",
            "Pydantic models double as TYPE for the route + SCHEMA for /docs + VALIDATOR for the body — single source of truth"
          ],
          "cons": [
            "GIL still blocks CPU work even with async — pure Python number crunching stays single-core; offload to numpy/C/numba/ProcessPoolExecutor",
            "Pydantic v2 is fast (rust core) but cold start is ~80ms vs Flask ~15ms — matters for serverless (Lambda cold start penalty)",
            "Async footgun: blocking call (time.sleep, requests.get, sqlite3) inside async def stalls the event loop — use httpx.AsyncClient and aiosqlite",
            "Dependency injection can hide expensive work: Depends(get_db) that opens a new connection per request adds ~2ms — use connection pool (asyncpg pool)",
            "FastAPI version churn: 0.100 → 0.110 changed Pydantic v1→v2 migration, lifespan syntax, async test client — pin versions in requirements.txt",
            "OpenAPI auto-gen is great until you have 200+ endpoints: /docs page loads slowly and groups become a mess — split into routers + tags, use response_model_exclude_unset=True"
          ],
          "gotchas": [
            "async def vs def: use async ONLY when you actually await something; sync def runs in threadpool (4-40 workers default) and is fine for blocking I/O like psycopg2",
            "Pydantic BaseModel inside List[Project] = N-times the validation cost — for 10k items use response_model_exclude_unset=True OR return plain dict",
            "Depends() executes PER REQUEST — putting heavy setup (compile ML model) there = recompile every request. Use module-level singleton or @lru_cache",
            "CORS preflight: add CORSMiddleware BEFORE any route; missing = browser blocks all POST from other origin",
            "Lifespan context manager (replaces on_event('startup')) is the v3 way — startup code in yield, shutdown in finally",
            "Response_model strips extra fields by default — useful for security (don't leak password_hash) but bites if you return dynamic dict",
            "Form vs Body vs Query: use Form() for multipart upload, Body() for raw JSON, Query() for ?key=val; mixing them in one route fails 422",
            "StaticFiles mount must come LAST or it shadows /docs: app.mount('/static', StaticFiles(directory='.')) AFTER all API routes"
          ],
          "anti_patterns": [
            "Returning dict when you have a Pydantic model: return {'slug': p.slug} — drops validation, breaks OpenAPI schema, IDE loses autocomplete. Return ProjectOut()",
            "Global mutable state for db connection: db = Session() at module top — broken across workers (gunicorn -w 4 = 4 different sessions, requests leak). Use Depends(get_db)",
            "Putting business logic in route handler: 200-line async def that reads request, queries DB, formats response, sends email. Split into service.py + route just orchestrates",
            "Using @app.on_event('startup') in new code: deprecated in 0.110. Use lifespan context manager (async with Lifespan(app): ...)",
            "Returning ORM model directly: return db.query(Project).first() — leaks lazy-loaded fields on serialization, breaks response_model. Use .from_orm() or Pydantic v2 model_validate()"
          ],
          "next_steps": [
            "9.3",
            "9.4",
            "9.7",
            "9.8",
            "9.9",
            "9.10"
          ],
          "tags": [
            "fastapi",
            "python",
            "async",
            "pydantic",
            "openapi",
            "พี-วัน"
          ],
          "compare_with": [
            "Flask (simpler, no async, no auto docs)",
            "Django REST (heavier, batteries-included)",
            "Litestar (newer, similar)"
          ]
        },
        {
          "id": "9.3",
          "title": "SQLAlchemy ORM",
          "icon": "🗄️",
          "description": "Python ORM with Alembic migrations, 2.0 typed Mapped[] syntax, async + sync engines.",
          "when_to_use": "ทุก relational DB project, multi-table queries, need migrations, team wants IDE autocomplete on query results. EN: any relational DB project; multi-table JOINs; need Alembic migrations; want typed query results.",
          "when_not_to_use": "Raw SQL > 100k queries/sec (use psycopg/asyncpg direct); single-table read-only (use dataset or raw); pure OLAP / analytical (use DuckDB)",
          "examples": "codehub-api, flowboard, cospace, dinebook",
          "snippet": "class Project(Base):\\n    __tablename__ = 'projects'\\n    slug = Column(String(64), primary_key=True)\\n    name = Column(String(256), nullable=False)",
          "snippet_full": "# codehub-api pattern — SQLAlchemy 2.0 typed Mapped + relationship + session_scope\\nfrom sqlalchemy import String, ForeignKey, Index, UniqueConstraint\\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship, sessionmaker\\nfrom db import Base, session_scope\\n\\nclass Project(Base):\\n    __tablename__ = 'projects'\\n    slug: Mapped[str] = mapped_column(String(64), primary_key=True)\\n    name: Mapped[str] = mapped_column(String(256), nullable=False)\\n    category: Mapped[str] = mapped_column(String(64), default='Other', index=True)\\n    versions: Mapped[list['Version']] = relationship(\\n        'Version', back_populates='project',\\n        cascade='all, delete-orphan', order_by='Version.position')\\n\\nclass Version(Base):\\n    __tablename__ = 'versions'\\n    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)\\n    project_slug: Mapped[str] = mapped_column(\\n        String(64), ForeignKey('projects.slug', ondelete='CASCADE'), index=True)\\n    version: Mapped[str] = mapped_column(String(32))\\n    project: Mapped['Project'] = relationship('Project', back_populates='versions')\\n    __table_args__ = (UniqueConstraint('project_slug', 'version'),)\\n\\n# usage in route:\\ndef get_project_full(slug: str) -> Project | None:\\n    with session_scope() as s:\\n        return (s.query(Project)\\n                 .options(selectinload(Project.versions))  # fix N+1\\n                 .filter(Project.slug == slug).first())",
          "difficulty": "Intermediate",
          "time_to_learn": "2 days",
          "docs_url": "https://docs.sqlalchemy.org/en/20/",
          "pros": [
            "Mapped[str] / mapped_column() in 2.0 = full type hints → mypy catches 'user.email' typo on Unmapped instance before runtime",
            "Unit of Work pattern: session.add() + session.commit() batches N statements into 1 transaction = 10x faster than per-row commits",
            "relationship() with lazy='selectin' = 1 query for parent + 1 IN-query for all children — fixes the classic N+1 (1 query → 101 queries for list of 100)",
            "Alembic autogenerate detects model diffs and writes migration: alembic revision --autogen -m 'add index' — no more handwritten DDL",
            "Engine-agnostic: same code runs on SQLite for tests + Postgres for prod via DATABASE_URL — CodeHub does this in api/db.py:get_db_url()",
            "Connection pool built-in (QueuePool default) — set pool_size=10, max_overflow=20 for 30-conn budget; pool_pre_ping=True auto-recycles dead conns"
          ],
          "cons": [
            "Async support in 2.0 still has rough edges: AsyncSession requires asyncpg + greenlet, lazy loading doesn't work, no sync-from-async bridge",
            "N+1 trap: query Project then access .versions in a loop fires 1+N queries. ALWAYS use selectinload(Version.project) or joinedload()",
            "Session lifecycle is footgun: forget session.close() = connection leak → pool exhausted → 500s. Use context manager: with session_scope() as s: ...",
            "Reflection (autoload_from) looks magical until schema changes break prod — explicit Base models are worth the typing",
            "Complex GROUP BY / window functions force you into raw SQL anyway — ORM stops helping past 'give me all rows'",
            "Alembic autogenerate MISSES: column renames (looks like drop+add), table renames, enum changes, server defaults — ALWAYS review the generated migration before shipping"
          ],
          "gotchas": [
            "session = Session() is NOT thread-safe — share only via Depends(get_db) per request, NEVER as module global",
            "expire_on_commit=True (default) means accessing .name after commit fires a fresh SELECT — set False for read-heavy loops",
            "Relationship lazy='select' is the DEFAULT (per-row SELECT); for lists ALWAYS use selectinload() or subqueryload()",
            "String length: String(64) without length becomes TEXT in Postgres — fast for inserts but no index can be built on TEXT without expression index",
            "Index columns you WHERE/ORDER BY on, NOT columns you only SELECT — index has write cost, only useful if query uses it",
            "DateTime(timezone=True) — store UTC always; naive datetimes in Postgres = 'timestamp without time zone' which silently drops TZ",
            "session.add() does NOT flush — it queues. session.commit() flushes. To get auto-increment ID back, use session.flush() before commit()",
            "Pydantic v2 + ORM: use from_attributes=True in Config OR model_validate(user, from_attributes=True) — naive dump() misses relationships"
          ],
          "anti_patterns": [
            "Model as dict: user.name when name is None returns 'None' string. Always check attribute access returns Optional[T]",
            "Committing in a loop: for u in users: session.add(u); session.commit() — 1000 commits = 1000 round-trips. Build list, commit once",
            "Mixing ORM and Core: query(Project).filter(Project.id == 1) is fine, but don't mix into(select(Version)) without aliasing — name collision errors are cryptic",
            "Forgetting rollback in exception handler: except Exception: pass — leaves session in poisoned state, next request may see stale data. Use context manager: with session_scope() as s:",
            "Using ORM for bulk insert > 10k rows: 10k INSERT statements are 100x slower than COPY (raw SQL). Use session.execute(insert(Project), [dict, dict, ...]) OR copy_expert()"
          ],
          "prereqs": [
            "9.2",
            "9.4"
          ],
          "next_steps": [
            "9.5"
          ],
          "tags": [
            "orm",
            "sql",
            "sqlalchemy",
            "alembic",
            "พี-วัน",
            "ฐานข้อมูล"
          ],
          "compare_with": [
            "Django ORM (heavier, batteries-included)",
            "Tortoise ORM (async-first, Django-like)",
            "Peewee (light, sync only)",
            "raw asyncpg/psycopg (max perf)"
          ]
        },
        {
          "id": "9.4",
          "title": "SQLite",
          "icon": "💾",
          "description": "File-based SQL DB. Zero install, ACID, FTS5, WAL mode, perfect for dev/cache/edge.",
          "when_to_use": "Single-user, dev/test, local cache, mobile (iOS/Android native), edge (Cloudflare D1 = SQLite), read-heavy embedded. EN: single-writer, dev/test, local cache, mobile native, edge serverless (D1/Turso).",
          "when_not_to_use": "Multi-writer concurrent (web app with > 1 writer/second sustained), > 1TB data, network-mounted FS (NFS — locks break), > 100k writes/sec",
          "examples": "codehub-api, codehub-cache, memo-ai, ai-data-analyzer",
          "snippet": "import sqlite3\\ncon = sqlite3.connect('codehub.sqlite')\\ncon.execute('PRAGMA journal_mode=WAL')\\ncon.execute('CREATE TABLE IF NOT EXISTS p (id INTEGER PRIMARY KEY, slug TEXT UNIQUE)')",
          "snippet_full": "# CodeHub SQLite pattern — WAL mode + FTS5 + per-request connection\\nimport sqlite3\\nfrom pathlib import Path\\n\\nDB_PATH = Path('/workspace/codehub/.cache/codehub.sqlite')\\n\\ndef get_conn() -> sqlite3.Connection:\\n    conn = sqlite3.connect(DB_PATH, isolation_level=None)  # autocommit\\n    conn.execute('PRAGMA journal_mode=WAL')      # 1 writer + N readers\\n    conn.execute('PRAGMA busy_timeout=5000')    # wait 5s for lock, don't fail\\n    conn.execute('PRAGMA foreign_keys=ON')       # off by default — easy to miss\\n    conn.execute('PRAGMA synchronous=NORMAL')   # 2x faster than FULL, safe with WAL\\n    return conn\\n\\n# FTS5 full-text search — CodeHub /api/search uses this\\nconn = get_conn()\\nconn.executescript('''\\n    CREATE VIRTUAL TABLE IF NOT EXISTS codehub_fts USING fts5(\\n        slug, version, title, user_cmd, ai_response, body,\\n        tokenize='unicode61 remove_diacritics 2'\\n    );\\n''')\\n# Match with prefix: 'fast*' matches 'fastapi', 'faster', etc.\\nrows = conn.execute(\\n    \"SELECT slug, version, bm25(codehub_fts) AS score FROM codehub_fts WHERE codehub_fts MATCH ? ORDER BY score LIMIT 20\",\\n    ('fast* OR babylon*',)\\n).fetchall()",
          "difficulty": "Beginner",
          "time_to_learn": "1 hour",
          "docs_url": "https://www.sqlite.org/docs.html",
          "pros": [
            "Zero install: import sqlite3 (stdlib) and you're running. No docker, no apt-get, no env vars. CodeHub default in api/db.py",
            "ACID transactions out of the box — full crash safety via WAL (write-ahead log); no separate config",
            "FTS5 built-in full-text search with BM25 ranking — CodeHub's /api/search uses this for code+ai-instructions search in one .sqlite file",
            "One file = trivial backup (cp file.sqlite backup.sqlite), trivial migration (send the file), trivial inspection (DB Browser for SQLite)",
            "In-process = no network round-trip; queries are 5-50x faster than Postgres for small datasets (< 100k rows)",
            "Edge-deployable: Turso, Cloudflare D1, LiteFS — same SQLite dialect, runs at CDN edge in 5ms"
          ],
          "cons": [
            "Single writer at a time: even with WAL, writes serialize. Heavy insert workload (> 1k writes/sec) stalls readers waiting for lock",
            "No client-server: you can't connect from another machine without writing your own server. For multi-user app, jump to Postgres",
            "No user accounts / no GRANT system: any process with file read = full DB access. Mitigate with file permissions (chmod 600)",
            "No parallel write scaling: 1 process holding write lock blocks all other writers; high-write web app must use Postgres",
            "WAL file grows until checkpoint: on long-running writers, wal file can hit GB before auto-checkpoint. Set wal_autocheckpoint=1000",
            "Type affinity is loose: '5' (TEXT) and 5 (INTEGER) compare equal; explicit CAST or strict tables needed for type safety"
          ],
          "gotchas": [
            "Enable WAL for any real workload: PRAGMA journal_mode=WAL — gives 1 writer + N readers concurrently instead of 1 writer OR 1 reader",
            "Foreign keys OFF by default: PRAGMA foreign_keys=ON must run per connection (or via SQLAlchemy event listener) — easy to miss for months",
            "busy_timeout: PRAGMA busy_timeout=5000 makes writers wait 5s for lock instead of failing immediately — critical for web apps under contention",
            "DateTime stored as TEXT (ISO8601) or REAL (julian) — neither has timezone; always store UTC and convert in app code",
            "Connection per thread (not per process): in threaded server (gunicorn -w 4 --threads 2), each thread needs its own connection, NOT shared",
            "NUL bytes in strings break: 'foo\\x00bar' gets truncated. Sanitize input or use BLOB type for binary",
            "VACUUM after big DELETE: empty space doesn't auto-reclaim, file stays huge. Run VACUUM (or auto_vacuum=INCREMENTAL + pragma)",
            "Memory temp store: PRAGMA temp_store=MEMORY for ORDER BY on large results — defaults to disk which 100x slower"
          ],
          "anti_patterns": [
            "Using sqlite for multi-user web app write path: 100 concurrent users = 1 writer + 99 waiters = 504 Gateway timeouts. Use Postgres",
            "Storing the .db file in /tmp on Linux: cleaned on reboot = total data loss. Always in a persistent, backed-up location",
            "Storing the .db file on a network filesystem (NFS/SMB): SQLite's lock protocol breaks on NFS — corruption guaranteed after 1 week. Local SSD only",
            "Forgetting to close connection: conn = sqlite3.connect('x.db') in a hot loop = 1000 file handles → 'too many open files'. Use context manager: with sqlite3.connect() as conn:",
            "Using INTEGER PRIMARY KEY as 'always increments from 1': after delete + VACUUM, IDs can be reused. Use AUTOINCREMENT keyword (with the perf cost) if monotonic required"
          ],
          "next_steps": [
            "9.3",
            "9.5"
          ],
          "tags": [
            "sqlite",
            "sql",
            "cache",
            "fts5",
            "wal",
            "ฐานข้อมูล"
          ],
          "compare_with": [
            "9.5 PostgreSQL (multi-writer, client-server)",
            "DuckDB (OLAP, single-node columnar)",
            "JSON file (no schema, no queries)"
          ]
        },
        {
          "id": "9.5",
          "title": "PostgreSQL",
          "icon": "🐘",
          "description": "Production-grade RDBMS: MVCC, JSONB, FTS, partitioning, replication, 30+ years of battle-testing.",
          "when_to_use": "Multi-user production web app, complex JOINs (>3 tables), need JSON + relational hybrid, full-text search beyond SQLite FTS5, > 10k writes/sec, need read replicas. EN: any multi-user production system; complex queries; JSON+relational mix; need read replicas.",
          "when_not_to_use": "Single-user / dev (use SQLite); pure OLAP > 1B rows (use ClickHouse / DuckDB); > 100k writes/sec single-node (use Cassandra/Scylla); no ops team (use a managed service like Supabase/Neon)",
          "snippet": "SELECT slug, name, category FROM projects\\nWHERE category = 'Games'\\n  AND meta @> '{\"tags\": [\"babylon\"]}'\\nORDER BY created_at DESC LIMIT 20;",
          "snippet_full": "# CodeHub-on-Postgres pattern — asyncpg pool + SQLAlchemy core + JSONB\\n# api/db.py supports this via CODEHUB_DB_URL env override:\\n#   CODEHUB_DB_URL=postgresql://user:pass@host:5432/codehub\\nimport asyncpg\\nfrom sqlalchemy import create_engine, text\\n\\nasync def get_projects_by_tag(tag: str, limit: int = 20) -> list[dict]:\\n    # JSONB containment + GIN index = fast tag filter\\n    sql = text(\\\"\\\"\\\"\\n        SELECT slug, name, category, created_at,\\n               jsonb_array_length(meta->'tags') AS tag_count\\n        FROM projects\\n        WHERE meta @> :tag_filter\\n        ORDER BY created_at DESC\\n        LIMIT :limit\\n    \\\"\\\"\\\")\\n    async with asyncpg.create_pool('postgresql://localhost/codehub', min_size=2, max_size=10) as pool:\\n        async with pool.acquire() as conn:\\n            rows = await conn.fetch(sql, tag_filter={'tags': [tag]}, limit=limit)\\n    return [dict(r) for r in rows]\\n\\n# Index required for the @> query to be fast (one-time setup):\\n# CREATE INDEX idx_projects_tags ON projects USING gin (meta);\\n# Without this index, every query = sequential scan = O(N) of all rows",
          "difficulty": "Advanced",
          "time_to_learn": "3 days",
          "docs_url": "https://www.postgresql.org/docs/",
          "pros": [
            "MVCC = readers NEVER block writers and writers NEVER block readers — high concurrency without table-level locks (vs SQLite's 1-writer limit)",
            "JSONB = indexed JSON column, query with ->> and @> operators: WHERE meta @> '{\"category\": \"games\"}' is index-accelerated",
            "Native full-text search: tsvector + tsquery + GIN index — better ranking than SQLite FTS5, supports 30+ languages out of box",
            "LIST/RANGE partitioning for time-series: CREATE TABLE ... PARTITION BY RANGE (created_at) — keep hot data fast, archive cold to cheap storage",
            "Logical replication: 1 primary → N read replicas async — scale reads to 100+ connections without app changes (PgBouncer pools in front)",
            "Generated columns, CTEs, window functions, lateral joins, UPSERT (ON CONFLICT DO UPDATE) — every feature you'd write in app code, already in DB"
          ],
          "cons": [
            "Ops burden: install, configure, monitor, backup, vacuum, autovacuum tune, WAL archive, replica lag, failover. Use managed (RDS, Supabase, Neon) unless you have a DBA",
            "Vacuum & bloat: dead tuples from UPDATE/DELETE accumulate; autovacuum must be tuned per table or you hit 'table is full' or 10x storage bloat",
            "Connection cost: each connection forks a process (~10MB RAM); default max_connections=100 = 1GB just for idle conns. Use PgBouncer in transaction-pool mode",
            "Slow COUNT(*): no cheap row count without table scan (unlike MySQL). Use pg_class.reltuples (estimate, fast) or maintain counter in separate table",
            "Migration downtime: ALTER TABLE on big table can lock for minutes (ADD COLUMN with default = full rewrite in < 12). Use pg_repack or pt-online-schema-change",
            "Replication lag: read replica can be 5-30s behind primary. 'Read your own write' needs session stickiness or read-after-write routing"
          ],
          "gotchas": [
            "Use SERIAL or GENERATED ALWAYS AS IDENTITY (preferred, SQL-standard) — NOT 'id INTEGER PRIMARY KEY' (no auto-increment, manual sequence needed)",
            "Timestamptz (with timezone) NOT timestamp — naive timestamp silently drops TZ, then ORDER BY mixes Bangkok and UTC rows",
            "ILIKE for case-insensitive search: WHERE name ILIKE '%foo%' — but it's SLOW without trigram index. CREATE EXTENSION pg_trgm; CREATE INDEX ON users USING gin (name gin_trgm_ops)",
            "Connection pool: use asyncpg.create_pool(min_size=2, max_size=10) — NOT one connection per request (10x slower under load)",
            "VARCHAR(n) without length = TEXT, no perf difference. Use TEXT unless you need a constraint at DB level",
            "EXPLAIN ANALYZE before optimizing: shows actual row counts vs estimates. WHERE on unindexed column = Seq Scan = full table scan, kills perf",
            "Default isolation = READ COMMITTED. For 'no phantom reads' use REPEATABLE READ. For 'no serialization anomalies' use SERIALIZABLE (5x more retries)",
            "Backups: pg_dump for small, pg_basebackup for hot, WAL archiving for PITR. NEVER rely on just one of these — restore drill quarterly"
          ],
          "anti_patterns": [
            "Storing everything in one JSONB column to 'avoid migrations': loses JOIN performance, can't enforce structure, IDE has no autocomplete. JSONB for VARIABLE parts only",
            "Using OFFSET for pagination: OFFSET 100000 skips 100k rows = 1s latency. Use cursor: WHERE id > last_id ORDER BY id LIMIT 20 (10ms regardless of depth)",
            "Forgetting to add index on FK column: queries like WHERE user_id = 5 do Seq Scan because FK doesn't auto-create index. Always CREATE INDEX ON child_table (fk_col)",
            "Using SELECT * in production: breaks when column added, transfers 10x more data than needed (BLOB column? hope you wanted it). Always explicit column list",
            "Embedding business logic in stored procedures: hard to test, hard to version-control, no type safety. Use Python + SQLAlchemy, keep DB as persistence only"
          ],
          "prereqs": [
            "9.3",
            "9.4"
          ],
          "next_steps": [
            "9.6"
          ],
          "tags": [
            "postgres",
            "postgresql",
            "sql",
            "jsonb",
            "fts",
            "rdbms",
            "พี-จี"
          ],
          "compare_with": [
            "9.4 SQLite (single-writer, file-based)",
            "MySQL (similar, less JSONB)",
            "CockroachDB (Postgres-compatible, distributed)",
            "Supabase/Neon (managed Postgres)"
          ]
        },
        {
          "id": "9.6",
          "title": "Redis Cache & Pub/Sub",
          "icon": "⚡",
          "description": "In-memory data store: cache, pub/sub, rate limiting, session, queue, leaderboard, geo. Sub-ms latency.",
          "when_to_use": "Hot data cache, session store, realtime pub/sub, rate limit counters, Celery broker, leaderboards (sorted set), distributed lock, geo radius. EN: anything that needs sub-ms latency + shared state across multiple app servers.",
          "when_not_to_use": "Primary data store with > 64GB (use Postgres + cache pattern); complex queries (no JOINs in Redis); durable queue with > 7-day retention (use Kafka/SQS)",
          "snippet": "import redis\\nr = redis.Redis()\\nr.set('key', 'val', ex=300)  # 5-min TTL\\nr.incr('hits:user:42')         # atomic counter",
          "snippet_full": "# Redis patterns — cache + rate limit + pub/sub (all in one snippet)\\nimport redis\\n\\nr = redis.Redis(host='localhost', port=6379, decode_responses=True)\\n\\n# 1) Cache pattern: read-through with TTL\\ndef get_project(slug: str) -> dict | None:\\n    cached = r.get(f'project:{slug}')\\n    if cached:\\n        return json.loads(cached)\\n    project = db.get_project(slug)  # slow Postgres\\n    if project:\\n        r.setex(f'project:{slug}', 300, json.dumps(project))  # 5-min TTL\\n    return project\\n\\n# 2) Rate limit: atomic INCR with EXPIRE on first hit\\ndef check_rate_limit(user_id: str, limit_per_min: int = 60) -> bool:\\n    key = f'rate:{user_id}:{int(time.time() // 60)}'  # bucket per minute\\n    count = r.incr(key)\\n    if count == 1:\\n        r.expire(key, 60)  # TTL = bucket width\\n    return count <= limit_per_min\\n\\n# 3) Pub/Sub for WebSocket fanout across servers\\npubsub = r.pubsub()\\npubsub.subscribe('chat:room:42')\\nfor message in pubsub.listen():\\n    if message['type'] == 'message':\\n        broadcast_to_local_websockets(message['data'])  # to this server's clients",
          "difficulty": "Intermediate",
          "time_to_learn": "4 hours",
          "docs_url": "https://redis.io/docs/",
          "pros": [
            "Sub-millisecond GET/SET (in-memory, single-threaded but I/O multiplexed) — vs Postgres 5-50ms = 10-100x speedup for hot data",
            "Atomic INCR = perfect for rate limiting (sub 9.10) and counters: INCR api:user:42:hits → no race condition across concurrent requests",
            "Sorted sets (ZADD + ZRANGEBYSCORE) = O(log N) leaderboard: 'top 10 users by score this week' in 0.5ms vs Postgres window function 200ms",
            "Pub/Sub: PUBLISH + SUBSCRIBE = realtime cross-process messaging without Kafka setup; great for WebSocket fanout (sub 9.7 scaling)",
            "TTL on every key: SET foo bar EX 300 → auto-expire after 5 min. No cron job to clean up; no memory leak from forgotten keys",
            "Persistence optional: AOF every-sec or RDB snapshot — survive restart. Or pure in-memory = lose on restart but max speed"
          ],
          "cons": [
            "Volatile by default: server crash + no persistence = total data loss. Mitigate with AOF + appendfsync=always (slower) or everysec (lose 1s)",
            "Single-threaded for commands: 1 huge SLOW command (KEYS *) blocks ALL other clients. Use SCAN cursor (non-blocking) for any iteration",
            "No SQL: data is key-value / hash / list / set / zset. Complex 'SELECT with 4 JOINs' = multiple HGETs in app code = round-trip cost",
            "Memory-bound: entire dataset must fit in RAM. 1M keys × 1KB = 1GB. Eviction policy (allkeys-lru) helps but cold data vanishes on pressure",
            "Pub/Sub is fire-and-forget: no replay, no acknowledgment, no queue. Subscribers offline = messages lost. Use Redis Streams (XADD/XREAD) for durability",
            "No client-side cursors: KEYS pattern blocks, SCAN cursor adds 5x round-trips vs Postgres' single query. For 'find all keys matching X' Redis is wrong tool"
          ],
          "gotchas": [
            "Use SET with EX (seconds) or PX (ms), not separate EXPIRE: SET k v EX 300 = atomic. Two commands = race condition + key without TTL",
            "Pipeline (batched round-trip): pipe = redis.pipeline(); pipe.set('a', 1); pipe.get('b'); results = pipe.execute() — 1 round-trip for N commands = 10x throughput",
            "MSET for multi-key write: MSET k1 v1 k2 v2 = atomic + 1 round-trip. But MSET can't set per-key TTL, use Lua script for that",
            "Pipelining ≠ transaction: pipeline = 1 round-trip but no atomicity. MULTI/EXEC = atomic + 1 round-trip + commands run sequentially",
            "Eviction policy matters: default noeviction = OOM error on full. allkeys-lru = drop least-used (good for cache). volatile-lru = only drop keys with TTL (good for mixed)",
            "Lua scripts run atomically server-side: eval 'return redis.call(\"INCR\", KEYS[1])' 1 mykey = race-free increment + custom logic. Use for any 'read-modify-write' pattern",
            "Connection pool: redis.Redis() uses 1 connection by default. For 100 concurrent web requests, use redis.asyncio.Redis with connection_pool=ConnectionPool(max_connections=50)",
            "Don't use FLUSHDB in production: deletes EVERYTHING. Use FLUSHDB on a test DB (DB 15 say) or a separate Redis instance"
          ],
          "anti_patterns": [
            "Using Redis as primary DB without persistence: server restart = total data loss. Either enable AOF or treat as cache-only with warm-up on restart",
            "Storing 1MB+ values: 1 huge SET hogs the single thread, blocks all other clients. Keep values < 100KB; use S3/Postgres for large blobs",
            "Storing session data without TTL: 'session:abc' with no EX = leaks memory forever. Always SETEX or SESSION EXPIRE",
            "KEYS * in production: blocks Redis for seconds on million-key dataset. Always use SCAN cursor or maintain a separate index set",
            "Using LIST as a queue without consumer-group: BRPOP returns item to ONE client, no replay if consumer crashes mid-process. Use Streams (XADD/XREADGROUP) for durable queue"
          ],
          "next_steps": [
            "9.7",
            "9.10"
          ],
          "tags": [
            "redis",
            "cache",
            "pubsub",
            "rate-limit",
            "queue",
            "เรดิส",
            "แคช"
          ],
          "compare_with": [
            "Memcached (cache only, no pub/sub)",
            "RabbitMQ/Kafka (durable queue)",
            "Valkey (Redis fork, BSD)",
            "Upstash (serverless Redis)"
          ]
        },
        {
          "id": "9.7",
          "title": "WebSocket",
          "icon": "🔄",
          "description": "Bidirectional persistent connection for realtime: chat, live cursor, multiplayer, push notifications.",
          "when_to_use": "Realtime features (chat, multiplayer, live cursor, dashboard updates, push notifications), low-latency bidirectional, server-push needed. EN: anything realtime that needs server→client AND client→server on same connection.",
          "when_not_to_use": "One-way server→client (use SSE, sub 9.8, simpler); infrequent updates > 5s (just poll or use SSE); > 100k concurrent connections per server (use Pusher/Ably managed)",
          "snippet": "@app.websocket('/ws')\\nasync def ws(socket: WebSocket):\\n    await socket.accept()\\n    while True:\\n        msg = await socket.receive_text()\\n        await socket.send_text(f'echo: {msg}')",
          "snippet_full": "# Production WebSocket pattern — FastAPI + JWT auth + heartbeat + Redis pub/sub fanout\\nimport asyncio, json, jwt\\nfrom fastapi import FastAPI, WebSocket, WebSocketDisconnect\\nimport redis.asyncio as redis\\n\\napp = FastAPI()\\nr = redis.Redis()\\n\\n@app.websocket('/ws/chat/{room_id}')\\nasync def chat(socket: WebSocket, room_id: str, token: str):\\n    # 1) Auth BEFORE accept — reject before handshake\\n    try:\\n        user = jwt.decode(token, SECRET, algorithms=['HS256'])\\n    except jwt.PyJWTError:\\n        await socket.close(code=4001, reason='invalid token')\\n        return\\n    await socket.accept()\\n    user_id = user['sub']\\n    \\n    # 2) Redis pub/sub — works across multiple uvicorn workers\\n    pubsub = r.pubsub()\\n    await pubsub.subscribe(f'room:{room_id}')\\n    listener = asyncio.create_task(_pump(socket, pubsub, user_id))\\n    \\n    try:\\n        while True:\\n            raw = await socket.receive_text()\\n            msg = json.loads(raw)\\n            if msg.get('type') == 'ping':\\n                await socket.send_text(json.dumps({'type': 'pong'}))\\n            else:\\n                await r.publish(f'room:{room_id}',\\n                    json.dumps({'from': user_id, 'text': msg.get('text', '')}))\\n    except WebSocketDisconnect:\\n        listener.cancel()\\n        await pubsub.unsubscribe()\\n    # NOTE: production needs heartbeat (ping every 30s, drop if no pong in 60s)",
          "difficulty": "Advanced",
          "time_to_learn": "1 day",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket",
          "pros": [
            "Full-duplex over single TCP connection: no HTTP overhead per message (no headers, no auth re-check) = 100x lower per-message latency vs polling",
            "Server can push anytime: client doesn't have to ask. Perfect for chat, live price, multiplayer position sync, notifications",
            "Persistent connection = no reconnect/auth cost per message. Open once, send 10k messages for free (vs 10k HTTPS round-trips)",
            "Frame-based protocol: 1 TCP connection multiplexes many 'channels' (subprotocol pattern) — chat + notifications + presence on one socket",
            "Browser native API: new WebSocket('wss://...') — no library needed for client. Works through HTTP/2 + HTTP/3 (RFC 8441)",
            "Binary frames supported: send ArrayBuffer for game state, image delta, protobuf. 60% smaller than JSON text"
          ],
          "cons": [
            "Stateful: server must track open connections in memory. Restart server = 10k clients reconnect at once (thundering herd). Use exponential backoff on client",
            "Scaling pain: connection 1234 lives on server A. Request 1235 goes to server B. Need sticky session (load balancer hash on client IP) OR centralized pub/sub (Redis)",
            "No HTTP semantics: no status code per message, no headers, no auth middleware. Auth happens once on upgrade (Sec-WebSocket-Protocol or first message)",
            "Browser limits: 6 connections per origin in HTTP/1.1 (HTTP/2 lifts to 1000+). One tab with 1 socket = 5 sockets left for other things",
            "Proxy/load-balancer hostile: many CDNs/edges don't support WebSocket (Cloudflare does, AWS ALB needs config, Vercel = no). Plan deploy target before design",
            "Reconnect logic must be bulletproof: mobile networks drop constantly, browsers throttle background tabs. Need heartbeat ping every 30s + exponential reconnect"
          ],
          "gotchas": [
            "Heartbeat ping/pong every 30s: without it, NAT/firewall silently drops the idle connection after 60s and client doesn't know. Send 'ping', expect 'pong' within 10s",
            "Use wss:// (TLS), not ws://, in production: cleartext WebSocket leaks auth tokens in plaintext, banned by all browsers in 2024+",
            "Auth on upgrade: pass JWT in Sec-WebSocket-Protocol header OR as ?token= query string. Validate BEFORE accept(). Accept-then-validate = unauth'd message allowed",
            "Sticky session needed for single-server deploy: nginx ip_hash; HAProxy cookie; AWS ALB sticky session. Without it, reconnect lands on different server = lost state",
            "Cross-server broadcast needs Redis pub/sub: PUBLISH 'room:42' message on server A, server B SUBSCRIBEs and forwards to its local sockets. Don't try direct connection between servers",
            "Frame size limit: default 1MB max payload. Sending 10MB image = PROTOCOL_ERROR + drop. Chunk large messages client-side, send length-prefix",
            "Clean close: send close code (1000 = normal, 1001 = going away, 4000-4999 = app-specific) + reason. Clients can branch on code (e.g. '1001 = reconnect with backoff')",
            "Reconnect storm: 10k clients all disconnect on server restart = 10k reconnects in 1s. Client must use exponential backoff (1s, 2s, 4s, 8s, max 30s) + jitter"
          ],
          "anti_patterns": [
            "Sending every game tick: 60Hz position updates = 60 messages/sec/client × 1000 clients = 60k msg/sec. Throttle to 10Hz + delta compression",
            "Storing connection state in module global dict: works for 1 process, breaks for multi-worker / multi-server. Use Redis or DB",
            "Skipping auth on upgrade: any anonymous client can connect and spam. Validate JWT in connection handler before accept()",
            "Sending JSON with Date object: client gets string, not Date, may interpret wrong TZ. Use ISO 8601 string OR numeric epoch ms (preferred)",
            "No backpressure handling: client slow to receive = server send buffer fills up = 1 slow client blocks all others on same event loop. Per-socket bounded queue + drop oldest"
          ],
          "prereqs": [
            "1.7",
            "9.2"
          ],
          "next_steps": [
            "9.8"
          ],
          "tags": [
            "websocket",
            "realtime",
            "ws",
            "wss",
            "pubsub",
            "แชท",
            "เรียลไทม์"
          ],
          "compare_with": [
            "9.8 SSE (one-way, simpler)",
            "Socket.IO (WebSocket + fallback to polling)",
            "Pusher/Ably (managed, 100k+ conn)",
            "WebRTC (peer-to-peer, no server)"
          ]
        },
        {
          "id": "9.8",
          "title": "Server-Sent Events (SSE)",
          "icon": "📡",
          "description": "One-way server→client HTTP stream for live feeds, progress bars, notifications. Simpler than WebSocket.",
          "when_to_use": "Server-to-client only stream: live news feed, build progress bar, AI token stream, stock price, log tail, notification push. EN: any one-way server→client stream over plain HTTP.",
          "when_not_to_use": "Client needs to send frequent messages (use WebSocket sub 9.7); needs to work through proxies that buffer HTTP (SSE keeps connection open, most CDNs fine); < 1 update/sec (just poll or REST)",
          "snippet": "from fastapi.responses import StreamingResponse\\nasync def stream():\\n    while True:\\n        yield f'data: {value}\\n\\n'\\n        await asyncio.sleep(1)\\nreturn StreamingResponse(stream(), media_type='text/event-stream')",
          "snippet_full": "# Production SSE pattern — FastAPI + typed events + heartbeat + reconnect resume\\nimport asyncio, json\\nfrom fastapi import FastAPI, Request\\nfrom fastapi.responses import StreamingResponse\\n\\napp = FastAPI()\\n\\nasync def build_progress_stream(build_id: str, last_event_id: str | None):\\n    # 1) Heartbeat IMMEDIATELY so client knows it's connected\\n    yield ': connected\\n\\n'  # comment line = heartbeat, no event\\n    \\n    cursor = int(last_event_id) if last_event_id else 0\\n    while True:\\n        # 2) Fetch new events since cursor (e.g. from DB or in-memory queue)\\n        events = await db.fetch_events(build_id, since=cursor)\\n        for e in events:\\n            cursor = e.id\\n            # 3) 'id:' = resume token; 'event:' = typed; 'data:' = payload\\n            yield f'id: {e.id}\\nevent: {e.type}\\ndata: {json.dumps(e.payload)}\\n\\n'\\n        # 4) 15s heartbeat keeps NAT/firewall happy (they drop idle after ~60s)\\n        try:\\n            await asyncio.wait_for(asyncio.shield(_next_event()), timeout=15)\\n        except asyncio.TimeoutError:\\n            yield ': keep-alive\\n\\n'\\n\\n@app.get('/api/build/{build_id}/stream')\\ndef stream_build(build_id: str, request: Request):\\n    last_id = request.headers.get('last-event-id')\\n    return StreamingResponse(\\n        build_progress_stream(build_id, last_id),\\n        media_type='text/event-stream',\\n        headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'},  # disable nginx buffer\\n    )\\n# Browser: new EventSource('/api/build/abc/stream') — auto-reconnects with Last-Event-ID",
          "difficulty": "Intermediate",
          "time_to_learn": "2 hours",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events",
          "pros": [
            "Plain HTTP: works through every proxy, CDN, load balancer, no upgrade handshake, no special config. wss:// vs ws:// headaches gone",
            "EventSource API in browser auto-reconnects on disconnect with Last-Event-ID — server can resume from where client left off (just return from log cursor)",
            "One-line server: yield 'data: foo\\n\\n' from a generator = stream. No socket state, no sticky session, scales horizontally with zero config",
            "Text-only is fine: 99% of use cases (progress, status, text tokens) are text. JSON in payload = same speed as binary for < 1KB",
            "Built-in event types: 'data:' for message, 'event: name' for typed events, 'id:' for resume, 'retry:' for reconnect interval. Simple but complete",
            "Serverless-friendly: Lambda response streaming + EventSource = realtime without long-running WebSocket server. Far cheaper"
          ],
          "cons": [
            "One-way only: client can't send back over the same connection. Need a separate POST endpoint for client→server. 2 connections instead of 1 for chat-like",
            "Text-only (UTF-8): binary data needs base64 = 33% bloat. Use WebSocket sub 9.7 if sending binary frames (game state, images)",
            "Browser limit: 6 concurrent SSE connections per origin in HTTP/1.1. HTTP/2 lifts this. Problem only if > 6 live feeds per page",
            "No binary framing: every message is full HTTP chunk (header + payload). High-frequency (> 100 msg/sec) = high overhead. WebSocket better for chat firehose",
            "Reconnect floods: server restart = all clients reconnect. EventSource retry hint helps, but no built-in exponential backoff (need to use polyfill or library)",
            "Proxy buffering: nginx by default buffers responses up to 1MB. SSE needs proxy_buffering off + chunked transfer-encoding or client sees nothing until 1MB"
          ],
          "gotchas": [
            "Every event MUST end with \\n\\n (two newlines): 'data: hello\\n\\n' — one newline = client never sees it, this is the SSE framing rule",
            "Disable proxy buffering: nginx 'proxy_buffering off; proxy_cache off;', Cloudflare 'Cache-Control: no-cache' header, otherwise clients see nothing until buffer fills",
            "Use 'event: name' to type messages: client adds eventSource.addEventListener('progress', e => ...). Default 'message' event for 'data:' lines",
            "Send heartbeat every 15-30s: 'data: \\n\\n' (just a colon) keeps connection alive through NAT/firewall (they drop idle after 60s). Without it, clients see 'reconnecting' every minute",
            "Last-Event-ID header on reconnect: client sends the last 'id:' it received. Server can use it to resume: SELECT * FROM logs WHERE id > last_id. Cheap catch-up",
            "FastAPI StreamingResponse: don't await sleep — use async for + yield, OR sync generator with time.sleep. Mixing breaks the event loop",
            "CORS: 'Content-Type: text/event-stream' + 'Cache-Control: no-cache' + 'Connection: keep-alive' headers. EventSource in browser respects CORS like fetch",
            "Serverless: AWS Lambda response streaming (2023+) supports SSE but max 20-min connection. For longer, use Cloudflare Workers (5-min) or EC2/Container"
          ],
          "anti_patterns": [
            "Sending JSON without 'data: ' prefix: EventSource client only fires on 'data: ' lines. JSON.stringify(obj) directly = client gets nothing",
            "Closing connection on error: client wants to reconnect, server should send 500 then close. Client tries reconnect per 'retry:' hint. Closing on first error = no recovery",
            "Sending huge payload: each message is a new HTTP chunk. 1MB JSON = 1MB per message = slow. Batch: send last 100 events as one chunk if late",
            "Forgetting to set Content-Type: 'text/event-stream' without it, browser EventSource refuses to connect. application/json = also rejected",
            "Polling for SSE support: old IE/old Edge don't support EventSource. Use a polyfill (event-source-polyfill) OR just WebSocket sub 9.7"
          ],
          "prereqs": [
            "9.2"
          ],
          "tags": [
            "sse",
            "events",
            "streaming",
            "server-sent-events",
            "realtime",
            "เอสเอสอี"
          ],
          "compare_with": [
            "9.7 WebSocket (bidirectional)",
            "Long polling (old, server holds HTTP open)",
            "Webhooks (client polls server, opposite direction)"
          ]
        },
        {
          "id": "9.9",
          "title": "OAuth 2.0 & Social Login",
          "icon": "🔑",
          "description": "Delegate auth to Google/GitHub/Facebook. PKCE for SPA, Authorization Code for backend, refresh tokens for long sessions.",
          "when_to_use": "Apps ที่ต้องการ user account โดยไม่เก็บ password; need profile data from Google/GitHub; B2B SSO (Google Workspace, Okta). EN: any app that needs user accounts; don't want to store passwords; need social profile data.",
          "when_not_to_use": "Internal tool with < 10 users (just username + bcrypt); full server-side rendered app (use session cookie + DB password, simpler); machine-to-machine (use API key or mTLS)",
          "examples": "codehub-api",
          "snippet": "# 1. Redirect to /auth/google with state + PKCE\\n# 2. Callback /auth/google/cb?code=...&state=...\\n# 3. Exchange code + verifier for access_token + refresh_token\\n# 4. Call /userinfo with access_token to get profile",
          "snippet_full": "# Production OAuth pattern — Google Authorization Code + PKCE + state CSRF + refresh\\n# CodeHub-lightweight variant: X-Admin-Token (simpler, no OAuth provider)\\nimport secrets, hashlib, base64, httpx, jwt\\nfrom fastapi import FastAPI, Response, Cookie, HTTPException\\n\\napp = FastAPI()\\nGOOGLE_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'\\nGOOGLE_TOKEN = 'https://oauth2.googleapis.com/token'\\nGOOGLE_USERINFO = 'https://www.googleapis.com/oauth2/v2/userinfo'\\n\\ndef _pkce_pair() -> tuple[str, str]:\\n    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip('=')\\n    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')\\n    return verifier, challenge\\n\\n@app.get('/auth/google')\\ndef login(response: Response):\\n    state = secrets.token_urlsafe(32)\\n    verifier, challenge = _pkce_pair()\\n    # Store BOTH in HttpOnly cookies — verifier never sent to browser via JSON\\n    response.set_cookie('oauth_state', state, httponly=True, secure=True, samesite='lax')\\n    response.set_cookie('oauth_verifier', verifier, httponly=True, secure=True, samesite='lax')\\n    params = {'client_id': GOOGLE_CLIENT_ID, 'redirect_uri': 'https://app.x.com/auth/google/cb',\\n              'response_type': 'code', 'scope': 'openid email profile',\\n              'state': state, 'code_challenge': challenge, 'code_challenge_method': 'S256'}\\n    return RedirectResponse(f'{GOOGLE_AUTH}?{urlencode(params)}')\\n\\n@app.get('/auth/google/cb')\\nasync def callback(code: str, state: str, oauth_state: str = Cookie(None),\\n                    oauth_verifier: str = Cookie(None), response: Response = None):\\n    # 1) CSRF check\\n    if not oauth_state or oauth_state != state:\\n        raise HTTPException(400, 'state mismatch — possible CSRF')\\n    # 2) Exchange code + verifier for token\\n    async with httpx.AsyncClient() as h:\\n        tok = (await h.post(GOOGLE_TOKEN, data={\\n            'client_id': GOOGLE_CLIENT_ID, 'client_secret': GOOGLE_SECRET,\\n            'code': code, 'code_verifier': oauth_verifier,\\n            'redirect_uri': 'https://app.x.com/auth/google/cb',\\n            'grant_type': 'authorization_code'\\n        })).json()\\n        profile = (await h.get(GOOGLE_USERINFO,\\n                  headers={'Authorization': f'Bearer {tok[\"access_token\"]}'})).json()\\n    # 3) Issue SESSION (server-side), not the OAuth token\\n    session_id = create_session(profile['email'])\\n    response.set_cookie('sid', session_id, httponly=True, secure=True, samesite='lax', max_age=86400*30)\\n    response.delete_cookie('oauth_state'); response.delete_cookie('oauth_verifier')\\n    return RedirectResponse('/')",
          "difficulty": "Advanced",
          "time_to_learn": "3 days",
          "docs_url": "https://oauth.net/2/",
          "pros": [
            "No password storage: zero liability for breach, no 'forgot password' flow to build, no email verification to code. Provider handles all of it",
            "Social profile = instant onboarding: 1 click → name, email, avatar, verified email. Conversion rate 2-5x vs signup form",
            "OAuth scopes: ask only for what you need (email, profile, calendar). User sees exactly what you can access, more trust",
            "Refresh token = long-lived session (30 days) without storing user's password. Short access token (1h) limits blast radius if leaked",
            "Standardized: every library (NextAuth, Authlib, Passport, requests-oauthlib) supports it. Same code works for Google, GitHub, Facebook, Microsoft",
            "PKCE (RFC 7636) makes authorization code flow safe for public clients (SPA, mobile) without client_secret — code_verifier in cookie, code_challenge in URL"
          ],
          "cons": [
            "Multiple flows: Authorization Code, Implicit (deprecated 2020), Client Credentials, Password Grant (deprecated). Pick wrong = security hole. Code + PKCE is the default for 2024+",
            "State parameter for CSRF: forget to validate state on callback = attacker pre-auths victim's browser. Always generate random state, store in session, validate on return",
            "Refresh token rotation: if attacker steals refresh token, you can't detect. Solution = rotate on every use (new refresh token issued + old invalidated), keep family-id for replay detection",
            "Scope creep: ask for 'profile + email + contacts + drive' = user sees scary consent screen = 50% drop-off. Minimum scope, ask incrementally (re-consent later)",
            "Provider downtime: Google OAuth down 1h/quarter (their SLA). Implement fallback: also support GitHub OR password login, so user has backup",
            "Logout complexity: revoking token at provider requires separate API call. Just dropping cookie locally = user still 'logged in' if they reopen in new tab"
          ],
          "gotchas": [
            "Authorization Code with PKCE: client generates code_verifier (random 43-128 char), code_challenge = base64url(SHA256(verifier)). Send challenge in /authorize, verifier in /token exchange",
            "State parameter: random base64 token, store in HttpOnly cookie, validate on callback. Without it, CSRF attack can pre-auth attacker's account into victim's browser",
            "Nonce for ID token: required by OIDC, prevents token replay. Random value in /authorize, must match in ID token claim",
            "Token storage: NEVER localStorage (XSS-readable). Use HttpOnly + Secure + SameSite=Lax cookie. SPA reads from cookie, sends to /api/me, not from JS",
            "Token expiration: access token 1h, refresh token 30d. Server checks exp claim on every request. Refresh 5 min BEFORE expiry, not after (avoids race)",
            "Redirect URI whitelist: must match EXACTLY (scheme + host + port + path). 'http://localhost:3000/cb' ≠ 'http://localhost:3000/cb/' = auth fails. Add to provider console FIRST",
            "Client secret: NEVER in SPA bundle (visible in DevTools). SPA uses PKCE without secret. Backend uses secret. Mobile: secret in keystore, NOT in code",
            "Logout everywhere: revoke token via /revoke endpoint, clear cookie, optionally call provider's logout URL to log out from Google too. Otherwise 'logout' is a lie"
          ],
          "anti_patterns": [
            "Implicit flow for SPA: deprecated by OAuth 2.1 draft (2020). Token in URL fragment = logged in referer headers, no PKCE, no refresh. Use Authorization Code + PKCE",
            "Storing access token in localStorage: XSS = game over. HttpOnly cookie ONLY, OR in-memory + refresh in HttpOnly cookie",
            "Skipping state parameter: 'works fine in dev' = CSRF vulnerability. Always generate, store, validate",
            "Asking for 'offline_access' scope without using refresh: 90% of apps never refresh, just re-login on expiry. Use 'prompt=consent' to get refresh only when needed",
            "Not validating ID token signature: just trusting payload = trivial forgery. Verify with provider's JWKS endpoint, check 'iss', 'aud', 'exp', 'nonce' claims"
          ],
          "prereqs": [
            "9.2"
          ],
          "next_steps": [
            "9.10"
          ],
          "tags": [
            "oauth",
            "auth",
            "login",
            "pkce",
            "google",
            "github",
            "jwt",
            "ล็อกอิน",
            "ยืนยันตัวตน"
          ],
          "compare_with": [
            "Session cookie + bcrypt password (simpler, no provider)",
            "NextAuth/Auth0 (managed)",
            "Clerk/Supabase Auth (managed)",
            "JWT-only (stateless, harder to revoke)"
          ]
        },
        {
          "id": "9.10",
          "title": "Rate Limiting & Abuse Prevention",
          "icon": "🚦",
          "description": "Token bucket per IP/user/endpoint. Prevent abuse, brute force, scraper overload, runaway clients.",
          "when_to_use": "Public APIs, login endpoints (anti-bruteforce), signup (anti-spam), expensive endpoints (search, export), any endpoint where one client can hurt others. EN: any endpoint where abuse can affect cost, fairness, or security.",
          "when_not_to_use": "Pure internal API with trusted clients (use simple API key, no per-request limit); one-time batch job (use quota, not rate)",
          "snippet": "from slowapi import Limiter\\nlimiter = Limiter(key_func=get_remote_address)\\n@app.get('/api/x')\\n@limiter.limit('5/minute')\\ndef x(): ...",
          "snippet_full": "# Production rate limit pattern — slowapi + Redis backend + per-user-after-auth\\n# (CodeHub doesn't implement this yet; would go in api/app.py as middleware)\\nfrom fastapi import FastAPI, Request, Depends\\nfrom slowapi import Limiter, _rate_limit_exceeded_handler\\nfrom slowapi.util import get_remote_address\\nfrom slowapi.errors import RateLimitExceeded\\nfrom slowapi.middleware import SlowAPIMiddleware\\nimport redis\\n\\nlimiter = Limiter(\\n    key_func=get_remote_address,  # default = IP; override per-route with custom key\\n    storage_uri='redis://localhost:6379/0',  # multi-server consistent\\n    strategy='fixed-window',  # or 'moving-window' for smoother\\n)\\napp = FastAPI()\\napp.state.limiter = limiter\\napp.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)\\napp.add_middleware(SlowAPIMiddleware)\\n\\n# 1) Per-IP limit on expensive endpoint\\n@app.get('/api/search')\\n@limiter.limit('60/minute')  # 60 searches per IP per minute\\ndef search(q: str, request: Request):\\n    return do_search(q)\\n\\n# 2) Per-USER limit on write endpoint (requires auth dependency)\\ndef user_key(request: Request):\\n    user = request.state.user  # set by auth middleware\\n    return user['id'] if user else get_remote_address(request)\\n\\n@app.post('/api/admin/project')\\n@limiter.limit('10/hour', key_func=user_key)  # 10 creates per user per hour\\nasync def create_project(p: ProjectIn, request: Request, _user = Depends(require_admin)):\\n    repo.create(p)\\n    return {'ok': True}\\n\\n# 3) Strict limit on auth endpoints (anti-bruteforce)\\n@app.post('/api/login')\\n@limiter.limit('5/minute', key_func=get_remote_address)  # 5 login attempts per IP per min\\nasync def login(req: Request, body: LoginIn):\\n    return authenticate(body.email, body.password)\\n# 429 response includes Retry-After header; slowapi auto-handles",
          "difficulty": "Intermediate",
          "time_to_learn": "1 hour",
          "docs_url": "https://slowapi.readthedocs.io/",
          "pros": [
            "Prevents brute force: 5 attempts/min on /login = 7k attempts/day max vs unlimited = millions. Kills credential stuffing",
            "Fairness: stops one misbehaving client from starving all others. 100 req/s global limit divided = 10 req/s/user cap",
            "Cost control: protects DB / external API bill from runaway. Each /api/search = 100ms DB + external API call. Limit to 60/min/user = max 1/sec expensive work",
            "DDoS mitigation: per-IP limit drops a single attacker from 10k req/s to 60 req/s in 1 line. Doesn't stop L3/L4 DDoS (need CDN), but blocks L7 abuse",
            "Many algorithms: fixed window (simplest, bursts at boundary), sliding window (smoother), token bucket (allows burst then steady), leaky bucket (steady rate)",
            "Implementation trivial in FastAPI: slowapi library = decorator, 2 lines. Redis-backed = multi-server consistent"
          ],
          "cons": [
            "False positives: shared NAT (school, corporate VPN) = 1000 users behind 1 IP = 1 user spamming kills whole school. Need per-user-after-auth limit, not just per-IP",
            "Boundary bursts: fixed window allows 2x rate at boundary (5:59:59 → 6 reqs, 6:00:00 → 6 reqs = 12 in 1s). Use sliding window or token bucket to avoid",
            "State storage: limit counter must be in Redis (multi-server) — not in-memory (per-server). 1 line config, but a new dep",
            "Differentiating legit from abuser: simple rate limit blocks both. Need 'challenge' (CAPTCHA, JS proof-of-work) for grey zone, not just deny",
            "429 response: clients must read Retry-After header and respect it. Naive client hammers at 1 Hz = 1% of limit used for retries. Servers should 'punish' by raising limit",
            "Cost of enforcement itself: 1 Redis INCR per request = 0.5ms. For 100k req/s that's 50k Redis ops/s. Acceptable; but every micro-optimization costs more than it saves"
          ],
          "gotchas": [
            "Use 429 status code (Too Many Requests) + Retry-After header (seconds or HTTP date). Client respects it, user sees 'try again in 30s' not generic 500",
            "Per-IP works only as first line: identify user (JWT/API key) and use per-user limit when authenticated. Otherwise 1 authenticated client = unlimited, 1 IP = limited",
            "Different limits per endpoint: /login = 5/min/IP (brute force), /api/search = 60/min/user (cost), /api/projects = 300/min/IP (fair use). One global limit = wrong priorities",
            "Burst allowance: token bucket with burst=10 means client can do 10 quick then 1/s steady. Better UX for legit users (page load = 8 calls in 200ms)",
            "Redis INCR is atomic + O(1): but key naming matters. r:user:42:60 = per minute. Or r:user:42 (with TTL=60 set on first INCR). Or Lua script for read-modify-write",
            "Pre-auth vs post-auth: pre-auth (login, signup) = per-IP. Post-auth = per-user. After successful auth, check per-user (catches credential stuffing across IPs)",
            "SlowAPI (Python) or express-rate-limit (Node) or tollbooth (Go) — pick one. Hand-rolled is always wrong (forgot edge case: clock skew, race condition, fail-open vs fail-closed)",
            "Fail-open vs fail-closed: if Redis is down, allow all (fail-open, safer for UX) or deny all (fail-closed, safer for security). Pick based on endpoint: /login fail-CLOSED, /api/data fail-OPEN"
          ],
          "anti_patterns": [
            "In-memory rate limit on multi-server deploy: server A has 60 reqs, server B has 60 reqs, total = 120. Use Redis-backed ALWAYS in production",
            "Per-second limits only: client can do 5 reqs/sec = 432k/day. Real abuse is sustained over minutes. Use per-minute OR per-hour limit, not per-second",
            "Returning 503 instead of 429: 503 = 'service unavailable, try again later' (server problem). 429 = 'you specifically, slow down' (client problem). Use 429 for rate limit",
            "Rate limiting on /health endpoint: legitimate health checks fire every 1s, will get blocked. EXCLUDE /health, /metrics, /docs from rate limiter",
            "No X-RateLimit-* headers: client has no way to know remaining quota, can't back off proactively. Return X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (epoch seconds)"
          ],
          "prereqs": [
            "9.2",
            "9.6"
          ],
          "tags": [
            "ratelimit",
            "abuse",
            "security",
            "bruteforce",
            "ddos",
            "slowapi",
            "rate-limit",
            "การจำกัดอัตรา"
          ],
          "compare_with": [
            "Cloudflare (L7 DDoS, free tier)",
            "API Gateway (AWS/Cloudflare)",
            "Envoy proxy (service-mesh level)",
            "fail2ban (SSH-style, not HTTP)"
          ]
        }
      ]
    },
    {
      "id": 10,
      "name": "Domain Recipes",
      "icon": "🎯",
      "tagline": "Copy patterns — reuse immediately",
      "sub_phases": [
        {
          "id": "10.1",
          "title": "Tycoon Game",
          "icon": "🏪",
          "description": "Customer state machine (arrive→queue→order→wait→eat→pay) + patience decay + tip multiplier + reputation + upgrade economy.",
          "when_to_use": "ร้านอาหาร/โรงงาน/ธุรกิจจำลองที่ player ต้อง serve entities แบบ real-time; idle/incremental ที่มี production loop. Use เมื่อ core fun = wait → upgrade → wait less.",
          "when_not_to_use": "เกมที่ player เป็น entity เอง (action/RPG); เกมที่ไม่มี currency; เกม single-shot < 5 นาที; simulation ที่ไม่มี scarcity",
          "examples": "noodle-shop, stronghold-3d, mars-colony",
          "snippet": "// Customer FSM: arrive→queue→order→wait→eat→pay. Tip=base×(patience/100)×rep_mod\\nif(c.patience>70) earn+=c.order.price*.3; // 30% tip if served fast",
          "snippet_full": "// Customer state machine — finite states with explicit transitions\\nclass Customer {\\n  constructor(recipe, spawnTime) {\\n    this.recipe = recipe; this.patience = 100; this.state = 'queue';\\n    this.spawnAt = spawnTime; this.tipMultiplier = 1.0;\\n  }\\n  update(dt) {\\n    if (this.state === 'queue' || this.state === 'wait')\\n      this.patience -= dt * 1.2; // 1.2%/sec decay\\n    if (this.patience <= 0 && this.state !== 'leaving') {\\n      this.state = 'leaving'; this.complained = true;\\n    }\\n  }\\n  serve(cookTime, repMod) {\\n    this.state = 'eat';\\n    const speedBonus = this.patience / 100; // 0-1\\n    this.tipMultiplier = Math.min(2.0, 1.0 + speedBonus * repMod);\\n    return this.recipe.price * this.tipMultiplier; // tip-included earn\\n  }\\n}\\n// Spawn loop: Poisson avg 8s, max 50 customers pooled\\nlet nextSpawn = 0; function tick(t, dt) {\\n  if (t > nextSpawn && pool.length < 50) {\\n    pool.push(new Customer(randomRecipe(), t));\\n    nextSpawn = t + 8 + Math.random() * 4; // 6-12s jitter\\n  }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 week",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes",
          "pros": [
            "Customer state machine ทำให้ทุก customer มี 'life' ของตัวเอง — player เห็น urgency จาก patience bar ที่ลดลง",
            "Tip multiplier (1.0-2.0×) ตอบแทนความเร็ว + คุณภาพ — reward skillful play แทนการ grind อย่างเดียว",
            "Reputation 0-5★ เป็น meta-loop ที่กัน inflation — ราคาแพงขึ้นต้องคู่กับคุณภาพ ไม่งั้น customer หนี",
            "Upgrade tree (queue size, cook speed, tip bonus) ให้ player รู้สึก progress ทุก 2-3 นาที",
            "วัด KPI ได้ง่าย (served/hour, avg tip, complaint rate) — เหมาะกับ data-driven balancing"
          ],
          "cons": [
            "Balance tuning ใช้เวลา 2-3 สัปดาห์ — patience decay rate, spawn rate, upgrade cost ต้องวนปรับจนกว่า 'feels right'",
            "Late-game prestige ต้องคิดล่วงหน้า — เมื่อ player มีเงิน 1M+ ต้องมี sink (cosmetics/upgrades) ไม่งั้นเกมจบ",
            "Customer spawn ต้อง rate-limit ไม่งั้น frame rate ตก — ใช้ object pool แทน new/delete",
            "AI customer ทำนายไม่ได้ — random complaint ทำให้ player รู้สึก unfair ถ้าไม่มี visual cue (anger icon)",
            "Save/load ต้อง serialize pending customers + queue position + patience — ง่ายที่จะ bug ตรงนี้"
          ],
          "gotchas": [
            "Patience decay rate ต้อง ≤ 1.5%/วินาที — เร็วกว่านี้ player กดเครียด, ช้ากว่านี้ไม่มี urgency",
            "Tip = base × (patience_remaining/100) × reputation_modifier — clamp [1.0, 2.0] ไม่งั้น whale player ได้ infinite money",
            "Spawn rate ต้อง Poisson distribution (avg 8s) ไม่ใช่ fixed interval — fixed = 'feels robotic'",
            "Reputation decay 0.05/นาทีเมื่อไม่ serve — ถ้าไม่ decay, late game ไม่มี tension",
            "Use object pool สำหรับ customer entities (50 ตัว max) — new/delete ทุก 8s = GC stutter",
            "Customer ต้องมี patience bar visible + color-coded (green→yellow→red) — text-only ไม่สื่อ",
            "Upgrade cost = base × 1.6^level (geometric) — linear cost ทำให้ late upgrade feel grindy",
            "Save state ต้องเก็บ queueIndex + patience snapshot ต่อ customer, ไม่ใช่แค่ money"
          ],
          "anti_patterns": [
            "ทำ fixed upgrade path — player เบื่อเร็ว ต้องมี 2-3 branching specialization (speed vs quality vs capacity)",
            "ลืม in-game economy sink — เงินไม่มีที่ใช้ late game = grind ไม่จบ",
            "Spawn customer เร็วเกินจน overlap กัน visually — queue เละ, player สับสน",
            "ไม่มี fail state — tycoon ที่ไม่มี bankruptcy/chapter reset = ไม่มี risk = ไม่สนุก",
            "Patience bar แบบ linear — ต้องเป็น piecewise (0-30% เร็ว, 30-70% ช้า, 70-100% เร็วมาก) เพื่อ create urgency ช่วงท้าย"
          ],
          "prereqs": [
            "1.3",
            "1.4",
            "4.1"
          ],
          "next_steps": [
            "10.7",
            "10.4"
          ],
          "tags": [
            "tycoon",
            "recipe",
            "queue",
            "fsm",
            "tycoon-game",
            "ร้านอาหาร",
            "engagement-loop",
            "patience",
            "tips"
          ],
          "compare_with": [
            "10.7 Restaurant Sim (food-specific)",
            "10.4 Tower Defense (wave + currency, no patience)",
            "10.2 City Builder (no per-entity state)"
          ]
        },
        {
          "id": "10.2",
          "title": "City Builder",
          "icon": "🏰",
          "description": "Grid 14×10 + buildings + level + visual scale 1.15× + adjacency bonus + resource chain (wood→plank→house) + zoning.",
          "when_to_use": "เกมวางผังเมือง/ฐาน/อาณาจักรที่ player วาง tile บน grid แล้วตึก produce resource. Use เมื่อ fun = spatial puzzle + production chain optimization.",
          "when_not_to_use": "เกม action ที่ camera อิสระ; เกมที่ไม่มี grid; 4X strategy (ยาวเกินไป); tycoon single-screen (เกิน 1 cell = grid นี้ไม่พอ)",
          "examples": "stronghold-3d, mars-colony, realm-of-heroes",
          "snippet": "buildings[id]={type,x,z,level,group,meshes,baseY}; function upgrade(id){const b=buildings[id]; b.level++; b.meshes.forEach(m=>m.scaling.scaleInPlace(1.15))}",
          "snippet_full": "// Stronghold-3D style: grid 14x10, building + level + adjacency\\nconst GRID_W=14, GRID_H=10; const cells = Array.from({length:GRID_W}, () => Array(GRID_H).fill(null));\\nconst buildings = {}; let nextId=1;\\nfunction place(type, x, z) {\\n  if (cells[x][z]) return null; // occupied\\n  const id = nextId++;\\n  const mesh = createMesh(type); // Babylon mesh\\n  cells[x][z] = id;\\n  buildings[id] = { id, type, x, z, level:1, mesh, baseY:0 };\\n  return id;\\n}\\nfunction upgrade(id) {\\n  const b = buildings[id]; if (b.level >= 5) return;\\n  const cost = Math.floor(100 * Math.pow(1.6, b.level));\\n  if (gold < cost) return; gold -= cost;\\n  b.level++; b.mesh.scaling.scaleInPlace(1.15); // 1.15x per level\\n  recomputeAdjacency(b.x, b.z); // bonus for neighbors\\n}\\nfunction adjBonus(x, z) {\\n  let bonus = 0;\\n  for (const [dx,dz] of [[1,0],[-1,0],[0,1],[0,-1]]) {\\n    const n = cells[x+dx]?.[z+dz]; if (!n) continue;\\n    const nb = buildings[n]; if (nb.type === 'park' && b.type === 'house') bonus += 0.2;\\n  }\\n  return bonus;\\n}",
          "difficulty": "Advanced",
          "time_to_learn": "2 weeks",
          "docs_url": "https://doc.babylonjs.com/features/featuresDeepDive/mesh/creation/set",
          "pros": [
            "Grid (14×10) ทำให้ spatial puzzle ชัด — player เห็น adjacency ได้ทันที, optimize placement = core skill",
            "Visual scale 1.15× per level ทำให้ upgrade ที่ level 1→5 ใหญ่ขึ้น 2.0× — visual feedback ที่ 'ฟรี'",
            "Resource chain (wood→plank→house) สร้าง depth — player ต้อง plan 3-4 step ล่วงหน้า",
            "Zoning (residential/commercial/industrial) กัน bug ได้ทันที — แต่ละ zone มี adjacency rule ตายตัว",
            "Stronghold-3D proof: Babylon handles 80 buildings + 200 villagers ที่ 60fps บน mobile"
          ],
          "cons": [
            "Empty space = wasted potential — player รู้สึก guilty ถ้าเหลือ cell (ต้องมี 'filler' เช่น park/decoration)",
            "Adjacency bonus ต้องสมดุล — ใหญ่ไป = ต้องวางแน่น, เล็กไป = ไม่มีประโยชน์ ต้อง tune",
            "Building mesh swap ตอน upgrade ต้อง dispose mesh เก่า — ลืม = memory leak หลัง 30+ upgrades",
            "Zoning conflict เกิดง่าย (โรงงานติดบ้าน = -happiness) — ต้อง visual indicator (red glow) ทันที",
            "Save/load grid state ใหญ่ — 14×10=140 cells × 5 properties = 700 numbers ต่อ save"
          ],
          "gotchas": [
            "Adjacency check = 4-neighbor (up/down/left/right) ไม่ใช่ 8 — 8-neighbor = bug เรื่อง diagonal confusion",
            "Upgrade cost = baseCost × (1.6^level) — geometric, ไม่ใช่ linear (linear = grind ไม่จบ)",
            "Production tick = every 5s, ไม่ใช่ every frame — frame-rate production = lag spike ตอน save",
            "Babylon mesh dispose: scene.removeMesh(mesh) + mesh.dispose() ไม่งั้น GPU memory เพิ่มเรื่อยๆ",
            "Zoning bonus: residential + park = +20% happiness, residential + industrial = -30% — encode ใน 2D table",
            "Resource chain: ต้อง visualize arrow (wood→plank) — ไม่งั้น player งงว่าทำไม plank ขาด",
            "Grid 14×10 = 140 cells; max 80 buildings = 60 empty ต้องคิด decoration (tree, fountain) ให้ player",
            "Camera Y lock ที่ 30° — 90° = top-down น่าเบื่อ, 0° = first-person ไม่เห็น grid"
          ],
          "anti_patterns": [
            "ทำ free placement (no grid) แล้วหวังว่า player จะ align — เกิด overlap, bug, frustration",
            "Building ใหญ่ผิดสเกล — โรงงาน 3×3 ใกล้บ้าน 1×1 ดูแปลก ใช้ unified cell size 1.0",
            "ไม่มี undo/redo — city builder ที่ผิดพลาดแล้วลบไม่ได้ = rage quit ทันที",
            "Production loop เป็น instant — ต้องมี delay (5-30s) ไม่งั้น resource ไม่มี value",
            "ไม่มี win/lose condition — sandbox OK แต่ต้องมี milestone (population 100, gold 10k) ให้ player ตั้งเป้า"
          ],
          "prereqs": [
            "1.3",
            "3.1",
            "4.1"
          ],
          "next_steps": [
            "10.1",
            "10.3"
          ],
          "tags": [
            "city",
            "builder",
            "grid",
            "babylon",
            "city-builder",
            "เมือง",
            "adjacency",
            "zoning",
            "resource-chain"
          ],
          "compare_with": [
            "10.4 Tower Defense (path-based, no zoning)",
            "10.1 Tycoon (no spatial grid)",
            "Mars-colony (3D voxel, harder)"
          ]
        },
        {
          "id": "10.3",
          "title": "RPG Pattern",
          "icon": "⚔️",
          "description": "NPC needs (hunger/energy/social/fun) + relationship 0-100 stages + dialogue tree + quest chain + idle behavior tree.",
          "when_to_use": "RPG/life-sim/dating-sim ที่ player มีปฏิสัมพันธ์กับ NPC ที่ 'มีชีวิต'. Use เมื่อ core = character ไม่ใช่ combat.",
          "when_not_to_use": "Action RPG ที่ NPC เป็นแค่ vendor; multiplayer competitive (NPC AI สิ้นเปลือง); visual novel (no behavior tree needed)",
          "examples": "sims-lite, realm-of-heroes, sj88-rpg-idle-3d",
          "snippet": "class NPC{constructor(name){this.name=name; this.energy=100; this.hunger=0; this.social=50}\\n decide(){if(this.energy<20)return 'sleep'; if(this.hunger>80)return 'eat'; return 'work'}}",
          "snippet_full": "// Sims-lite style: NPC with 4 needs + decision tree + relationship stage\\nclass NPC {\\n  constructor(name, role) {\\n    this.name = name; this.role = role;\\n    this.needs = { energy:100, hunger:0, social:50, fun:50 };\\n    this.rel = {}; // playerName -> 0-100\\n    this.questFlags = 0; // bitmask\\n    this.action = 'wander'; this.actionUntil = 0;\\n  }\\n  tick(dt, t) {\\n    this.needs.hunger += dt * 2;     // +2/min\\n    this.needs.energy -= dt * 1.5;   // -1.5/min\\n    this.needs.social -= dt * 0.8;\\n    this.needs.fun    -= dt * 0.5;\\n    if (t > this.actionUntil) this.decide(t);\\n  }\\n  decide(t) {\\n    const n = this.needs;\\n    if (n.energy < 20)        { this.action='sleep'; this.actionUntil=t+300; return; }\\n    if (n.hunger > 80)        { this.action='eat';   this.actionUntil=t+180; return; }\\n    if (n.social < 30)        { this.action='social';this.actionUntil=t+120; return; }\\n    if (n.fun    < 20)        { this.action='play';  this.actionUntil=t+150; return; }\\n    this.action = this.role === 'farmer' ? 'farm' : 'wander';\\n    this.actionUntil = t + 200;\\n  }\\n  relStage(p) {\\n    const r = this.rel[p]||0;\\n    return r<25?'stranger': r<50?'friend': r<75?'close': r<100?'bestie':'love';\\n  }\\n}",
          "difficulty": "Advanced",
          "time_to_learn": "1 week",
          "docs_url": "https://www.gamedeveloper.com/design/behavior-trees-for-ai",
          "pros": [
            "NPC need-decision (energy<20→sleep) ทำให้ NPC 'รู้สึกมีชีวิต' โดยไม่ต้อง animate อะไรเลย",
            "Relationship 0-100 with stages (stranger/friend/bestie/love) ให้ progress ที่วัดได้ + dialogue เปลี่ยนตาม stage",
            "Quest chain (intro→mid→epilogue) ทำให้ NPC มี arc — Sims-lite มี 12 NPCs แต่ละตัว 3 arcs",
            "Dialogue tree แบบ branch ให้ player agency — replay value จากการเลือกคำตอบต่างกัน",
            "Idle behavior tree (wander→work→eat→sleep) ทำให้ village ดู populated แม้ player ไม่อยู่"
          ],
          "cons": [
            "Authoring time สูง — 12 NPCs × 3 arcs × 5 dialogues = 180 scripts ใช้เวลา 3-4 weeks",
            "NPC AI loop tick ทุก 1s × 12 NPCs = 12 decisions/sec — scale เป็น 100 NPCs = frame drop",
            "Relationship decay เมื่อไม่คุย — player รู้สึกผูกพันน้อย ถ้าไม่ visualize (heart meter) จะลืม",
            "Dialogue tree branching ต้อง test ทุก permutation — bug ง่าย (typo, dead-end, soft-lock)",
            "Save/load NPC state ซับซ้อน — needs (4 floats) + relationship (12 ints) + quest flags (20 bools) ต่อ NPC"
          ],
          "gotchas": [
            "Need decay rate: hunger +2/min, energy -1.5/min, social -0.8/min, fun -0.5/min — calibrate ให้ cycle 4-6 ชั่วโมงเกม",
            "Decision priority = critical > high > low — energy<20 = critical, hunger>80 = high, social<30 = low",
            "Relationship stage thresholds: 0=stranger, 25=friend, 50=close, 75=bestie, 100=love — encode ใน 1 helper function",
            "Dialogue: option visibility ต้อง check quest flags ไม่งั้น player เห็น option ที่ soft-lock",
            "Quest chain: ใช้ bitmask flags (1<<0=intro_done, 1<<1=mid_done) แทน array of bools ประหยัด memory",
            "Idle wander: ใช้ navmesh หรือ waypoint list ไม่ใช่ random — random = NPC เดินทะลุกำแพง",
            "Save NPC state ทุก 30s ไม่ใช่ทุก action — tick = 12 NPCs × 4 floats = 48 writes ต่อ action",
            "NPC ตาย/จากไป ต้องมี replacement — ไม่งั้น quest chain ขาด ตอน player reload"
          ],
          "anti_patterns": [
            "NPC เป็นแค่ vendor (stand still, sell, repeat) — ไม่ใช่ RPG, เป็นแค่ storefront",
            "Relationship แบบ binary (0/1) — ไม่มี progress = ไม่มี reward loop",
            "Dialogue ยาวเกิน 5 บรรทัด — player กด skip, ไม่อ่าน, เสีย authoring effort",
            "Need decay เร็วเกินจน player ไม่ทันตอบ — Sims 'mwah' simulator ไม่ใช่ fun",
            "Quest chain linear ไม่มี choice — visual novel ที่มี combat เท่านั้น, ไม่ใช่ RPG"
          ],
          "prereqs": [
            "1.3",
            "1.4",
            "4.4",
            "4.7"
          ],
          "next_steps": [
            "10.6",
            "10.10"
          ],
          "tags": [
            "rpg",
            "npc",
            "needs",
            "behavior-tree",
            "life-sim",
            "dating-sim",
            "sims",
            "quest",
            "relationship"
          ],
          "compare_with": [
            "10.6 Fighting (no persistent NPCs)",
            "10.10 Social (NPCs are other players)",
            "Visual novel (no behavior tree)"
          ]
        },
        {
          "id": "10.4",
          "title": "Tower Defense",
          "icon": "🗼",
          "description": "Path + waves + towers + A* pathfinding + range + DPS + targeting priority (first/last/closest/strongest) + economy.",
          "when_to_use": "เกมวางป้อม/ปืน/กับดักบน path เพื่อป้องกัน enemy เดินผ่าน. Use เมื่อ fun = positioning + upgrade timing + wave strategy.",
          "when_not_to_use": "Action shooter (camera อิสระ); strategy 4X (ยาวเกินไป); MOBA (player-controlled, ไม่ใช่ AI); survival ไม่มี wave structure",
          "examples": "fortress-builders, engine-battle",
          "snippet": "class Tower{range; dmg; rate; targets=[]; update(dt){for(const e of enemies)if(dist(this,e)<range)targets.push(e); targets.sort((a,b)=>a.dist-b.dist); if(targets[0])targets[0].hp-=this.dmg}}",
          "snippet_full": "// Tower Defense: A* path + tower targeting + wave manager\\nconst GRID = 20; const grid = Array.from({length:GRID}, () => Array(GRID).fill(0)); // 0=open,1=block,2=path\\nfunction aStar(sx, sy, ex, ey) {\\n  // Manhattan heuristic; returns path nodes or []\\n  const open = [{x:sx,y:sy,g:0,f:Math.abs(ex-sx)+Math.abs(ey-sy)}];\\n  const came = {}; const gScore = {[sx+','+sy]:0};\\n  while (open.length) {\\n    open.sort((a,b)=>a.f-b.f); const cur = open.shift();\\n    if (cur.x===ex && cur.y===ey) { const path=[]; let k=cur.x+','+cur.y;\\n      while (k) { const [x,y]=k.split(',').map(Number); path.push({x,y}); k=came[k]; } return path.reverse(); }\\n    for (const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {\\n      const nx=cur.x+dx, ny=cur.y+dy;\\n      if (nx<0||ny<0||nx>=GRID||ny>=GRID||grid[nx][ny]===1) continue;\\n      const tg = cur.g+1; const tk = nx+','+ny;\\n      if (tg < (gScore[tk]??Infinity)) {\\n        gScore[tk]=tg; came[tk]=cur.x+','+cur.y;\\n        open.push({x:nx,y:ny,g:tg, f:tg+Math.abs(ex-nx)+Math.abs(ey-ny)});\\n      }\\n    }\\n  } return [];\\n}\\n// Tower: 'first' priority = enemy furthest along path\\nfunction towerTick(t, dt) {\\n  for (const tower of towers) {\\n    tower.cd -= dt; if (tower.cd > 0) continue;\\n    const inRange = enemies.filter(e => dist(tower, e) < tower.range);\\n    if (!inRange.length) continue;\\n    inRange.sort((a,b) => b.pathIdx - a.pathIdx); // first = furthest\\n    const tgt = inRange[0];\\n    tgt.hp -= tower.dmg; tower.cd = 1/tower.rate;\\n  }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 week",
          "docs_url": "https://en.wikipedia.org/wiki/A*_search_algorithm",
          "pros": [
            "A* pathfinding บน grid ให้ enemy เดิน optimal — visual debug ได้ (g/h/f overlay)",
            "Targeting priority 4 modes (first/last/closest/strongest) สร้าง build diversity — 'close' กับ boss แตกต่างกัน 100%",
            "Wave manager (timing + HP scaling) ทำ pacing ง่าย — 10 waves × 3 phases = 30 levels",
            "Tower upgrade tree 3×3 = 9 paths ให้ player เลือก — replay จากการ build ต่างกัน",
            "Economy (kill gold + interest) balance ง่าย — 1 hp = 1 gold baseline, scale 1.5×/wave"
          ],
          "cons": [
            "Late-game repetitive — wave 50+ เป็นแค่ HP x 100, ไม่มี mechanic ใหม่ ต้องเพิ่ม boss/mini-event",
            "A* recompute ตอน block path = expensive — precompute 2-3 alt paths แทน live recompute",
            "Path ที่ player block = soft-lock enemy เดินออกนอก grid — ต้อง fallback (cheapest path or die)",
            "Tower DPS math ซับซ้อน — fire rate × damage × splash × crit ต้อง table ในสเปรดชีต",
            "Tower stacking ทำให้ game trivial — max 2-3 of same type per level cap"
          ],
          "gotchas": [
            "A* ใช้ Manhattan heuristic บน grid — Euclidean ช้ากว่า 2× และไม่จำเป็นบน 4-connect grid",
            "Path recompute: ใช้ incremental A* (block 1 cell = recompute suffix only) — full recompute 20ms",
            "Targeting priority: 'first' = enemy ที่ผ่าน path ไปได้ไกลสุด, 'last' = ใกล้ exit สุด, 'closest' = dist จริง",
            "Fire rate วัดเป็น shots/sec ไม่ใช่ ms — ms ทำให้ slow tower เลขเล็ก confusing",
            "Wave scaling: enemy.hp = baseHp × (1.4^waveNumber) — 1.4×/wave = ~3-4 min difficulty bump",
            "Interest on banked gold (1% per 10 gold, max 50) — passive saving mechanic",
            "Tower range visualized ตอน hover — ไม่ visualize = วางผิดบ่อย rage quit",
            "Sell tower refund = 70% — 100% = exploit (วาง/ลบวนไป), 50% = ผู้เล่นไม่ยอมลบทดลอง"
          ],
          "anti_patterns": [
            "A* แบบ uniform cost บน grid ใหญ่ — ใช้ JPS (jump point search) ถ้า grid > 100×100",
            "Tower ที่ target เฉพาะ enemy แรกเสมอ — 'first' mode ใช้ได้แต่ต้องมี 3 mode อื่น",
            "Wave แบบ fixed interval (ทุก 30s) — ใช้ difficulty curve ตาม player gold/dps ratio",
            "Boss wave ที่ไม่มี visual cue (telegraph) — boss มาแบบเงียบ = 'unfair' feeling",
            "ให้ player ขาย tower ได้ 100% — exploit ในการเลือก optimal หลังเห็น wave"
          ],
          "prereqs": [
            "1.3",
            "4.3",
            "4.1"
          ],
          "next_steps": [
            "10.6",
            "10.5"
          ],
          "tags": [
            "td",
            "tower-defense",
            "pathfinding",
            "a-star",
            "wave",
            "dps",
            "targeting",
            "เกมป้อม"
          ],
          "compare_with": [
            "10.5 Platformer (no path AI)",
            "10.1 Tycoon (no enemy AI)",
            "10.2 City Builder (no enemy)"
          ]
        },
        {
          "id": "10.5",
          "title": "Platformer",
          "icon": "🦘",
          "description": "Gravity + variable jump height + coyote time + jump buffering + tile map collision + one-way platforms + moving platforms.",
          "when_to_use": "เกมกระโดด 2D (Mario/Celeste/Cuphead style). Use เมื่อ core = timing + reflex + level navigation.",
          "when_not_to_use": "3D platformer (camera + jump arc ซับซ้อนกว่า); endless runner (auto-run, ไม่ใช่ manual); puzzle platform ที่ jump ไม่ใช่ core",
          "examples": "pencil-rush, fitcheck, engine-battle",
          "snippet": "const gravity=800; const jumpForce=-400; update(dt){player.vy+=gravity*dt; player.y+=player.vy*dt; if(collideFloor){player.y=floor.y;player.vy=0} if(jumpPressed&&player.onGround)player.vy=jumpForce}",
          "snippet_full": "// Platformer with coyote time + jump buffer + variable jump\\nconst GRAV = 800, JUMP = -400, MAX_FALL = 600;\\nconst COYOTE_F = 6, BUFFER_F = 6;\\nlet coyote = 0, buffer = 0, holdJump = false;\\nfunction update(dt, keys) {\\n  // coyote: count down when in air but just left ground\\n  if (p.onGround) coyote = COYOTE_F; else coyote = Math.max(0, coyote - 1);\\n  // buffer: remember recent jump press\\n  if (keys.jumpPressed) buffer = BUFFER_F; else buffer = Math.max(0, buffer - 1);\\n  // variable jump: cut velocity if released early\\n  if (p.vy < 0 && !keys.jumpHeld) p.vy *= 0.5;\\n  // jump trigger\\n  if (buffer > 0 && coyote > 0) { p.vy = JUMP; buffer = 0; coyote = 0; p.onGround = false; }\\n  // gravity + cap\\n  p.vy += GRAV * dt; if (p.vy > MAX_FALL) p.vy = MAX_FALL;\\n  p.y += p.vy * dt;\\n  // separate-axis collision (Y first)\\n  if (tileAt(p.x, p.y + p.h) === 1 && p.vy >= 0) {\\n    p.y = Math.floor((p.y + p.h) / 16) * 16 - p.h;\\n    p.vy = 0; p.onGround = true;\\n  } else { p.onGround = false; }\\n  p.x += p.vx * dt;\\n  if (tileAt(p.x + (p.vx<0?0:p.w), p.y + p.h/2) === 1) { p.vx = 0; }\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 week",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript",
          "pros": [
            "Variable jump height (jump key hold = jump สูงขึ้น) ให้ 'feel' ที่ดี — Mario feel = 50% variable jump",
            "Coyote time 6 frames หลังพ้นขอบ = forgivable, player ไม่ rage quit",
            "Jump buffering 6 frames ก่อนถึงพื้น = input feel snappy, ไม่ต้อง frame-perfect",
            "Tile map (16×16) collision เร็วกว่า polygon — 200 tiles ใช้ 1ms",
            "One-way platforms (jump up only) ทำให้ level design ยืดหยุ่น — fall through = bug ต้อง guard"
          ],
          "cons": [
            "Physics tuning ใช้เวลานาน — gravity 800, jump -400, max-fall 600 ต้องลองผิดลองถูกจน feel right",
            "Wall jump / wall slide เพิ่ม complexity × 3 — collision normal vector, stick time, push-off",
            "Camera follow laggy ทำให้ motion sickness — lerp factor ต้อง 0.1-0.15 ไม่ใช่ 0.5",
            "One-way platform + moving platform = edge case hell — slope + 1-way = corner clip",
            "Animation transitions (idle/walk/jump/fall) ต้อง machine — 4 states × 8 frames = 32 sprites"
          ],
          "gotchas": [
            "Variable jump: cut velocity y by 50% ตอน release jump key early — ไม่งั้น jump = on/off แข็ง",
            "Coyote time: 6 frames (100ms @60fps) หลังพ้น platform = player มี buffer",
            "Jump buffer: 6 frames (100ms) ก่อนแตะพื้น = press early ก็ขึ้นได้",
            "Gravity 800 px/s² + jump -400 = jump arc ~500ms — เร็วกว่านี้ feel snappy, ช้ากว่า feel floaty",
            "Max fall speed cap ที่ 600 px/s — ไม่ cap = tunneling through floor เมื่อ FPS drop",
            "Tile collision: separate X แล้ว Y — resolve Y ก่อนแล้ว X ไม่งั้น corner stuck",
            "One-way platform: check player.vy >= 0 ก่อน collision ไม่งั้น jump up = blocked",
            "Animation: transition idle↔walk = instant, walk→jump = interrupt (no blend), fall = looped"
          ],
          "anti_patterns": [
            "Fixed jump height (jump key press = สูงเท่ากันทุกครั้ง) — feel arcade-y แต่ไม่ modern",
            "ไม่มี coyote time — player เดินตกขอบ = 'unfair' ทันที",
            "Square hitbox (player = rect 16×16 แน่น) — wide-pixel art ดูดี แต่ gameplay clunky ใช้ 12×14 hitbox",
            "Camera snap (no lerp) — เกม motion sickness, ไม่ smooth",
            "Apply gravity ใน fixed timestep ที่ไม่ consistent — tunneling + variable jump height bug"
          ],
          "prereqs": [
            "1.3",
            "3.2",
            "4.1"
          ],
          "next_steps": [
            "10.4",
            "10.6"
          ],
          "tags": [
            "platformer",
            "platformer-game",
            "jump",
            "coyote",
            "tile",
            "physics",
            "เกมกระโดด",
            "gravity",
            "collision"
          ],
          "compare_with": [
            "10.4 Tower Defense (no player physics)",
            "10.6 Fighting (fixed 2D plane, no platform)",
            "10.1 Tycoon (no player avatar)"
          ]
        },
        {
          "id": "10.6",
          "title": "Fighting Game",
          "icon": "🥊",
          "description": "State machine (idle/walk/crouch/attack/hurt/block) + hitbox + frame data + cancel windows + block advantage + throw tech.",
          "when_to_use": "เกมต่อสู้ 1v1 (Street Fighter / Mortal Kombat / Smash). Use เมื่อ core = read opponent + punish + frame trap.",
          "when_not_to_use": "Hack-and-slash (ไม่มี frame data); MOBA (auto-target, ไม่ใช่ frame-perfect); beat-em-up 4+ players (AI ยาก)",
          "examples": "street-fighter-thai",
          "snippet": "class Fighter{states={idle:5,walk:8,attack:12,hurt:6}; hitbox={x,y,w,h,damage,frame}; update(dt){if(this.currentState==='attack'&&this.frame>=5&&!this.hasHit){this.checkHit(opponent);this.hasHit=true}}}",
          "snippet_full": "// Fighting game: state machine + hitbox + frame advantage\\nconst STATES = { IDLE:0, WALK:1, ATTACK:2, HURT:3, BLOCK:4 };\\nconst FRAME = { IDLE:5, WALK:8, ATTACK_STARTUP:5, ATTACK_ACTIVE:8, ATTACK_RECOVERY:12, HURT:14, BLOCK:8 };\\nclass Fighter {\\n  constructor(side) { this.side=side; this.state=STATES.IDLE; this.frame=0; this.vx=0; this.hp=1000; this.combo=0; }\\n  update(dt) {\\n    this.frame++;\\n    if (this.state===STATES.ATTACK && this.frame === FRAME.ATTACK_STARTUP+1) {\\n      // hitbox active frame\\n      this.hitbox = { x:this.side>0?this.x+30:this.x-50, y:this.y+10, w:40, h:80, dmg:80, kb:5 };\\n    }\\n    if (this.state===STATES.ATTACK && this.frame >= FRAME.ATTACK_STARTUP+FRAME.ATTACK_ACTIVE+FRAME.ATTACK_RECOVERY) {\\n      this.state = STATES.IDLE; this.frame = 0; this.hitbox = null;\\n    }\\n    if (this.state===STATES.HURT && this.frame >= FRAME.HURT) {\\n      this.state = STATES.IDLE; this.frame = 0;\\n    }\\n    this.x += this.vx;\\n  }\\n  checkHit(opp) {\\n    if (!this.hitbox) return;\\n    if (overlap(this.hitbox, opp.hurtbox())) {\\n      opp.state = STATES.HURT; opp.frame = 0; opp.vx = this.hitbox.kb * this.side;\\n      opp.hp -= this.hitbox.dmg; this.combo++;\\n      // hitstop 6 frames\\n      this.state===STATES.ATTACK; this.frame -= 6;\\n    }\\n  }\\n  cancelInput(input) {\\n    // cancel window: 3-5 frames of recovery only\\n    if (this.state===STATES.ATTACK && this.frame >= FRAME.ATTACK_STARTUP+FRAME.ATTACK_ACTIVE+3 && this.frame <= FRAME.ATTACK_STARTUP+FRAME.ATTACK_ACTIVE+5) {\\n      this.state = STATES.ATTACK; this.frame = 0; // new attack\\n    }\\n  }\\n}",
          "difficulty": "Advanced",
          "time_to_learn": "3 weeks",
          "docs_url": "https://www.gamedeveloper.com/design/the-cta-model-combat-design",
          "pros": [
            "State machine 6-8 states = readable — แต่ละ state มี frame count ทำให้ prediction deterministic",
            "Cancel windows (2-4 frames) สร้าง combo depth — light→special ที่ frame 3 = 'combo' ที่ต้องจังหวะ",
            "Frame advantage (on-block, on-hit) ให้ risk/reward ที่ชัด — +3 on block = เริ่ม next move ได้ก่อน",
            "Hitbox visualization (toggle) ใน dev mode = essential สำหรับ balance team",
            "Throw tech (5-frame window) ตอบ anti-throw — counter-play ที่ design ได้"
          ],
          "cons": [
            "Balance ใช้เวลา 2-3 เดือน — character A vs B = 50/50 matchup ต้อง test 100+ games",
            "Frame data ต้อง consistent — input lag 16ms (1 frame) เปลี่ยน matchup ได้",
            "Animation cancel 1 frame off = 'macro' feel — bug ใน frame 6→7 cancel = exploit",
            "AI opponent ยาก — random AI = ชนะ 0%, scripted AI = predictable, behavior tree AI = 2 weeks",
            "Rollback netcode ต้อง GGPO (120+ ms latency) — local play ไม่มีปัญหา แต่ online = project ใหม่"
          ],
          "gotchas": [
            "Frame data: light attack 5f startup / 8f active / 12f recovery = total 25f (416ms @60fps)",
            "Cancel window = 'special' button ได้ที่ frame 3-5 of recovery เท่านั้น — ก่อน/หลัง = no cancel",
            "Block advantage: +3f = opponent can act 3 frames later than you — frame trap ได้",
            "Hitbox: separate from hurtbox — 16×80 attack box (vertical slash) vs 24×56 hurtbox (body)",
            "Throw tech: กด throw ภายใน 5f หลังถูก throw = tech = both stagger, no damage",
            "Input buffer 4 frames — qcf+HP registered ถ้ากดใน 4f ของ forward+down — modern fighting standard",
            "Animation priority: hurt > block > attack — ถ้า hit ทั้งสอง side, hurt wins (no double-hit)",
            "Hitstop 6f on heavy hit — freeze 100ms ให้ impact feel + player อ่าน combo ได้"
          ],
          "anti_patterns": [
            "Animation = collision — ถ้าใช้ sprite เป็น hitbox ทั้งตัว, combos ตายยาก ต้อง separate",
            "ไม่มี hitstun/blockstun — hit แล้วทุกคน recover เท่ากัน = no pressure = เกมน่าเบื่อ",
            "Input leniency 0 frame (frame-perfect) — qcf ต้อง exact down→down-forward→forward ไม่มี buffer",
            "AI แบบ reaction (ตอบทุก action ทันที) — AI เก่งเกินไป, player แพ้ตลอด rage quit",
            "ไม่มี training mode (dummy + frame data display) — competitive scene จะไม่เกิด"
          ],
          "prereqs": [
            "1.3",
            "3.2",
            "4.4"
          ],
          "next_steps": [
            "10.5",
            "10.4"
          ],
          "tags": [
            "fighting",
            "beat-em-up",
            "street-fighter",
            "hitbox",
            "frame-data",
            "state-machine",
            "combo",
            "cancel",
            "เกมต่อสู้"
          ],
          "compare_with": [
            "10.5 Platformer (no state machine)",
            "10.4 Tower Defense (no PvP)",
            "10.1 Tycoon (no combat)"
          ]
        },
        {
          "id": "10.7",
          "title": "Restaurant Sim",
          "icon": "🍜",
          "description": "Queue + 7 menu items + ingredient stock + cook time + tip multiplier + reputation + daily grade S/A/B/C + chef upgrade.",
          "when_to_use": "ร้านอาหาร/คาเฟ่/fast-food/fine-dining ที่ player serve customer + manage ingredient + build reputation. เน้นกว่า 10.1 (tycoon) ตรง menu depth + recipe unlock.",
          "when_not_to_use": "Cooking game (puzzle, ไม่ serve customer); food truck ที่ไม่มี queue; tycoon ที่ไม่ใช่อาหาร (factory)",
          "examples": "noodle-shop, dinebook",
          "snippet": "// End-of-day grading\\nif(complained===0&&serve>=10)grade='S';\\nelse if(complained<=1&&serve>=7)grade='A';\\nelse if(complained<=3&&serve>=4)grade='B';\\nelse grade='C'",
          "snippet_full": "// Restaurant sim: menu + stock + cook queue + tip + grade\\nconst MENU = {\\n  pad_thai:   { name:'Pad Thai',   need:{noodle:1,shrimp:1,egg:1}, cook:8,  price:60  },\\n  tom_yum:    { name:'Tom Yum',    need:{shrimp:1,lemongrass:1},     cook:10, price:80  },\\n  som_tum:    { name:'Som Tum',    need:{papaya:1,lime:1},           cook:6,  price:50  },\\n  // ... 4 more unlockable\\n};\\nconst stock = { noodle:20, shrimp:15, egg:30, lemongrass:10, papaya:12, lime:18 };\\nlet serveCount=0, complaintCount=0, totalTip=0;\\nfunction tryCook(order) {\\n  const r = MENU[order.dish];\\n  for (const k of Object.keys(r.need)) if ((stock[k]||0) < r.need[k]) return null; // out of stock\\n  for (const k of Object.keys(r.need)) stock[k] -= r.need[k]; // deduct at START\\n  return setTimeout(() => finishCook(order, r), r.cook * 1000);\\n}\\nfunction finishCook(order, r) {\\n  const c = order.customer;\\n  if (c.patience <= 0) { complaintCount++; return; }\\n  const tipMult = Math.min(2.0, (c.patience/100) * chef.tipMod * reputation.mod);\\n  const earn = r.price * tipMult;\\n  totalTip += earn; serveCount++; money += earn;\\n}\\nfunction dailyGrade() {\\n  if (complaintCount===0 && serveCount>=10) return 'S';\\n  if (complaintCount<=1 && serveCount>=7)  return 'A';\\n  if (complaintCount<=3 && serveCount>=4)  return 'B';\\n  return 'C';\\n}",
          "difficulty": "Intermediate",
          "time_to_learn": "1 week",
          "docs_url": "https://www.gamedeveloper.com/design/the-art-of-game-design-balance",
          "pros": [
            "7 menu items (Pad Thai, Tom Yum, Som Tum, Khao Soi, etc.) + recipe unlock tree ให้ progression 3-4 ชม.",
            "Ingredient stock (3-5 per dish) + supplier delivery สร้าง supply chain — ลืมสั่งของ = เมนูขาด",
            "Daily grade S/A/B/C (end-of-day summary) ให้ self-improvement loop — grade S = +10% tip tomorrow",
            "Chef upgrade (cook speed + quality + tip%) แตกต่างจาก restaurant layout upgrade — character growth",
            "Tip multiplier based on patience+food quality+atmosphere = 3 inputs → 1 output, satisfaction visible"
          ],
          "cons": [
            "Recipe balance 7 menu × 5 ingredients × 3 cook-times = 105 numbers ต้อง tune",
            "Ingredient stock race condition — 2 customers order same dish, 1 portion ขาด mid-cook",
            "Daily grade calc ต้อง fair — 1 complaint ใน 50 served ควร A ไม่ใช่ B (player rage)",
            "Tip overflow — late game ได้ 100k+ ต่อวัน ต้องมา sink (kitchen upgrade tier 5)",
            "Customer arrival wave (lunch 12:00, dinner 18:00) ต้อง ramp ไม่ใช่ step — step = frame stutter"
          ],
          "gotchas": [
            "Recipe: { name, ingredients:{pork:1,noodle:1}, cookTime:8s, price:60, tipMult:1.0 } — store เป็น object",
            "Ingredient stock: deduct ตอน START cooking ไม่ใช่ตอน serve — late deduct = oversold",
            "Arrival wave: Poisson(λ=0.5) 12:00-13:00, λ=0.3 18:00-19:00, λ=0.1 otherwise",
            "Daily grade: S=0 complaints & ≥10 served, A=≤1 & ≥7, B=≤3 & ≥4, C=else",
            "Cook queue max 3 — overflow → customer leaves + complaint counter +1",
            "Tip = price × (patience/100) × chef.tipMod × reputation.modifier — clamp [0, 2×price]",
            "Menu unlock: tier 1 (3 items day 1), tier 2 (2 more day 3), tier 3 (2 more day 7)",
            "Save state: stock + reputation + chef.level + last 7 day grades — 7-10 ints/floats ต่อ save"
          ],
          "anti_patterns": [
            "Menu 20+ items ตั้งแต่ day 1 — overwhelming, ไม่มี progression. ใช้ unlock tree 7 → 15",
            "Cook time 0 (instant) — ไม่มี tension, ไม่มี cook queue mechanic",
            "ไม่มี ingredient depletion — supply chain เป็นแค่ cosmetic, ไม่ใช่ mechanic",
            "Reputation ไม่มีผลต่อ spawn rate — 5★ vs 1★ ควร spawn ต่างกัน 2×",
            "Daily grade คำนวณจาก money only — ต้อง multi-factor: complaints + served + avg tip"
          ],
          "prereqs": [
            "10.1"
          ],
          "next_steps": [
            "10.8"
          ],
          "tags": [
            "restaurant",
            "noodle",
            "thai-food",
            "cafe",
            "queue",
            "recipe",
            "ร้านอาหาร",
            "เกมทำอาหาร",
            "tip",
            "menu"
          ],
          "compare_with": [
            "10.1 Tycoon (no menu)",
            "10.8 Medical Booking (no queue)",
            "10.9 E-Commerce (no per-order state)"
          ]
        },
        {
          "id": "10.8",
          "title": "Medical Booking",
          "icon": "🏥",
          "description": "Doctor rotation + 12 สาขา + 10 แพทย์ + emergency slot 1669 + recurring appointment + no-show policy + queue priority.",
          "when_to_use": "คลินิก/รพ./salon/spa/แพทย์เฉพาะทาง ที่ patient book slot กับ doctor ที่มีเวลาจำกัด. Use เมื่อ core = schedule + doctor + slot conflict.",
          "when_not_to_use": "Walk-in clinic (no booking); telemedicine chat (no slot); emergency 911 (ad-hoc, not scheduled)",
          "examples": "medbook, cospace, fitbook, glowbook",
          "snippet": "const doctors=[{name,slots:[]},...]; function book(doctorId,slotId,patient){doctors.find(d=>d.id===doctorId).slots.find(s=>s.id===slotId).booked=patient; setReminder(patient,slotId,24,1)}",
          "snippet_full": "// Medical booking: doctor rotation + emergency slot + recurring + no-show\\nconst doctors = [\\n  {id:1, name:'Dr.Somchai',  workHours:'08:00-17:00', slots:[]},\\n  // 9 more doctors\\n];\\nfunction generateSlots(date) {\\n  doctors.forEach(d => {\\n    d.slots = d.slots.filter(s => s.date >= today); // drop past\\n    for (let h=8; h<17; h++) {\\n      const id = `${d.id}-${date}-${h}`;\\n      if (!d.slots.find(s => s.id===id)) d.slots.push({ id, doctorId:d.id, date, hour:h, type:'normal', booked:null });\\n    }\\n    // emergency slot — 17:00 every day, hidden from normal search\\n    const eid = `${d.id}-${date}-17`;\\n    if (!d.slots.find(s => s.id===eid)) d.slots.push({ id:eid, doctorId:d.id, date, hour:17, type:'emergency', booked:null });\\n  });\\n}\\nfunction book(slotId, patient, recurringWeeks=0) {\\n  const slot = doctors.flatMap(d=>d.slots).find(s => s.id===slotId);\\n  if (slot.booked) throw new Error('Slot taken');\\n  slot.booked = { patientId:patient.id, ts:Date.now(), status:'booked' };\\n  scheduleReminder(patient, slot, 24); scheduleReminder(patient, slot, 1);\\n  if (recurringWeeks > 0) {\\n    for (let w=2; w<=recurringWeeks*2; w+=2) {\\n      const futureDate = addDays(slot.date, w*7);\\n      book(`${slot.doctorId}-${futureDate}-${slot.hour}`, patient); // same time, 2 weeks later\\n    }\\n  }\\n}\\n// No-show cron: every 10 min\\nsetInterval(() => {\\n  const cutoff = Date.now() - 15*60*1000;\\n  doctors.flatMap(d=>d.slots).forEach(s => {\\n    if (s.booked && s.booked.status==='booked' && new Date(`${s.date}T${s.hour}:00`).getTime() < cutoff) {\\n      s.booked.status='no_show'; incrementNoShow(s.booked.patientId);\\n    }\\n  });\\n}, 600000);",
          "difficulty": "Intermediate",
          "time_to_learn": "3 days",
          "docs_url": "https://www.hl7.org/fhir/appointment.html",
          "pros": [
            "Doctor rotation (10 แพทย์ × 8 slots/day = 80 slots/day) — patient เลือกเฉพาะเจาะจง + admin balance load",
            "Emergency slot 1669 (always 1 reserve slot/doctor/day) — real-world inspired, urgent = jump queue",
            "Recurring appointment (ทุก 2 สัปดาห์ × 4 visits) auto-create — diabetes/HIV follow-up use case",
            "No-show policy (3 strikes → blacklist 30 วัน) — ลด fake booking, สำคัญใน real clinic",
            "12 สาขา (สาขาในกรุงเทพ) — multi-location = ใหญ่ขึ้น, search by สาขา → doctor → slot"
          ],
          "cons": [
            "HIPAA-style privacy: encrypt at rest + audit log + 2FA — overhead 30-40% เทียบ regular booking",
            "Doctor schedule conflict — admin แก้ schedule = แจ้ง patient ที่จองแล้ว 50+ คน",
            "Timezone bug ถ้า multi-สาขา — กรุงเทพ vs เชียงใหม่ = 1 ชม. ต่าง, booking UTC shift",
            "Recurring skip: 'next Monday is holiday' = skip 1 + auto-pick next — edge case ซับซ้อน",
            "No-show counter fraud — patient สร้าง 3 email ปลอม + book 3 ครั้ง = exploit, ต้อง phone-verify"
          ],
          "gotchas": [
            "Slot = { doctorId, start, duration, type:'normal'|'emergency'|'recurring' } — emergency slot = hidden จาก normal search",
            "Book: SELECT slot FOR UPDATE (SQL row lock) — ไม่งั้น 2 patient จอง slot เดียวกันพร้อมกัน",
            "Recurring: cron daily at 02:00 — generate next 30 days × 10 doctors = 300 slots — pre-cache",
            "No-show: cron every 10 min — check slot.start < now-15min && status='booked' → mark no-show + count++",
            "Time zone: store UTC in DB, render in สาขา local — ISO 8601 + Intl.DateTimeFormat with timeZone option",
            "Doctor schedule: separate work_hour (8:00-17:00) vs slot generation — admin แก้ work_hour กระทบ slot",
            "Reminder: push 24h + 1h ก่อน slot — push ทั้งสอง = no-show ลด 60% (medical research)",
            "Audit log: เก็บ every (patientId, action, slotId, ts) — 90-day retention ตาม HIPAA"
          ],
          "anti_patterns": [
            "Slot = 1 hour fixed — doctor ที่ปรึกษา 15 นาที เสียเวลา 45 นาที, ต้อง flexible duration 15/30/60",
            "ไม่มี emergency slot — emergency ต้อง wait normal queue = patient เสียชีวิต, real-world liability",
            "Booking ไม่มี conflict check — 2 patient จอง slot เดียวกัน (race condition) = 2 angry patients",
            "ไม่เก็บ medical record — re-visit ต้องเริ่มใหม่, แพทย์โกรธ, patient โกรธ, เปลี่ยนคลินิก",
            "Recurring = manual repeat (admin ต้องสร้าง 10 visits เอง) — เปลืองเวลา 90%, auto-generate"
          ],
          "prereqs": [
            "1.3",
            "5.4",
            "9.2"
          ],
          "next_steps": [
            "10.9"
          ],
          "tags": [
            "medical",
            "booking",
            "clinic",
            "doctor",
            "appointment",
            "คลินิก",
            "นัดหมาย",
            "แพทย์",
            "recurring",
            "no-show"
          ],
          "compare_with": [
            "10.9 E-Commerce (no time slot)",
            "10.7 Restaurant Sim (no recurring)",
            "10.1 Tycoon (no booking)"
          ]
        },
        {
          "id": "10.9",
          "title": "E-Commerce",
          "icon": "🛒",
          "description": "Product catalog + cart + checkout + Stripe payment + order + inventory race + shipping + 60-item POS (barcode scan) + tax/VAT.",
          "when_to_use": "ร้านค้า/marketplace/POS/grocery ที่ user browse + cart + pay. Use เมื่อ core = list/detail/cart/pay flow.",
          "when_not_to_use": "Subscription service (recurring, ไม่ใช่ one-time); B2B (custom pricing); marketplace multi-vendor (ระบบ commission)",
          "examples": "pos-grocery, dinebook, price-compare",
          "snippet": "// Cart\\ncart.push({productId,qty});\\n// Checkout\\ncheckout(){for(const item of cart)if(inventory[item.productId].qty<item.qty)throw 'Out of stock'; stripe.charge(total); sendEmailConfirmation(orderId)}",
          "snippet_full": "// E-commerce: cart + inventory check + Stripe checkout + order\\nlet cart = JSON.parse(localStorage.getItem('cart_v1') || '[]');\\nconst inventory = { p001:{name:'Rice 5kg', qty:50, price:180}, p002:{name:'Coke 1.5L', qty:120, price:42}, /*58 more*/ };\\nfunction addToCart(productId, qty=1) {\\n  const ex = cart.find(c => c.productId===productId);\\n  if (ex) ex.qty += qty; else cart.push({ productId, qty });\\n  localStorage.setItem('cart_v1', JSON.stringify(cart));\\n}\\nasync function checkout() {\\n  if (!cart.length) throw new Error('Cart empty');\\n  // 1. Inventory check FIRST (atomic with order create)\\n  for (const item of cart) {\\n    if (inventory[item.productId].qty < item.qty) throw new Error(`Out of stock: ${item.productId}`);\\n  }\\n  const subtotal = cart.reduce((s,i) => s + inventory[i.productId].price * i.qty, 0);\\n  const vat = Math.round(subtotal * 0.07); // Thailand 7%\\n  const total = subtotal + vat;\\n  // 2. Stripe Checkout (hosted page — no card storage)\\n  const orderId = 'ord_' + Date.now();\\n  const { sessionId } = await fetch('/api/create-checkout', {\\n    method:'POST', body: JSON.stringify({ orderId, items:cart, total, vat })\\n  }).then(r => r.json());\\n  stripe.redirectToCheckout({ sessionId }); // Stripe handles card form\\n  // 3. Webhook (server) decrements inventory on payment_intent.succeeded\\n}\\n// POS: barcode scan via keyboard wedge\\nlet barcode = '';\\ndocument.addEventListener('keydown', e => {\\n  if (e.key === 'Enter' && barcode.length === 13) { scanBarcode(barcode); barcode = ''; }\\n  else if (/[0-9]/.test(e.key)) barcode += e.key;\\n});",
          "difficulty": "Intermediate",
          "time_to_learn": "1 week",
          "docs_url": "https://stripe.com/docs/checkout/quickstart",
          "pros": [
            "60-item POS (grocery) — small enough barcode scan, big enough ให้รู้สึก real store",
            "Cart persist ใน localStorage — refresh = cart ยังอยู่, conversion เพิ่ม 25%",
            "Stripe Checkout (hosted) = PCI compliance ฟรี, ไม่ต้องเก็บ card เอง",
            "Inventory check atomic — SELECT FOR UPDATE หรือ version-number แก้ race condition",
            "Order confirmation email + receipt = professional UX, ฟรีด้วย SendGrid/Resend free tier"
          ],
          "cons": [
            "Stripe integration ใช้เวลา 3-5 วัน — webhook + idempotency key + currency + tax มี 10+ edge case",
            "Inventory race: 2 user buy last 1 item พร้อมกัน = oversell — ต้อง transactional DB หรือ lock",
            "Cart abandonment = 70% baseline — ต้อง email follow-up 1h/24h/72h เพื่อ recover 15-20%",
            "Refund flow = chargeback + accounting — refund API แยกจาก charge, accounting ต้อง sync",
            "VAT/tax calc — Thailand 7% VAT, EU OSS, US state tax — multi-region = tax engine (TaxJar)"
          ],
          "gotchas": [
            "Stripe webhook: idempotency key = orderId — ป้องกัน double charge ตอน retry",
            "Inventory: deduct ใน same DB transaction as order — atomicity สำคัญที่สุด",
            "Cart: store in localStorage key 'cart_v1' with version, fallback v0 = array of {id,qty}",
            "Barcode scan: keyboard wedge input + 13-digit EAN-13 — debounce 50ms ป้องกัน multi-scan",
            "Order: status flow pending → paid → shipped → delivered — 5 states, webhook transitions",
            "Tax: Thailand 7% VAT ใน price หรือ add-on — most e-comm add-on, must show breakdown",
            "Stripe fee: 2.9% + 30¢ per charge — factor in price (THB 100 = รับจริง ~96.7)",
            "Empty cart UX — disable checkout button, show 'your cart is empty' message"
          ],
          "anti_patterns": [
            "เก็บ card number เอง — ไม่ต้อง, ใช้ Stripe Elements (PCI ฟรี), เก็บเอง = liability",
            "ไม่มี idempotency — user double-click 'pay' = 2 charges — ต้อง unique idempotency key",
            "Cart ไม่ persist — refresh = cart หาย, conversion ลง 25%",
            "Inventory check หลัง charge — เรียงผิด, charge แล้ว refund = เสีย reputation",
            "ไม่มี order confirmation email — user ไม่แน่ใจว่าสั่งสำเร็จ, call support 10×"
          ],
          "prereqs": [
            "1.3",
            "5.5",
            "9.2"
          ],
          "next_steps": [
            "10.7"
          ],
          "tags": [
            "ecommerce",
            "shop",
            "pos",
            "stripe",
            "cart",
            "inventory",
            "barcode",
            "ร้านค้า",
            "checkout",
            "payment"
          ],
          "compare_with": [
            "10.7 Restaurant Sim (in-game currency, not Stripe)",
            "10.8 Medical Booking (no payment)",
            "10.1 Tycoon (no real money)"
          ]
        },
        {
          "id": "10.10",
          "title": "Social Network",
          "icon": "👥",
          "description": "Feed (paginated ts desc) + likes + comments + followers graph + post visibility + mention/hashtag + chat memory + community board.",
          "when_to_use": "ฟอรั่ม/community/social/feed-based app ที่ user post + follow + interact. Use เมื่อ core = feed + relationship + content.",
          "when_not_to_use": "Chat 1-on-1 (use 9.7 WebSocket); dating (mutual-match mechanic ไม่ใช่ feed); dating-app ไม่ใช่ feed",
          "examples": "memo-ai, community-pin, people-crm",
          "snippet": "// Posts: { id, author, content, ts, likes[], comments[] }\\nfeed: () => posts.sort((a,b)=>b.ts-a.ts).slice(0,20)\\nlike(postId,userId){post.likes.push(userId)}",
          "snippet_full": "// Social network: feed + likes + comments + follow graph\\nconst posts = []; const followGraph = {}; // userId -> Set<userId>\\nfunction publish(authorId, content, visibility='public') {\\n  if (content.length > 500) throw new Error('Post too long');\\n  const post = {\\n    id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2,6),\\n    author: authorId, content, ts: Date.now(),\\n    visibility, likes: [], comments: [],\\n    tags: (content.match(/#([\\wก-๙]+)/g) || []).map(t=>t.slice(1).toLowerCase()),\\n    mentions: (content.match(/@([\\w]+)/g) || []).map(m=>m.slice(1))\\n  };\\n  posts.push(post);\\n  // notify mentioned users\\n  post.mentions.forEach(u => pushNotification(u, {type:'mention', postId:post.id}));\\n}\\nfunction feed(userId, cursor=null, limit=20) {\\n  const followees = followGraph[userId] || new Set();\\n  return posts\\n    .filter(p => p.visibility === 'public' || p.author === userId || followees.has(p.author))\\n    .filter(p => !cursor || p.ts < cursor)\\n    .sort((a,b) => b.ts - a.ts)\\n    .slice(0, limit);\\n}\\nfunction like(postId, userId) {\\n  const p = posts.find(x => x.id===postId); if (!p) return;\\n  const i = p.likes.indexOf(userId);\\n  if (i >= 0) p.likes.splice(i, 1); // unlike\\n  else p.likes.push(userId);\\n}\\nfunction comment(postId, userId, text, parentId=null) {\\n  const p = posts.find(x => x.id===postId); if (!p) return;\\n  p.comments.push({ id:'c_'+Date.now(), user:userId, text, ts:Date.now(), parent:parentId });\\n}\\nfunction follow(a, b) { (followGraph[a] = followGraph[a] || new Set()).add(b); }\\n// Mutual suggestion: BFS depth 2 from userId, exclude self + already-following\\nfunction suggest(userId) {\\n  const seed = followGraph[userId] || new Set();\\n  const out = new Map(); // userId -> count\\n  seed.forEach(f => (followGraph[f] || new Set()).forEach(ff => {\\n    if (ff !== userId && !seed.has(ff)) out.set(ff, (out.get(ff)||0)+1);\\n  }));\\n  return [...out.entries()].sort((a,b)=>b[1]-a[1]).slice(0,5);\\n}",
          "difficulty": "Advanced",
          "time_to_learn": "2 weeks",
          "docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API",
          "pros": [
            "Feed paginated by ts desc + limit 20 — query cheap, render 60fps ใน client",
            "Like = toggle ใน array ไม่ใช่ row ใหม่ — like counter O(1) read, write O(1)",
            "Follower graph (adjacency list) + mutual-friend suggestion — 'people you may know' จาก BFS depth 2",
            "Post visibility = public | followers | private — 3 modes, encode ใน 1 enum",
            "Chat memory (memo-ai) — context persist ใน session, AI จำ conversation ก่อนหน้าได้"
          ],
          "cons": [
            "Feed query ช้าเมื่อ user มี 10k posts — index (authorId, ts DESC) + cursor pagination แทน offset",
            "Moderation = human + AI — content moderation ใช้ OpenAI moderation API + report queue",
            "Comment tree = recursive — 100 comment ใน 1 post = nested render, ต้อง lazy load depth 3",
            "Real-time (new post notify) = WebSocket — 1000+ concurrent = scaling pain, Pusher/Ably ช่วย",
            "Privacy leak: like count public = 'soft' stalking — แก้โดย aggregate count only",
            "Notification spam — user โดน 100 mention ต่อวัน = mute แอป, rate limit mention/user/day"
          ],
          "gotchas": [
            "Feed query: SELECT * FROM posts WHERE authorId IN (followeeIds) OR visibility='public' ORDER BY ts DESC LIMIT 20",
            "Like: posts[id].likes = array of userId — toggle แทน insert/delete, unique constraint ไม่ต้อง",
            "Comment: parent_comment_id NULL for top-level, set for reply — recursive CTE 1 query for tree",
            "Pagination: cursor = encodeURI(lastTs + ':' + lastId) — offset pagination skip bug ที่ N+1",
            "Mention: regex /@([\\w]+)/g — validate user exists, ไม่งั้น notify user ที่ไม่มี = bug",
            "Hashtag: extract + lowercase, index in separate table, query posts JOIN post_tags",
            "Notification: per-user queue + mark-read — store ใน DB ไม่ใช่แค่ push (offline user)",
            "Chat memory (AI): store messages[] in session, send to AI as context — TTL 30 days then expire"
          ],
          "anti_patterns": [
            "Pull full feed (no pagination) — 10k posts × 1MB = 10GB memory, ใช้ cursor pagination",
            "Like เป็นแถวใหม่ต่อ user เลย — millions of rows, count = SELECT COUNT(*) slow, ใช้ array",
            "Comment recursive render ทุก depth — 100 nested = 1 frame 6 seconds, lazy load depth > 2",
            "ไม่มี rate limit — user spam 1000 mention = DoS, rate limit 10 mentions/user/hour",
            "Public feed ทุกคนเห็น — privacy leak ในระบบที่มี personal data, ต้อง default 'followers only'"
          ],
          "prereqs": [
            "1.3",
            "9.7",
            "9.9"
          ],
          "tags": [
            "social",
            "feed",
            "forum",
            "community",
            "followers",
            "likes",
            "comments",
            "สังคม",
            "ฟอรั่ม",
            "chat",
            "memo-ai"
          ],
          "compare_with": [
            "9.7 WebSocket (real-time push, not feed)",
            "10.7 Restaurant Sim (no social)",
            "10.8 Medical Booking (no social)"
          ]
        }
      ]
    }
  ]
}