Status: app v1.1.0 and Standard v1.2 shipped. Quiz JSON still uses schemaVersion: 1.
Last updated: 2026-08-28
This file is the maintainer reference for Quizbun's product behavior, architecture, decisions, release state, and deferred work. It describes the system as it exists and replaces the earlier planning and decision documents.
Use these sources for exact rules:
- CONTEXT.md defines the project's vocabulary.
- docs/description.md defines the product vision and wins if product documents conflict.
- src/shared/lib/quiz/schema.ts is the executable source of truth for Quiz validation.
- docs/standard.md is the normative Quiz Object Standard for authors and Renderer implementers.
The app version, Standard document revision, and schemaVersion are separate. App v1.1 added storage durability. Standard v1.1 added optional Question media and v1.2 added optional Image dimensions, both without breaking existing Quiz JSON.
Quizbun is a static, explanation-first quiz catalog for self-learners. It uses the versioned JSON Quiz Object Standard, which is designed for AI generation. The MIT-licensed site runs in the browser with no backend, accounts, or server-side runtime.
The core loop is short: generate a Quiz with an AI tool, Import its JSON file or pasted JSON into Quizbun, learn from each Explanation, then resume or Retake the Run.
The same Quiz JSON can be validated, reviewed in a pull request, and rendered by another application.
- Learning before testing. Every submitted Question reveals its Explanation. Results stay binary because the Explanation matters more than the score.
- Static Catalog, local Library. The build bundles Catalog Quizzes. Imported Quizzes and all Runs stay in browser storage. Remote Images may load from their declared URLs, and Videos contact YouTube only after activation.
- One strict Standard. Minimal required fields and fixed defaults reduce invalid AI output. Unknown fields fail validation.
- Safe Markdown. Quiz content uses Markdown. The Renderer strips raw HTML and sanitizes the generated markup.
- Open contributions. CI checks structure and schema. Reviewers check facts, clarity, and Explanation quality. A merged Quiz uses the repository's MIT license.
- Presentation-neutral content. A Quiz describes content and correctness, not layout, Option labels, pagination, or themes.
- A Learner takes Quizzes. The player is phone-first.
- A Creator makes Quizzes, usually with AI, and may keep them private.
- A Contributor submits a Quiz to the Catalog through a pull request.
One person may fill every role. Quizbun has no user identity.
The Standard defines one Quiz as metadata plus an ordered list of Questions. The generated JSON Schema is published at /schema/quiz.v1.json. This section summarizes the frozen v1 contract and its additive revisions: v1.1 media and v1.2 Image dimensions.
schemaVersionis the integer1. Strings such as"1"and"1.0"are invalid.- Validation is strict on Quiz, Question, Option, and validation objects. Unknown fields are errors.
- Errors include a precise path, problem, and suggested fix. src/shared/lib/quiz/format-errors.ts formats errors for Import and CI.
- Version 1 may add optional fields. Removing a field, tightening a constraint, or changing correctness requires
schemaVersion: 2. - Standard v1.1 added optional
imagesandvideosarrays. Existing Quiz JSON remains valid. - Standard v1.2 added optional Image
widthandheight, the intrinsic pixel size of the referenced file, enforced both-or-neither. They are generated byquiz:sizes:generateand verified byquiz:sizes:check, never typed by an author or a model. Existing Quiz JSON remains valid.
| Field | Required | Rule |
|---|---|---|
schemaVersion |
yes | Integer 1 |
id |
yes | Kebab-case slug with lowercase Latin letters, digits, and single hyphens |
title |
yes | Non-empty inline Markdown |
questions |
yes | Ordered, non-empty array with unique Question ids |
description |
no | Non-empty full Markdown |
language |
no | BCP 47-shaped tag such as en or en-US |
tags |
no | Tag array, defaults to [] |
author |
no | Non-empty free-form string, not an account |
Quiz id is the Library primary key. Library and Catalog use separate namespaces, so a private Quiz may reuse a Catalog id.
Every Question has a kebab-case id, title, type, and explanation. The title contains the ask. An optional description adds context but does not replace the ask. Optional description and references fields use full Markdown. Optional media arrays must be non-empty. A Question id is part of Progress identity, so changing it creates a new Question for storage.
Version 1 has three Question types:
single-choicemultiple-choiceinput
Choice Questions contain at least two { text, isCorrect } Options. Options have no ids or labels. Their identity is their index in the original JSON order.
single-choicerequires exactly one correct Option.multiple-choicerequires at least one correct Option. All Options may be correct. The submitted Option set must match the correct set exactly.
Input Questions use one validation mode:
textcompares submitted text with each accepted string. It trims, collapses whitespace, normalizes to Unicode NFC, and ignores case unlesscaseSensitiveistrue.numericaccepts finite JSON numbers. It allows.or,as the decimal separator, but not both. The answer is correct when its absolute difference from any accepted number is no greater thantolerance, which defaults to0.
Every Question produces one correct or incorrect result. Version 1 has no points, weighting, partial credit, or Run history. A Summary reports X of Y correct.
Version 1 also fixes these constraints:
- A choice Question needs at least two Options.
multiple-choicecannot express "select none" as correct.- Fields and arrays have no hard maximum lengths beyond their non-empty requirements.
- Numeric accepted answers are JSON numbers, not numeric strings.
Any Question may contain images and videos. Media exists only at Question level.
An Image is { src, alt, caption?, placement?, width?, height? }. Its required alt text describes content. caption uses inline Markdown. src accepts either an https:// URL or a bare kebab-case filename with a supported image extension. Directory segments, http://, protocol-relative URLs, and data URLs are invalid. width and height are the intrinsic pixel size of the referenced file, whole numbers at or above one, valid only as a pair. They let a Renderer reserve space before the file loads; they never set the displayed size.
A Video is { provider: "youtube", id, start?, placement? }. The id is an 11-character YouTube video id. start is a whole number of seconds at or above zero.
Both media types accept placement: "question" | "explanation". Missing placement means question, but the Renderer does not write that default into the Quiz. Question media appears between the title and description. Explanation media appears after the Explanation and before References.
The Renderer keeps Image order, then Video order. It never upscales Images. Failed Images show their alt text. Videos use a same-origin facade and create a youtube-nocookie.com iframe only after the Learner activates it. Markdown image syntax remains inert.
Media is part of the Content hash. Any media change invalidates that Question's saved answer. Export preserves the original placement and source values.
Authors must verify remote URLs and YouTube ids. CI checks Catalog Image files without making network requests.
Every text field uses Markdown. Raw HTML is stripped.
- Short fields use inline Markdown: Quiz titles, Question titles, Option text, and Image captions.
- Long fields use full Markdown: descriptions, Explanations, and References.
Fenced code highlighting supports JavaScript, TypeScript, JSX, TSX, JSON, HTML, CSS, Python, Bash, shell, and SQL. Unknown or missing language names render without highlighting.
Two rules preserve content identity across Renderers:
- Saved choice answers use original-order Option indexes. A shuffled Renderer must translate displayed positions before saving or checking an answer.
- Progress uses Quiz id, Question id, and Content hash. Re-import keeps an answer only when its Question id and Content hash still match.
Shuffling, Option labels, pagination, Page size, keyboard controls, and layout never belong in Quiz JSON.
z.toJSONSchema()generates public/schema/quiz.v1.json. CI rejects drift from the Zod schema. Zod remains the final authority for cross-field rules that JSON Schema cannot fully express.- The
create-quizskill owns the AI generation prompt. The site renders that source with a short introduction. - CI validates every file in docs/examples with the Zod schema.
- The Public catalog profile adds repository-only rules. It requires
description,language, at least one Tag, repository-wide Quiz id uniqueness, and a filename that matches the Quiz id. Every Catalog Image also requires generatedwidthandheightthat match the vendored file.
Quizbun uses Astro 7 static output and React 19 islands, with Bun as the package manager and Node 22.12 or newer. GitHub Pages hosts the build. JavaScript hydrates only interactive parts such as the player, Import, Library, Tag filter, continue block, and copy-prompt control.
GitHub Pages builds set GITHUB_PAGES=true, which changes Astro's base path to /quizbun. Route and asset URLs must use withBase or the documentation loader's rewriting. Do not hardcode root-relative site URLs.
The code follows Feature-Sliced Design:
src/app/contains layouts and chrome used on every page.src/pages/contains Astro routes and required Astro endpoints only. Routes compose page slices and assign allclient:directives.src/_pages/contains screen-specific React composition forhome,quizzes,quiz,library, andlibrary-quiz. Page slices do not import each other.src/features/contains user capabilities.src/entities/quiz/contains Quiz metadata presentation.src/shared/contains cross-cutting code, styles, and UI components.
There is no widgets/ layer. Add it only for a reusable multi-feature block that is not a page.
Each Catalog Quiz stores vendored Images in content/quizzes/{id}/ and refers to them by filename. astro-quiz-assets.ts serves validated asset requests during development and copies assets to dist/quiz-assets/{id}/ during a build. withBase produces the public URL.
The Public catalog profile rejects remote Images because they cause third-party requests and link rot. Videos remain remote and use the click-to-load facade.
Library Imports accept both valid Image source forms. A bare filename resolves to {BASE_URL}quiz-assets/{quiz.id}/{filename}. A missing file falls back to alt text. Import does not reject or rewrite the source because either action would break Export round trips or alter the Content hash.
IndexedDB stores Library Quizzes and Runs. A Library record wraps the original Quiz as { quiz, importedAt }. Export returns only the Quiz.
The database stores one Run per Quiz and source. The key is ${source}:${quizId}, where source is catalog or library. Each submitted answer records its Content hash, submitted value, and correctness. A Run also records startedAt, optional finishedAt, and updatedAt. Readers use startedAt when an older Run lacks updatedAt.
The Content hash is SHA-256 over a stable serialization of the whole Question. Replacing a Library Quiz keeps answers whose Question ids and hashes still match, then discards the rest.
localStorage holds Page size, the selected Voice URI, and storage-notice dismissal. Invalid values fall back to defaults.
The app shows durability advice only while browser storage is not persistent. It never reports a byte count because estimate() covers the whole origin and browsers may pad it.
The notice links to browser-specific installation instructions and offers an explicit request for persistent storage. In standalone mode, the app calls persist() automatically. The app registers no service worker because installability no longer requires one and offline support has not shipped.
The notice appears on Home and Library because Catalog Runs and Library Quizzes share IndexedDB. hasStoredData() checks both stores. Home waits until data exists. Library may advise installation before the first Import.
Dismissal has separate nothing-stored and data-stored states so an early dismissal expires once data exists.
Library Quiz ids do not exist at build time. Private detail pages therefore use the static shell /library/quiz/?id={id}. GitHub Pages cannot serve an arbitrary dynamic path.
Quizbun rejects two alternatives:
- A
404.htmlrewrite would return a real 404 before client code corrected the route. - Hash routing would add SPA routing to an otherwise static site.
Do not replace the query-param route without revisiting the GitHub Pages constraint.
The site renders repository Markdown at build time through src/shared/lib/docs. The repository files remain canonical. The loader rewrites published documentation links for the deployment base and fails the build when a local link is missing.
Astro content collections were rejected because the small documentation set needs link rewriting, not a second content source.
Read aloud uses the browser's Web Speech API and lists only on-device English Voices. It is off until the Learner chooses a Voice. The Renderer speaks flattened Explanation text and sends no Quiz content to a speech service.
The footer picker and player are separate Astro islands. src/shared/lib/speech stores the selection with useSyncExternalStore, localStorage, voiceschanged, and a same-tab quizbun:voice-preference event.
Cloud speech and automatic playback are out of scope because they would break the privacy and accessibility rules.
Base UI appends each popup portal to document.body. src/shared/styles/global.css assigns [data-base-ui-portal] { z-index: 2 }. This works because the body is a grid and static grid items honor z-index. The most recently opened portal paints last because portals share the same value and append in open order.
Popup transforms create their own stacking contexts, so popup-level z-index cannot order separate portals. Browser tests must assert visible paint order with document.elementFromPoint, not compare computed values.
Page content must keep unscoped z-index below 2. The Import form isolates its internal sticky toolbar. If the body stops using grid, move page content into an isolated wrapper and keep portals as its siblings.
| Route | Kind | Purpose |
|---|---|---|
/ |
static with islands | Home, recent Catalog Quizzes, unfinished Runs |
/quizzes/, /quizzes/page/{n}/ |
static with island | Paginated Catalog and Tag filter |
/quizzes/{id}/ |
static per Quiz | Catalog Quiz detail and player |
/library/ |
static shell, noindex | Library list from IndexedDB |
/library/quiz/?id={id} |
static shell, noindex | Library Quiz detail and player |
/import/ |
static with island | Import |
/docs/ and child routes |
static | Public documentation |
/docs/examples/{file}.json |
static endpoint | Canonical example download |
/schema/quiz.v1.json |
static asset | Published JSON Schema |
The sitemap omits Library routes and /quizzes/page/1/. Page 1 uses /quizzes/ as its canonical URL. Library routes emit noindex because their useful content exists only in IndexedDB.
Player links use real anchors with rel="nofollow". They support deep links and modified clicks without asking crawlers to visit thousands of query-param copies of the same page.
Home explains Quizbun, lists the ten most recently added Catalog Quizzes, and shows up to five unfinished Runs when any exist. The full Catalog and Tag filter live at /quizzes/.
The build derives Quiz recency from each Catalog file's first Git commit. Missing history falls back to the build time and emits a warning when more than half of the Catalog uses the fallback. Equal dates sort by filename.
The continue block sorts Runs by updatedAt, resolves them across both namespaces, and shows the first five valid entries. Missing Quizzes are skipped.
The Tag filter stores a comma-separated ?tags= value with history.replaceState. Unknown Tags are ignored.
The Catalog and Library use the same player with different Quiz sources.
- Page size is
1,3,5, or10, defaults to5, and may change during a Run. - Submission reveals correctness, Explanation, optional media, and References. The Question then locks for the Run and saves immediately.
- Learners may visit pages in any order. Resume opens the first page with an unanswered Question.
- Finish appears after every Question is submitted. Summary links back to each Explanation. Retake replaces the Run.
- Choice controls use the original Option order in v1. Input Questions share one answer-checking module.
- Every choice Question shows an answer-count hint above its Options, derived from
type: "Select one" or "Select all that apply". The Option group points at it witharia-describedby, and it stays visible after submission so a reviewed answer still shows the rule it was graded by. Quiz text never states this; see docs/standard.md Renderer rules. - Question media and Explanation media follow the placement rules in section 2. Video activation moves focus to the created iframe.
- Read aloud appears after the Learner selects a Voice.
Import uses one textarea. Paste, file selection, and drag and drop all fill it. The app parses and validates JSON, shows an editable error report or a preview, then saves the Quiz. An id collision requires an explicit replace or cancel choice.
Library lists, opens, exports, and deletes Quizzes from IndexedDB. Deleting a Quiz also deletes its Run. The Library and Catalog use the same Tag-filter feature with different data sources.
Quiz detail shows metadata, Export, Reset progress, a Question preview, and a Run-aware action. The action is Start, Continue, See summary, or Retake. Public pages render a static Start fallback so the primary action exists before hydration.
Preview titles are real deep links into the player. Starting a Run swaps the player into the same route. The features/player shell lazy-loads its runtime, which keeps Question controls and Summary code out of the initial detail bundle. Its loading frame retains the Quiz title while a Run loads. This code split reduced eager quiz-page JavaScript from 528 KB to 419 KB raw, and from 187 KB to 146 KB compressed.
Reset progress and Retake both delete the saved Run.
Catalog JSON lives in content/quizzes/*.json. A filename must match its Quiz id. Quizzes with Images store those files in content/quizzes/{id}/. The build fails on invalid content, duplicate ids, or filename mismatches.
scripts/validate-public-quizzes.ts applies the Public catalog profile. It also checks missing and orphaned assets, folder and filename rules, supported extensions, the 512,000-byte file limit, and recorded Image dimensions against each vendored file. docs/contributing.md documents the same rules.
The public documentation includes the Standard, generation prompt, canonical examples, and contribution guide. The pull request template mirrors the automated checks. The root CONTRIBUTING.md points to the guide.
Human review checks facts, clarity, and whether each Explanation teaches more than the correct answer. Media review also checks usefulness, placement, alt text, attribution, and remote ids.
Import and CI use the same error shape, so a Contributor can paste the report back into an AI chat for correction.
Vitest runs two projects under vitest.config.ts:
- Unit tests use
.spec.tsin Node. - Component tests use
.test.tsxin real Chromium throughvitest-browser-react.
Playwright runs .e2e.ts journeys in e2e against a root-base astro preview build. Rebuild after a GITHUB_PAGES=true build because Playwright's base URL has no /quizbun prefix. Each test gets fresh browser storage. Catalog tests derive mutable content from the rendered page instead of hardcoding Quiz names.
The covered journeys include Import through Summary, Catalog filtering, every Question type, resume, Library management, Page-size changes, Content-hash invalidation, media behavior, preview deep links, keyboard and phone use, theme behavior, install metadata, and storage notices. A deploy-fidelity test for base-path deep links remains open.
Stryker runs mutation testing over the unit lane, driven by stryker.config.mjs and vitest.stryker.config.ts. It is a local tool for finding tests that execute code without asserting on it — bun run mutate for everything in scope, bun run mutate:file <glob> for one file, bun run mutate:report to open the HTML report. It is deliberately outside CI: it is slow, its score is advisory, and thresholds.break is null so it never fails a command. Files the unit lane does not reach show up as "no coverage" rather than as survivors; they are exercised by the component and e2e lanes instead.
Knip finds unused files, exports, and dependencies, driven by knip.json. bun run knip reports them; the repository is currently clean, so the command exits zero. It is optional and outside CI: its findings are advisory, and the src/**/index.ts entry pattern means an FSD slice public API is never reported as unused even when nothing imports it yet. Files under .claude and the generated skills bundle are ignored.
jscpd finds copy-pasted code, driven by .jscpd.json and run through bunx without being a dependency. bun run jscpd scans ts, tsx, astro, css, and mjs sources at a 10-line / 60-token threshold and writes HTML and JSON reports to tmp/jscpd; bun run jscpd:report opens the HTML one. It is optional and outside CI: its findings are advisory, and near-identical sibling components or token-only CSS blocks are often duplication worth keeping.
The Explanation/marking check judges what validation cannot express: whether a Question's explanation defends the Options marked isCorrect. A flag moved to the wrong Option is a claim about meaning rather than shape, so it passes the Standard, the Public catalog profile, and the Markdown audit alike. It is driven by scripts/check-explanation-marking.ts, which sends one TypeSafe noul per choice Question and reports the ones above FLAG_THRESHOLD — bun run quiz:explanations:check <quiz.json|dir>, with --all for the calibration view that prints every probability. It is optional and outside CI: it needs TYPESAFE_API_KEY and network access, it bills per request, and its findings are advisory. Calibration so far is one Quiz: algorithms-you-already-ship-frontend scored 0.020–0.050 across its 45 choice Questions, while a copy with three isCorrect flags moved to a wrong Option and every Explanation left untouched scored 0.740–0.940 — the 0.5 threshold sits in empty space between them. Sweep the whole Catalog before trusting that line. The check finds a misplaced flag; a Question whose Explanation and marking are wrong together is internally consistent, and invisible to it.
@stryker-mutator/vitest-runner 10 is patched in patches because it builds Vitest's testNamePattern by joining suite and test names with a space, while Vitest 5 matches them joined with " > " — unpatched, every mutant runs zero tests and survives (stryker-js#6210). Drop the patch once that ships upstream.
.github/workflows/ci.yml regenerates CSS Module types, type-checks TypeScript and Astro, runs tests and linters, validates documentation examples and Catalog Quizzes, checks generated artifacts, builds Astro, and checks deployment-base paths. Deploy waits for these checks.
While the repository is private, CI runs only through workflow_dispatch. Restore automatic push and pull request triggers when the repository becomes public.
- Support current Chrome, Firefox, Safari, and Edge.
- Keep every page responsive. Design the player phone-first.
- Use semantic HTML, full keyboard operation, visible focus, labeled controls,
fieldsetandlegendfor each Question, live correctness announcements, and correct dialog focus handling. Target WCAG AA, but do not claim certification without an audit. - Render build-time content statically. Hydrate only interactive islands. The player runtime remains lazy-loaded.
- Use
light-dark()with<html data-theme>for theming. Components use semantic tokens and co-located CSS Modules. - Keep the theme control two-state.
systemmeans no stored preference. Do not store a choice that matches the current system theme, and do not rewrite a stored choice when the system changes.
- The
v1.0.0tag froze Standard v1. Breaking changes now requireschemaVersion: 2. - App v1.1.0 added installation guidance and persistent browser storage for Library and Run durability.
- Standard v1.1 added Question Images and Videos across schema, docs, validation, assets, rendering, and tests without changing
schemaVersion. - Standard v1.2 added optional Image
widthandheightacross schema, docs, and the JSON Schema artifact, withscripts/generate-quiz-image-sizes.tsgenerating them. The pre-launch Catalog migration wrote dimensions for 743 Images across 726 Questions in 55 Quizzes. The Public catalog profile verifies every pair against its vendored file. - Local production and
/quizbun/base-path builds pass. A live GitHub Pages acceptance run remains blocked while the repository is private.
When the repository becomes public, restore automatic CI, deploy main, publish the release, run the full acceptance flow on the live URL, and verify validation on a pull request from a fork.
- Progress export and Import across devices. This needs its own versioned format and merge rules.
- Run history and statistics.
- Partial credit and scoring.
- Raw HTML in Quiz content.
- Structured Author data and per-Quiz licenses.
- Offline routing and caching.
- Interface localization.
- Option ids, presentation hints, and a richer taxonomy.
- Hard field-length limits.
- Documentation links that load an example directly into the player.
- Read aloud for non-English Voices.
- More media sources, presentation fields, and Video providers.
- Direct AI loading through an explicit local integration and permission model.
- The frozen Standard makes late schema mistakes expensive. Existing fixtures, Catalog generation, and schema drift checks reduce the risk.
- The live GitHub Pages path has not been exercised. Base-path builds and preview tests reduce the risk until deployment is possible.
- Strict validation can slow contributions. Precise, reusable error reports keep correction work small.