Ready to study?
Select decks above and click Start to begin your session.
Create new cards
Create button on any template to build a single card.
Enable AI Bulk Builder to generate cards across multiple templates.
Select a deck
Choose a deck from the sidebar to view and manage its cards.
Study Stats
Track your learning progress and review forecasts
User Guide
Getting Started
New accounts start with an interactive onboarding tour that walks through Library, Create, Study, and Settings. You can replay the tour anytime by refreshing the page after logging in.
Navigation
The left sidebar has six sections: Library (decks and cards), Create (template builders and AI bulk generation), Study (review queue), Stats (progress charts), Settings (preferences and admin), and Help (this page). The sidebar collapses on smaller screens.
Library
Decks are shown as a responsive grid of cards. Each deck card displays the name, total card count, due count badge, and last-studied time. Sub-decks are visually nested under their parent.
Click a deck to open its detail view. Inside you can study the deck, add cards, and manage all cards in a sortable table with search, template filter, and status filter. Click any card to preview it in a modal.
Use the Active toggle on cards to deactivate them. Inactive cards are excluded from study queues and stats but remain visible for reference.
Create
Templates are displayed as a responsive grid of cards with icons and descriptions. Click a template once to open the single-card builder. Select multiple templates (2 or more) to unlock the AI Bulk Card Creator.
Enable Developer Mode in Settings to reveal the Upload Template button for importing custom templates.
Study
On the Study page, deck tiles show total cards and due counts (new / learning / review). Select one or more decks and click Start Session. The app builds a prioritized queue using FSRS scheduling:
- Due relearning cards
- Due learning cards
- Due review cards
- New cards (up to 20 per session)
Rate each card Again, Hard, Good, or Easy. FSRS schedules the next review automatically. When the session ends, a results summary appears and confetti celebrates your progress.
Click End Session anytime to stop early. Your progress up to that point is saved.
Templates
Twelve 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.
- SortOrder — Drag and drop items into the correct sequence or ranking.
- Ascending — Drag and drop items into ascending order by value, size, or intensity.
- MemoryMatch — Flip cards to find matching pairs.
- Hangman — Guess the word letter by letter. Perfect for vocabulary and terminology.
Every template supports images, audio, TTS generation, AI grading, and AI feedback.
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). Each template type has built-in optimized settings for AI feedback, grading, and TTS.
Templates can use "__tts__" as a marker in audio fields (e.g. "frontAudio": "__tts__") to trigger automatic text-to-speech generation during bulk creation.
After generation, review the cards, uncheck any you don't want, then Save All or Save Selected.
Prerequisites
Control learning order with prerequisites:
- Card-level — Click the star icon on any card in a deck to require other cards 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
The Stats page shows review counts, accuracy, cards studied per day, and a 7-day due-card forecast. Chart.js powers the visualizations:
- Retention trend — Line chart tracking accuracy over time.
- Cards by deck — Doughnut chart showing card distribution.
- Due forecast — Bar chart of upcoming due cards with color-coded states.
- Review calendar — GitHub-style heatmap of daily review activity.
Accuracy metrics only count active cards.
Settings
Study — Adjust desired retention (70–99%, default 90%). Higher retention = more frequent reviews. Toggle Auto-generate embeddings to enable semantic search (slows down card creation; off by default).
Appearance — Toggle between Light, Dark, and System theme. Saved to localStorage and propagated to all templates automatically.
Usage — View storage usage with a color-coded progress bar and daily rate-limit usage per AI category.
Account — Display email, log out, or reset all review history and card states.
Admin Panel — Admins can view all users, manage admin status, delete users, and edit global rate limits.
Advanced — Developer Mode reveals the Docs tab and the Upload Template button in the Create page.
Keyboard Shortcuts
- Escape — Close modals and preview overlay.
- Arrow Left / Right — Navigate between cards in the preview modal.
- Enter — Confirm prompt modals (rename, etc.).
Template Developer Docs
Folder Structure
Templates live in Card_Templates/ (built-in) or cards/users/{user_id}/templates/ (uploaded, persistent). Each template is a self-contained 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 produce much better results.
{
"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: #0d1117, text: #c9d1d9, surface: #161b22, border: #30363d).
You can also import the shared design tokens: <link rel="stylesheet" href="/templates/_shared/tokens.css">
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 || {};
// Templates have built-in input methods and AI settings.
// Mic button is available on text-input templates for voice transcription.
}
});
The Builder
The builder loads in an iframe when the user clicks a template in the Create page. 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: {}
})
});
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: {}
})
});
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
Templates have built-in optimized settings for AI feedback, grading, and TTS. The config field can still be used for template-specific options.
Icons
Templates can use icons from the /Icons/ folder. Reference them with absolute paths like /Icons/icons8-name-50.png. Both 50px and 100px versions exist for every icon.
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();
In bulk generation, use "__tts__" as an audio field value to auto-generate TTS for that field.
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/deepseek-v4-pro | deepseek/deepseek-v4-flash | openai/gpt-oss-20b | — |
| Multimodal | google/gemini-pro-latest | google/gemini-flash-latest | google/gemini-3.1-flash-lite-preview | openrouter/auto |
| TTS | google/gemini-3.1-flash-tts-preview | hexgrad/kokoro-82m | — | — |
| Image | google/gemini-3.1-flash-image-preview | bytedance-seed/seedream-4.5 | — | — |
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
POST /api/cards/batch # Batch create cards (body: {cards: [...]})
PUT /api/cards/{id} # Update card data/config
DELETE /api/cards/{id} # Delete a card
POST /api/cards/{id}/review # Submit review (body: {rating: "Again"})
POST /api/cards/{id}/move # Move card to another deck
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
POST /api/cards/bulk-generate # AI bulk generation (SSE streaming)
POST /api/cards/backfill-embeddings # Generate missing embeddings
Decks
GET /api/decks # List all decks with stats
POST /api/decks # Create a new deck
PUT /api/decks/{id} # Rename deck (body: {name: "..."})
PUT /api/decks/{id}/prerequisites # Set deck prerequisites
DELETE /api/decks/{id} # Delete deck (?cascade=true)
POST /api/decks/{id}/reset # Reset FSRS for all cards in deck
Study Queue
GET /api/study-queue # Get due cards (?deck= or ?decks=a,b)
GET /api/retention # Get desired retention
POST /api/retention # Set desired retention
GET /api/embeddings-setting # Get embedding generation setting
POST /api/embeddings-setting # Set embedding generation
Templates
GET /api/templates # List all templates + protected set
GET /api/templates/{name}/schema # Get schema.json
POST /api/templates/analyze # AI security scan (multipart)
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
User & Admin
GET /api/user/storage # Storage usage
GET /api/user/limits # Rate limit usage
GET /api/admin/users # List all users (admin only)
POST /api/admin/users/{id}/toggle-admin
DELETE /api/admin/users/{id} # Delete user and all data
GET /api/admin/usage # Daily usage report
GET /api/admin/rate-limits # Get rate limits
POST /api/admin/rate-limits # Update rate limits
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: #fafafa; }
body.dark-mode { background: #0d1117; color: #c9d1d9; }
#card { background: #ffffff; border: 1px solid #ccc; padding: 40px; margin-bottom: 20px; cursor: pointer; border-radius: 8px; }
body.dark-mode #card { background: #161b22; border-color: #30363d; }
.btns { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
button { padding: 10px 20px; border: 1px solid #ccc; background: #ffffff; 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: #fafafa; }
body.dark-mode { background: #0d1117; color: #c9d1d9; }
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: #161b22; border-color: #30363d; color: #c9d1d9; }
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: {}
};
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 (Ascending, Basic, Bins, Cloze, Dropdown, Hangman, MemoryMatch, MultiSelect, MultipleChoice, SortOrder, TrueFalse, WordOrder) are protected and cannot be overwritten or deleted.
Settings
Customize your study experience and account preferences