ProjectCards
Sign in to continue studying
Forgot password?

Don't have an account? Sign up

Study Session
Select decks to review

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.

Card Preview
Template Security Analysis

Analyzing...

Select a deck

Choose a deck from the sidebar to view and manage its cards.

0 selected
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:

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

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:

  1. Due relearning cards
  2. Due learning cards
  3. Due review cards
  4. 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:

  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). 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. 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: #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

  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: #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.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 (Ascending, Basic, Bins, Cloze, Dropdown, Hangman, MemoryMatch, MultiSelect, MultipleChoice, SortOrder, TrueFalse, WordOrder) are protected and cannot be overwritten or deleted.

Study

Appearance

Usage

Loading...
Loading rate limits...

Account

Not logged in

Media

Loading media...

Advanced

Title
Body