Select a deck and click Start Study Session to begin.
Study Stats
User Guide
Study
Select one or more decks from the dropdown and click Study All. The app builds a queue of due cards using FSRS scheduling. Cards appear in this order: relearning → learning → review → new (up to 20 new cards per session).
Rate each card Again, Hard, Good, or Easy. FSRS schedules the next review automatically. You can set your desired retention (default 90%) in Settings.
Templates
Nine built-in templates are included:
- Basic — Front/back flashcard. Best for definitions and facts.
- MultipleChoice — Question with 4 options and one correct answer.
- MultiSelect — Question with multiple correct answers (select all that apply).
- TrueFalse — Statement that is either true or false.
- Cloze — Fill-in-the-blank. Hide text with
{{double braces}}in a sentence. - Dropdown — Contextual fill-in-the-blank with choices. Wrap answers in
[[double brackets]]. - Bins — Categorize items into the correct bins.
- WordOrder — Reassemble a scrambled sentence into the correct word order.
- MemoryMatch — Flip cards to find matching pairs.
Every template supports images, audio, TTS generation, AI grading, and AI feedback.
Build Tab
The Build tab has a left sidebar listing all templates. Click a template once to open the single-card builder. Check multiple templates (2 or more) to unlock the AI Bulk Card Creator.
AI Bulk Card Creator
When 2+ templates are selected, the bulk creator appears. Describe your study material (or upload images, documents, or audio) and the AI will:
- Plan — Analyze the material and decide how many questions to create for each concept.
- Generate — Create complete cards across all selected template types.
Pick a model tier (Pro/Smart/Fast) and card count (Auto or fixed 5–50). Use Per-Template Settings to control input method, AI grading, tier, feedback, and TTS per template type. These settings persist across sessions.
After generation, review the cards, uncheck any you don't want, then Save All or Save Selected.
Cards
The Cards tab shows every card. Click any card to preview it. Use Order Added / Similarity to sort. Similarity uses AI embeddings to cluster related cards.
Each row has an Active checkbox. Uncheck to deactivate a card — inactive cards are excluded from study queues and stats. They still appear in the card list for reference.
Prerequisites
Control learning order with prerequisites:
- Card-level — Click the star (★) on any card to require other cards in the same deck to be completed first. A card is "completed" when it has been reviewed at least once.
- Deck-level — Click the ⋮ menu on any deck and choose Prerequisites to require entire decks to be completed first. A deck is "completed" when all its cards have been reviewed at least once.
Cards with unmet prerequisites are hidden from the study queue until their prerequisites are satisfied.
Stats
Review counts, accuracy, cards studied per day, a 7-day due-card forecast, and per-deck breakdowns. The accuracy chart only counts active cards.
Settings
Appearance — Toggle dark mode. Saved to localStorage and propagated to all templates automatically.
Study — Adjust desired retention (70–99%, default 90%). Higher retention = more frequent reviews.
Data — Reset all review history and card states.
Advanced — Developer Mode reveals the Docs tab and the Upload Template button in the Build sidebar.
Template Developer Docs
Folder Structure
Templates live in Card_Templates/ (built-in) or cards/templates/ (uploaded, persistent). Each template is a folder:
MyTemplate/
template.json
schema.json
viewer/
index.html
style.css
logic.js
builder/
index.html
style.css
logic.js
template.json
Required. Minimal metadata:
{
"name": "My Template",
"description": "What this template does",
"version": "1.0"
}
schema.json
Required for AI Bulk Card Creator compatibility. Describes your template's data fields so the AI knows how to generate cards for it. If missing, the app auto-generates a minimal schema, but explicit schemas work better.
{
"name": "MyTemplate",
"description": "One-sentence description of what this template does.",
"data_schema": {
"front": {
"type": "string",
"required": true,
"description": "Question or prompt shown on the front"
},
"back": {
"type": "string",
"required": true,
"description": "Answer shown on the back"
},
"frontImage": {
"type": "string",
"required": false,
"description": "URL to an image displayed on the front"
},
"frontAudio": {
"type": "string",
"required": false,
"description": "URL to an audio file played on the front"
}
},
"example": {
"front": "What is the capital of France?",
"back": "Paris"
}
}
Field types: string, number, boolean, array, object. Use required: true for fields the AI must always generate. Use required: false for optional media or extras. The example object is shown to the AI as a concrete sample.
The Viewer
The viewer loads in an iframe during study. It receives card data from the parent and sends back ratings.
Messages from Parent
{type: 'card', data: {...}, previews: {...}, theme: 'dark', preview: false}— sent when a new card loads.datacontains the card object withid,data(your template fields), andconfig.previewsshows interval hints like{again: '1m', good: '3d'}.themeis'dark'or'light'.previewistruewhen shown in the card preview modal.{type: 'theme', theme: 'dark'}— sent live when the user toggles dark mode.
Messages to Parent
{type: 'ready'}— send when your viewer loads so the parent knows to send the card. Wrap inDOMContentLoaded.{type: 'rating', value: 'Again'}— send when the user picks a rating. Valid values:Again,Hard,Good,Easy.{type: 'resize', height: 420}— send when your content height changes so the parent can resize the iframe. Use aResizeObserverondocument.bodyor the root element.
Dark Mode in Templates
The parent sends the theme via postMessage. Your template should apply a dark-mode class to body:
function applyTheme(theme) {
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
// Init from localStorage on load
(function initTheme() {
const saved = localStorage.getItem('theme');
if (saved === 'dark') document.body.classList.add('dark-mode');
})();
// Listen for live updates
window.addEventListener('message', (e) => {
if (e.data?.type === 'theme') applyTheme(e.data.theme);
if (e.data?.type === 'card') {
if (e.data.theme) applyTheme(e.data.theme);
// ... render card ...
}
});
Add body.dark-mode CSS overrides in style.css. See built-in templates for palette examples (background: #1a1b26, text: #c0caf5, surface: #24283b, border: #414868).
Preview Mode
The parent sends preview: true when showing a card in the preview modal. Hide rating buttons and input areas:
<style>
body.preview-mode #rating-bar,
body.preview-mode #input-area { display: none !important; }
</style>
Iframe Height & Resizing
The parent sets the iframe height dynamically. Tell it when you resize:
function sendHeight() {
const h = document.documentElement.scrollHeight;
window.parent.postMessage({type: 'resize', height: h}, '*');
}
// Send on load and when content changes
window.addEventListener('load', sendHeight);
new ResizeObserver(sendHeight).observe(document.body);
Media Files
Upload files via POST /api/upload and store the returned URL in card data:
async function uploadMedia(file) {
const form = new FormData();
form.append('file', file);
const res = await apiFetch('/api/upload', { method: 'POST', body: form });
const result = await res.json();
return result.url; // e.g. "/api/media/filename.jpg"
}
Reference them in your viewer with <img src="${card.data.frontImage}"> or <audio controls src="${card.data.frontAudio}">. The parent serves them from /api/media/{filename}.
Using Config in the Viewer
The card object includes config (set by the builder or AI Bulk Creator). Use it to conditionally show/hide features:
window.addEventListener('message', (e) => {
if (e.data?.type === 'card') {
const card = e.data.data;
const cfg = card.config || {};
// Show text input, mic button, or both
if (cfg.input_method === 'text') showTextInput();
else if (cfg.input_method === 'speak') showMicButton();
else showBoth();
// Enable AI grading / feedback
if (cfg.ai_grading) enableAIGrading(cfg.ai_tier);
if (cfg.ai_feedback) enableAIFeedback();
}
});
The Builder
The builder loads in an iframe when the user clicks a template in the Build sidebar. It creates and edits cards.
Creating a Card
async function saveCard() {
const deck = new URLSearchParams(location.search).get('deck') || 'default';
const res = await apiFetch('/api/cards', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
template: 'MyTemplate',
deck: deck,
data: { front: '...', back: '...' },
config: { input_method: 'text', ai_grading: false }
})
});
const result = await res.json();
window.parent.postMessage({type: 'saved', id: result.id}, '*');
}
Updating a Card
The builder receives an edit message when editing an existing card:
window.addEventListener('message', (e) => {
if (e.data?.type === 'edit') {
const card = e.data.data;
// Pre-populate your form fields
document.getElementById('front').value = card.data.front;
document.getElementById('back').value = card.data.back;
// Store the ID for the update call
editingId = card.id;
}
});
async function updateCard() {
const res = await fetch(`/api/cards/${editingId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
data: { front: '...', back: '...' },
config: { input_method: 'text', ai_grading: false }
})
});
window.parent.postMessage({type: 'updated', id: editingId}, '*');
}
Builder Messages from Parent
{type: 'theme', theme: 'dark'}— dark mode toggle.{type: 'edit', data: {...}}— populate form with existing card data.
Builder Messages to Parent
{type: 'saved', id: '...'}— new card created successfully.{type: 'updated', id: '...'}— existing card updated successfully.{type: 'resize', height: 600}— builder iframe height changed.
Card Config
Store per-card settings in the config field. The viewer receives this in card.config:
{
"input_method": "text", // "text" | "speak" | "both"
"ai_grading": true, // true | false
"ai_tier": "smart", // "smart" | "pro" | "fast"
"ai_feedback": true, // true | false
"tts_tier": "smart" // "smart" | "pro"
}
The builder should expose toggles for these so users can customize how each card behaves during study.
AI API
Include the shared library to use AI from any template:
<script src="/templates/_shared/openrouter.js"></script>
Chat
// Simple prompt
const reply = await OR.ask("Explain recursion", "smart");
// Full control
const result = await OR.chat({
capability: "text", // "text" | "multimodal"
tier: "smart", // "pro" | "smart" | "fast" | "auto"
messages: [{role: "user", content: "Hello"}],
temperature: 0.7,
max_tokens: 2048,
response_format: {type: "json_object"}
});
// result.text — the reply
// result.model — the model used
Multimodal (Images, Audio)
const reply = await OR.askMultimodal([
{role: "user", content: [
{type: "text", text: "What's in this image?"},
{type: "image_url", image_url: {url: "data:image/png;base64,..."}}
]}
], "smart");
Grade a Text Answer
const result = await OR.gradeAnswer(
question, // the card front / prompt
correctAnswer, // the expected answer
userAnswer, // what the user typed
"smart" // tier
);
// result.grade // "A" | "B" | "C" | "D" | "F"
// result.score // 0-100
// result.feedback // text feedback
// result.suggestedRating // "Again" | "Hard" | "Good" | "Easy"
Grade a Spoken Answer
Sends audio directly to a multimodal model. No transcription step.
// Record mic and grade in one call
const result = await OR.recordAndGradeSpeech(
question, correctAnswer, 10000, "smart"
);
// Or grade existing base64 audio
const result = await OR.gradeSpeech(
question, correctAnswer, base64Audio, "webm", "smart"
);
Text-to-Speech
const audio = await OR.ttsAudio("Hello world", "smart");
audio.play();
Speech-to-Text
const result = await OR.stt(base64Audio, "webm");
console.log(result.text);
Generate Cards from a Topic
const cards = await OR.generateCards("Python decorators", "smart", 3);
// cards = [{front: "...", back: "..."}, ...]
Embeddings
const result = await OR.embeddings(["hello", "world"]);
// result.vectors = [[0.1, ...], [0.2, ...]]
Image Generation
const result = await OR.generateImage("A cat in space", "smart");
// result.image_url or result.text
Model Tiers
| Capability | Pro | Smart | Fast | Auto |
|---|---|---|---|---|
| Text | deepseek-v4-pro | deepseek-v4-flash | gpt-oss-20b | — |
| Multimodal | gemini-pro | gemini-flash | gemini-flash-lite | openrouter/auto |
| TTS | gemini-tts | kokoro | — | — |
| Image | gemini-image | seedream | — | — |
Backend API Reference
Endpoints you may call from builders and viewers:
Cards
GET /api/cards # List all cards
GET /api/cards/{id} # Get a single card
POST /api/cards # Create a card
PUT /api/cards/{id} # Update card data/config
DELETE /api/cards/{id} # Delete a card
POST /api/cards/{id}/reset # Reset FSRS state to New
POST /api/cards/{id}/active # Toggle active (body: {active: true|false})
PUT /api/cards/{id}/prerequisites # Set card prerequisites (body: {prerequisite_cards: [...]})
POST /api/cards/batch # Batch create cards (body: {cards: [...]})
POST /api/cards/bulk-generate # AI bulk generation (multipart/form-data, SSE streaming)
Decks
GET /api/decks # List all decks with stats
GET /api/decks/{id} # Get deck details
PUT /api/decks/{id}/prerequisites # Set deck prerequisites (body: {prerequisite_decks: [...]})
POST /api/decks/{id}/rename # Rename deck (body: {name: "..."})
POST /api/decks/{id}/delete # Delete deck and all its cards
Study Queue
GET /api/study-queue # Get due cards (excludes inactive & unmet prereqs)
Templates
GET /api/templates # List all templates + protected set
GET /api/templates/{name}/schema # Get schema.json (auto-generated fallback)
POST /api/templates/analyze # AI security scan (multipart files)
POST /api/templates # Save uploaded template (multipart)
DELETE /api/templates/{name} # Delete custom template
Media
POST /api/upload # Upload file (multipart, field: "file")
GET /api/media/{filename} # Serve uploaded file
Settings
GET /api/retention # Get desired retention
POST /api/retention # Set desired retention (body: {desired_retention: 0.9})
FSRS Scheduling
When a user rates a card, FSRS updates its state and schedules the next review. The viewer doesn't handle this — just send the rating.
Card States
- New (0) — never reviewed. Appears in new card queue.
- Learning (1) — reviewed once, short due interval.
- Review (2) — graduated, longer intervals.
- Relearning (3) — lapsed after Again, short interval.
Queue Order
- Due relearning cards
- Due learning cards
- Due review cards
- New cards (up to 20 per session)
Desired Retention
The user can adjust desired retention in Settings (default 90%). This changes how aggressively FSRS schedules reviews. Your template does not need to handle this — it happens automatically in the backend scheduler.
Complete Example
A minimal template with dark mode, preview support, and resizing.
viewer/index.html
<style>
body { font-family: sans-serif; padding: 20px; text-align: center; background: #f8f9fa; }
body.dark-mode { background: #1a1b26; color: #c0caf5; }
#card { background: #fff; border: 1px solid #ccc; padding: 40px; margin-bottom: 20px; cursor: pointer; border-radius: 8px; }
body.dark-mode #card { background: #24283b; border-color: #414868; }
.btns { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
button { padding: 10px 20px; border: 1px solid #ccc; background: #fff; cursor: pointer; border-radius: 4px; }
body.preview-mode .btns { display: none !important; }
</style>
<div id="card" onclick="flip()">Waiting...</div>
<div class="btns">
<button onclick="rate('Again')">Again</button>
<button onclick="rate('Hard')">Hard</button>
<button onclick="rate('Good')">Good</button>
<button onclick="rate('Easy')">Easy</button>
</div>
<script>
let card = null, showingBack = false;
function applyTheme(theme) {
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
(function initTheme() {
const saved = localStorage.getItem('theme');
if (saved === 'dark') document.body.classList.add('dark-mode');
})();
function sendHeight() {
window.parent.postMessage({type: 'resize', height: document.documentElement.scrollHeight}, '*');
}
window.addEventListener('load', sendHeight);
new ResizeObserver(sendHeight).observe(document.body);
document.addEventListener('DOMContentLoaded', () => {
window.parent.postMessage({type: 'ready'}, '*');
});
window.addEventListener('message', (e) => {
if (e.data?.type === 'theme') applyTheme(e.data.theme);
if (e.data?.type === 'card') {
card = e.data.data;
if (e.data.theme) applyTheme(e.data.theme);
if (e.data.preview) document.body.classList.add('preview-mode');
else document.body.classList.remove('preview-mode');
showingBack = false;
render();
}
});
function render() {
document.getElementById('card').textContent =
showingBack ? card.data.back : card.data.front;
}
function flip() { if (card) { showingBack = !showingBack; render(); } }
function rate(v) { window.parent.postMessage({type: 'rating', value: v}, '*'); }
</script>
builder/index.html
<style>
body { font-family: sans-serif; padding: 20px; max-width: 400px; margin: 0 auto; background: #f8f9fa; }
body.dark-mode { background: #1a1b26; color: #c0caf5; }
input, textarea { width: 100%; padding: 8px; margin-bottom: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
body.dark-mode input, body.dark-mode textarea { background: #24283b; border-color: #414868; color: #c0caf5; }
button { padding: 10px 20px; cursor: pointer; border-radius: 4px; }
</style>
<input id="f" placeholder="Front">
<textarea id="b" placeholder="Back"></textarea>
<button onclick="save()">Save</button>
<script>
function getDeck() {
return new URLSearchParams(location.search).get('deck') || 'default';
}
function applyTheme(theme) {
if (theme === 'dark') document.body.classList.add('dark-mode');
else document.body.classList.remove('dark-mode');
}
(function initTheme() {
const saved = localStorage.getItem('theme');
if (saved === 'dark') document.body.classList.add('dark-mode');
})();
window.addEventListener('message', (e) => {
if (e.data?.type === 'theme') applyTheme(e.data.theme);
if (e.data?.type === 'edit') {
document.getElementById('f').value = e.data.data.data.front || '';
document.getElementById('b').value = e.data.data.data.back || '';
editingId = e.data.data.id;
}
});
let editingId = null;
async function save() {
const payload = {
template: 'Minimal',
deck: getDeck(),
data: { front: f.value, back: b.value },
config: { input_method: 'text', ai_grading: false }
};
const url = editingId ? `/api/cards/${editingId}` : '/api/cards';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const r = await res.json();
window.parent.postMessage({type: editingId ? 'updated' : 'saved', id: r.id || editingId}, '*');
f.value = ''; b.value = ''; editingId = null;
}
</script>
Tips
- Keep viewer and builder lightweight — they run in iframes.
- Use relative paths for files inside your template folder. Use absolute paths like
/templates/_shared/openrouter.jsfor shared resources. - The builder receives the deck ID via URL query:
?deck=my-deck. - Keyboard shortcuts in the viewer are disabled when the user is focused in any
<textarea>or<input>. - Wrap
postMessage({type: 'ready'})inDOMContentLoadedso the parent iframe is fully ready. - Send
{type: 'resize', height}whenever your content changes so the iframe never clips. - Test your template in both light and dark mode before uploading.
- schema.json is critical. Without it, the AI Bulk Card Creator will use a minimal auto-generated schema that may miss optional fields or produce poor card data.
- Built-in templates (Basic, MultipleChoice, Cloze, Bins, WordOrder, TrueFalse, MultiSelect, Dropdown, MemoryMatch) are protected and cannot be overwritten or deleted.