Select a deck and click Start Study Session to begin.

Loading templates...
Select a template to create cards, or select multiple for AI bulk generation.
Card Preview
Template Security Analysis

Analyzing...

Loading decks...
Edit Card
Card Prerequisites

Select cards that must be completed before this card appears in study:

Deck Prerequisites

Select decks that must be completed before this deck's cards appear in study:

Study Stats

Total Cards
New
Learning
Review
Cards by Deck
Due Cards (Next 7 Days)

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:

  1. Plan — Analyze the material and decide how many questions to create for each concept.
  2. 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. data contains the card object with id, data (your template fields), and config. previews shows interval hints like {again: '1m', good: '3d'}. theme is 'dark' or 'light'. preview is true when 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 in DOMContentLoaded.
  • {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 a ResizeObserver on document.body or 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

  1. Due relearning cards
  2. Due learning cards
  3. Due review cards
  4. 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.js for 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'}) in DOMContentLoaded so 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.

Settings

Appearance

Study

Data

Account

Not logged in

Storage

Loading...

Rate Limits

Loading...

Advanced