Interactive quiz platform for reviewing books, podcasts, and courses. Live at quiz.hasit.in.
Load the relevant skill before starting a domain-specific task:
| Task | Skill to Load |
|---|---|
| Converting syllabus/transcript/notes into quiz JSON | syllabus-to-quiz |
| Starting a multi-step task that may trigger compaction | hasits-plan |
Skills are loaded via OpenCode: <use_opencode_tool><name>skill</name><parameter>name</parameter>syllabus-to-quiz</use_opencode_tool>.
├── courses/ # Quiz content (JSON)
│ ├── courses_list.txt # Catalog of all course folders (IDs only, alphabetically sorted)
│ ├── courses-meta.json # Course metadata (title, type, chapters, source, description)
│ ├── course-schema.json # JSON Schema for chapter validation
│ ├── book-<title>/ # One folder per book/course
│ │ ├── 001.json # Chapter 1 questions (array of 7-field objects)
│ │ ├── 002.json # Chapter 2 questions
│ │ └── ...
│ ├── podcast-<title>/...
│ └── coursera-<title>/...
├── quiz/ # Frontend application
│ ├── index.html # Single-page quiz app (vanilla JS + Tailwind CDN)
│ ├── styles-theme.css # Theme variables and dark/light overrides
│ ├── styles-components.css # UI components (headers, filters, options, etc.)
│ ├── styles.css # Animations and keyframes only
│ ├── styles-responsive.css # Media queries
│ ├── lib/ # Modular JS (loaded via <script> tags, no bundler)
│ │ ├── state.js # Global state, BASE_URL, CATALOG_URL, streak utils
│ │ ├── catalog.js # Course catalog: load, filter, render, type filters
│ │ ├── preview.js # Preview screen: fetch module, show first question
│ │ ├── quiz.js # Quiz engine: timer, scoring, render, skip, completion
│ │ ├── sharing.js # Share modal, download PNG (html2canvas), Web Share API
│ │ ├── notifications.js # Toast notifications (showNotify, showNotifyWithAction)
│ │ ├── ai.js # AI Explain: calls Mistral via Cloudflare Worker proxy
│ │ └── main.js # Entry point: loads catalog, handles URL params, keyboard shortcuts
│ ├── tests/ # Playwright end-to-end + schema tests
│ │ ├── *.spec.mjs # Domain-split spec files
│ │ ├── test-utils.mjs # Shared mock data & route setup
│ │ ├── affected-tests.mjs # Git-diff-based test selector
│ │ ├── schema.config.mjs # Lightweight config for schema tests
│ │ ├── playwright.config.mjs # Main Playwright config
│ │ └── package.json # Test dependencies
│ └── proxy/ # Cloudflare Worker for AI explanations
│ └── worker.js # Mistral AI proxy
├── quiz/scripts/ # Node.js utility scripts
│ ├── answer-length-audit.js # Detect answer-length bias by word count
│ ├── assemble-course.mjs # Assembly helper: ch-*.json → input.json
│ ├── coverage-check.js # Verify concept coverage
│ ├── cross-chapter-repetition.js # Detect repeated concepts
│ ├── difficulty-audit.js # Print questions for difficulty labeling
│ ├── difficulty-tally.js # Tally difficulty distribution
│ ├── fix-length-bias.mjs # Auto-fix longest-answer bias by truncating conjunctions
│ ├── generate-course.mjs # CLI generator: input.json → split chapter files + metadata
│ ├── validate-all.js # Validate all courses comprehensively
│ └── validate.js # Validate a single course
├── .opencode/ # OpenCode AI agent configuration
├── .github/workflows/validate.yml # CI: schema, validate-all, full Playwright suite
├── opencode.json # OpenCode AI config
├── AGENTS.md # This file
└── README.md # Full reference documentation
Each course folder contains numbered chapter files (001.json, 002.json, ...). Each file is a JSON array of question objects with 7 required fields + 1 optional field:
| Field | Type | Required | Description |
|---|---|---|---|
question |
string |
Yes | Short concept name (e.g., "Opportunity Cost") |
content |
string |
Yes | Brief 1-2 sentence explanation of the concept |
description |
string |
Yes | Real-world scenario ending with a question |
options |
string[] |
Yes | Array of exactly 4 plausible answer strings. No positional refs. |
answer |
string |
Yes | Correct answer — must be identical (case, punctuation, whitespace) to one option |
explanation |
string |
Yes | Teaching explanation |
difficulty |
string |
Yes | "easy", "medium", or "hard" |
blank |
string |
No | Cloze passage containing answer verbatim, for fill-in-the-blank mode |
[
{
"question": "Concept Name",
"content": "Brief explanation (1-2 sentences).",
"description": "Real-world scenario. What does this demonstrate?",
"options": [
"Incorrect option 1",
"Incorrect option 2",
"Correct option",
"Incorrect option 4"
],
"answer": "Correct option",
"explanation": "Why this is correct and the others are not.",
"difficulty": "easy"
}
]- 7 required fields per question:
question,content,description,options(array of 4),answer,explanation,difficulty. No extra fields, no missing fields. An optionalblankfield may be added for cloze/fill-in-the-blank support. - Answer must match option exactly: The
answerstring must be character-for-character identical to one entry inoptions. Trailing spaces, capitalization, and punctuation differences all cause validation failures. - 4 options only: Exactly 4 strings in
options. No fewer, no more. - No positional references: Options must not reference other options by letter/position (e.g., "Both A and B", "All of the above", "A & C", "None of the above"). These break when the JS runtime shuffles option order.
- Difficulty values: Only
"easy","medium", or"hard"(lowercase). - Per-chapter difficulty targets:
- Easy: 30-40% of questions
- Medium: 35-45%
- Hard: 15-25%
- 7-12 questions per chapter file: Each
00N.jsonfile should have 7-12 question objects. More than 12 is allowed but not recommended. - Chapter files numbered 001.json, 002.json, etc.: Zero-padded 3-digit numbers in filenames.
courses_list.txtalphabetically sorted: After adding a new course, insert its ID in alphabetical order among existing entries.courses-meta.jsonkeys matchcourses_list.txt: Every key incourses-meta.jsonmust appear incourses_list.txtand vice versa. Both must be sorted identically. Thechaptersfield must matchGet-ChildItem courses/<id>/*.json | Measure-Object | Select-Object -ExpandProperty Count.- Answer word-count balance: All 4 options must be within ±20% of the mean word count of those 4 options. The answer must not be uniquely longest/shortest (a known length-hint signal). Mechanical templated padding (e.g., appending
"which is a critical factor to consider in this context") is banned — this suffix appears on 1,431 options in the existing corpus and is a detectable cheat signal. Usenode quiz/scripts/answer-length-audit.jsto detect both ratio violations and banned padding. Fix bias by writing context-appropriate expansions for each option, never a copy-paste template.
- Skills first: Load
hasits-planfor any multi-step task (3+ steps or any task that may trigger compaction). Loadsyllabus-to-quizfor all course content work. Never create/modify quiz JSON outside the skill. - Generator for 5+ chapters: Use
node quiz/scripts/generate-course.mjs input.jsonto create chapters from a structured input file (avoids PowerShell quoting issues). Supports--dry-runfor preview. The generator auto-updatescourses_list.txtandcourses-meta.json. Deleteinput.jsonafter use. For courses with many chapters, use the intermediatech-*.jsonworkflow: writech-001.json–ch-00N.jsonfiles in the course directory, thennode quiz/scripts/assemble-course.mjs <course-id>to produceinput.jsonfor the generator. - Validate before committing: Run both
node quiz/scripts/validate-all.jsandnpm run test:schemabefore committing course content changes. A pre-commit hook (.githooks/pre-commit) auto-runs both when course files are staged; enable it withgit config core.hooksPath .githooksafter cloning. - Don't validate courses for code-only changes: If no files under
courses/were touched (git diff --name-onlyhas nocourses/entries), skipvalidate-all.jsandtest:schema. Only run Playwright tests relevant to the changed lib files. Batching schema validation on every code change wastes time. - PowerShell execution policy: Prefix failing commands with
powershell -ExecutionPolicy Bypass -Command "...". - All paths relative to project root unless stated otherwise.
- Single-file edits: Validate each file before moving to the next. Don't batch edits across chapters.
- Run affected tests before commit: Use
node quiz/tests/affected-tests.mjsto see which tests are relevant to your changes, then run the suggested subset.npm run test:setup—quiz/lib/main.js,quiz/lib/state.js,quiz/lib/notifications.jsnpm run test:catalog—quiz/lib/catalog.jsnpm run test:preview—quiz/lib/preview.jsnpm run test:quiz—quiz/lib/quiz.js,quiz/lib/state.jsnpm run test:params—quiz/lib/main.jsnpm run test:ui—quiz/lib/sharing.jsnpm run test:ai—quiz/lib/ai.jsnpm run test:unit—quiz/lib/state.js,quiz/lib/catalog.js,quiz/lib/sharing.js(isolated function tests)npm run test:visual— visual-only changesnpm run test:schema—courses/**/*.json,courses/course-schema.json
- Catalog test conventions:
CATALOG_CONTENT=\n-joined course IDsMOCK_META= mockcourses-meta.jsonobject with{ chapters, title, type, source, description }MOCK_MODULES={ "001.json": [...], "002.json": [...] }for multi-chapter testing- Use
toHaveAttribute('data-value', …)for.list-item - Type filter buttons use
data-type(e.g.,.type-filter-btn[data-type="book"]) #course-dropdownhasw-fullclass- New course indicator tests: set
quizSeenCourseslocalStorage, checkNEWpill badge visibility (.new-badge), sort order (unseen first)
- Course ID display:
- Type prefix stripped via
/^(book|podcast|coursera|course)-/i - Emoji prefix (
📘/🎙/📖) shown only whenactiveTypeFilter === 'all' - Kebab-case → Title Case for display
- New courses (not yet seen) append
NEWpill badge (.new-badge); sort order: unseen first
- Type prefix stripped via
- Overflow-prone DOM elements (must not push content off-screen):
#preview-badge— course ID badge. Hastruncate max-w-[200px]#preview-title— course name (h2). Add mobile truncation instyles-responsive.css#module-label— quiz header span between "← Menu" and "Skip Module". Addmax-width: 140px+ mobile truncation; without it "Skip Module" gets pushed off-screen#course-dropdown .list-item— flex row with type SVG icon, title, and optionalNEWpill badge; overflow hidden on text, item getstitleattribute viarenderCatalogOptions()for full-name hover tooltip#begin-btn-wrapper— normal flow on mobile (not sticky)#preview-chapter-grid— flex-wrap chapter buttons, no truncation needed#topic-title,#description-text,#content-box— wrapping OK, no truncation
- Styling:
- Tailwind CSS via CDN (no build step) — utility classes in HTML
- Custom CSS (4 files):
styles-theme.css(variables),styles-components.css(components),styles.css(animations),styles-responsive.css(media queries) - JS toggles classes (
hidden,correct,wrong,screen-enter,score-pop) - Mobile overrides use
@media (max-width: 640px)
- Screen transition animation: The
screen-enterCSS class triggersfadeSlideInanimation (0.3s ease-out). Applied to preview, quiz-flow, and completion-screen on reveal. - Progress bar transition:
#progress-fillhastransition: width 0.3s easefor smooth animation. - Focus indicators:
:focus-visibleoutline (2pxvar(--accent), offset 2px) on#catalog-search,#share-btn,#course-dropdown.
- Scoring:
score += 100 + max(0, 50 - timeSpent * 5) + (streak > 2 ? 20 : 0) - Streak: Consecutive correct answers. Resets to 0 on wrong answer.
- Timer:
secondsElapsedincrements every second during quiz. Shown in#timer-val. - Daily streak: Stored in localStorage key
quizDailyStreakas{ lastDate: "YYYY-MM-DD", count: <number> }. Updated on quiz start and completion. - Course metadata:
coursesMeta(loaded fromMETA_URLvialoadCatalog()) stores per-course{ chapters, title, type, source, description }. Used by preview to show chapter count and chapter grid. - New course tracking: Stored in localStorage key
quizSeenCoursesviamarkCourseSeen(id), queried byisCourseNew(id). Unseen courses showNEWpill badge (.new-badge) and sort before seen ones in the dropdown. - Options shuffling: Options are shuffled via Fisher-Yates inside
shuffleArray()inquiz.js. Answer matching is done against the original (pre-shuffle) text. - Module chaining: After the last question of a chapter, if the course has more chapters, "Start Next Module" button appears. If it was the last chapter, "Return to Catalog" appears with trophy animation.
- Custom URL loading: The user can paste any JSON URL — no path validation required. The URL section (
#url-section,#quiz-url) is lazy-created bygetOrCreateUrlSection()incatalog.json firsttoggleUrlInput()call — not in initial HTML. The toggle button sits in the footer as🔗 Custom Quiz. Code that references#quiz-urlmust guard withdocument.getElementById('quiz-url')null check. - AI Explain flow: 4 persona buttons (
.ai-persona-btn[data-persona]) triggeraskAI(persona). Personas:child(simple),deep(expert),first-principles(fundamental truths),socratic(guiding questions). POSTs toMISTRAL_PROXY_URLwith persona-specific system prompt. Shows response in#ai-response. Falls back gracefully on failure. - Sharing context:
- Completion screen → share certificate + score
- Quiz active → share question + user's answer
- Catalog screen → share portal link
- Achievement card: Template hidden off-screen at
#achievement-card-template(CSSleft: -9999px). Useshtml2canvasto render to PNG. - XSS hazard: no URL interpolation into inline handlers:
quiz.js:272built<button onclick="initializeQuiz('${nextUrl}')">viainnerHTML— a crafted URL with'breaks the string boundary and enables arbitrary JS execution. Completion-screen code must useaddEventListener/document.createElement, never string interpolation intoonclick(or similar) attributes. - Options trailing-whitespace edge case:
quiz.js:196comparescorrectAnswer(trimmed via.trim()) againstdataset.option(untrimmed). If any option in a JSON file has trailing whitespace, the correct-answer highlight after a wrong answer silently fails. Validate thatanswerand its matchingoptionsentry are exactly identical with no leading/trailing whitespace (reinforces rule 2 above).
Two test runners coexist:
- Node
node:test(.test.mjsfiles) — pure function unit tests and schema validation. No browser, no Playwright dependency. Run vianode --test *.test.mjsornpm run test:unit/npm run test:schema. - Playwright (
.spec.mjsfiles) — DOM interaction and visual regression tests. Requiresnpx playwright test <file>.
Node test conventions (lib-unit.test.mjs, schema-valid.test.mjs):
- Pure functions extracted into
test-helpers.mjs(imported by unit tests) — duplicateslib/logic for testability without refactoring app globals - Uses
node:test(describe/it) andnode:assert/strict schema-valid.test.mjsvalidates all JSON files dynamically (no Playwright browser, no AJV in this file — uses plain JSON traversal)
Playwright conventions:
- Opens
file:///.../quiz/index.htmlviapage.goto() - Uses
devices['iPhone X']for mobile screenshots in visual tests - Common operations:
page.waitForFunction(),waitForSelector(),el.scrollIntoView() - Snapshots:
expect(await page.screenshot()).toMatchSnapshot(...)withthreshold: 0.01 - Main config (
playwright.config.mjs): Two projects in sequence (Desktop 1280×800, Mobile Pixel 5),webServeron port 8765,fullyParallel: false - Playwright helpers (
test-utils.mjs):CATALOG_CONTENT=\n-joined course IDsMOCK_MODULES={ "001": [...questions...], "002": [...questions...] }setupMockRoutes()= intercepts catalog, meta, and module URLscreateMockQuestion()= generates a question with given overridesMOCK_META= mockcourses-meta.json(used by catalog and preview tests)test-helpers.mjsalso used by Playwright tests that import pure functions directly
- Affected test mapping (in
affected-tests.mjs):courses/→schema-valid.test.mjsquiz/lib/main.js→setup.spec.mjs,url-params.spec.mjsquiz/lib/state.js→setup.spec.mjs,quiz.spec.mjs,lib-unit.test.mjs,catalog.spec.mjs,preview.spec.mjsquiz/lib/catalog.js→catalog.spec.mjs,lib-unit.test.mjsquiz/lib/preview.js→preview.spec.mjsquiz/lib/quiz.js→quiz.spec.mjs,navigation.spec.mjsquiz/lib/sharing.js→ui.spec.mjs,lib-unit.test.mjsquiz/lib/notifications.js→setup.spec.mjsquiz/lib/ai.js→ai.spec.mjsquiz/styles.css,quiz/styles-components.css,quiz/styles-theme.css,quiz/styles-responsive.css→visual.spec.mjsquiz/index.html→ all spec and test filesquiz/tests/test-helpers.mjs→lib-unit.test.mjsquiz/tests/test-utils.mjs→catalog.spec.mjs,preview.spec.mjs,setup.spec.mjs,quiz.spec.mjscourses/course-schema.json→schema-valid.test.mjs
courses-meta.jsonkeys are course IDs (e.g.,"book-atomic-habits")- Fields:
title(string),type(enum:book/podcast/coursera),chapters(number),source(string|null),description(string) - The
typefield determines the emoji prefix shown in the UI - The
chaptersfield must match the actual number of chapter JSON files on disk
.github/workflows/validate.ymlhas 3 jobs:schema,validate-all,full-suitefull-suiteusesdorny/paths-filterto skip when onlycourses/**or*.mdchanged- Node.js version: 24. Cache:
npmforquiz/tests/package-lock.json npm run test:schemaruns withnode --test schema-valid.test.mjs- Scheduled daily at 11:56 UTC
opencode.jsonspecifies DeepSeek V4 Flash as the primary model, Mistral Large and OpenAI o3-mini as fallbacks- Nemotron 3 Super DGX used for embeddings
- Context compaction enabled at 80K tokens
AGENTS.mdadded tocompactifyIncludesfor context survival- Skills loaded on startup:
hasits-plan,customize-opencode