Skip to content
Gary Wu
Go back

Ambient Spaced Repetition

Edit page

Org Status: 🟒 Active Cloudflare: N/A Last Audited: 2026-04-28


Your MacBook’s notch is wasting 74 vertical pixels. Below the camera cutout sits a thin strip of screen real estate that most apps ignore entirely β€” but it is exactly the right size to display a Japanese character, its reading, and its meaning. This article explores how to build an ambient spaced repetition system that turns idle screen time into passive language learning, without ever opening an app.

What you’ll learn:


  1. The Problem
  2. Does Osmosis Learning Work?
  3. Core Concepts
  4. The Algorithm: SM-2 vs FSRS
  5. Patterns
  6. Small Examples
  7. Data Sources
  8. Rendering CJK at Small Sizes
  9. The Notch as a Display Surface
  10. Existing Tools Landscape
  11. UX Design for Ambient Flashcards
  12. Implementation Architecture
  13. Comparisons
  14. Anti-Patterns
  15. References

Traditional spaced repetition demands attention. You open Anki, you sit down, you grind through a review queue of 50-200 cards. Each card requires a deliberate action: read, recall, self-grade, advance. This works β€” the research is overwhelming on that point β€” but it requires a dedicated study session. Most people fail not because the algorithm is wrong but because they stop opening the app.

The failure mode is always the same:

  1. Day 1-14: Enthusiastic daily reviews, 20 minutes each
  2. Day 15-30: Reviews pile up, sessions become 40+ minutes
  3. Day 31-45: Guilt spiral, skipped days compound
  4. Day 60: Abandoned deck with 847 overdue cards

Meanwhile, you spend 8-12 hours a day staring at a computer screen. Your eyes drift to the menu bar hundreds of times per day β€” checking the time, glancing at battery level, noticing a notification badge. Each glance is 1-3 seconds of idle cognitive bandwidth. That is unused learning surface.

The question is not β€œcan we replace active SRS with passive exposure?” β€” we cannot. The question is: can we supplement active study with ambient reinforcement, and does that reinforcement improve retention enough to justify building it?

The answer, as we’ll see from the research, is a qualified yes β€” but only if you design the system correctly.

What changes if you get this right


Before building anything, we need to confront the research honestly. β€œAmbient learning” sounds like wishful thinking β€” the educational equivalent of sleeping with a textbook under your pillow. But the evidence is more nuanced than either skeptics or enthusiasts suggest.

What the research says

Incidental vocabulary acquisition is real. A meta-analysis published in Language Teaching confirmed that second language incidental vocabulary learning through meaning-focused input (reading, listening, viewing) produces measurable gains. The effect is smaller than intentional study, but it exists and it accumulates.

Exposure frequency matters, but attention matters more. Research on exposure frequency found that even two encounters with an unfamiliar word during reading can affect vocabulary growth. However, some researchers found that the amount of attention paid to a word is a stronger predictor than raw encounter count. This is critical for ambient display design β€” we need to design for moments of attention, not just visibility.

Receptive knowledge forms before productive knowledge. Learners consistently perform better at recognizing words (receptive) than producing them (productive). This aligns perfectly with ambient display: we are building a recognition system, not a production system. You will learn to read kanji before you can write it from memory, and that is fine.

Distributed practice beats massed practice. Research on the spacing effect consistently shows that spreading study sessions over time produces better retention than cramming. An ambient system is, by definition, maximally distributed β€” it delivers micro-doses of exposure throughout the entire day.

Subliminal presentation has limits. Research on subliminal priming found that subliminal stimuli (presented for 33-66ms) can produce priming effects, but these effects diminish rapidly in peripheral vision. Our system should target supraliminal presentation β€” visible for 5-15 seconds, in or near focal vision when the user glances at the menu bar.

Key insight: Ambient SRS is not a replacement for active study. It is a supplement that exploits the spacing effect and incidental acquisition to keep words warm between active sessions. Design it as a complement, not a substitute, and the research supports its effectiveness.

The microlearning connection

Microlearning research confirms that breaking content into small chunks and distributing them over time improves long-term retention. Language learning apps like Duolingo already exploit this with 5-10 minute sessions. An ambient notch display takes microlearning to its logical extreme: each β€œsession” is a single glance at a single card.

The key constraint is that effective vocabulary learning requires multiple exposures across different contexts. A single ambient exposure is insufficient β€” but 15-20 ambient exposures spread across a week, combined with active Anki review, creates exactly the multi-exposure pattern that research says works.


The Card

A card in an ambient SRS system carries less information than a traditional flashcard. You cannot fit a full sentence example, audio playback, and mnemonics into a 24px-high strip. The ambient card is deliberately minimal.

interface AmbientCard {
  id: string;
  front: string;           // The kanji/word: ι£ŸγΉγ‚‹
  reading: string;         // Hiragana reading: γŸγΉγ‚‹
  meaning: string;         // English gloss: "to eat"
  level: JLPTLevel;        // N5 | N4 | N3 | N2 | N1
  tags: string[];          // ["verb", "ichidan", "common"]
  srs: SRSState;           // Scheduling state
  exposureCount: number;   // Total ambient exposures
  lastGlancedAt: number;   // Timestamp of last display
  acknowledged: boolean;   // User actively interacted
}

type JLPTLevel = 'N5' | 'N4' | 'N3' | 'N2' | 'N1';

interface SRSState {
  difficulty: number;      // 1.0 (easy) to 10.0 (hard)
  stability: number;       // Days until 90% recall probability
  retrievability: number;  // Current recall probability (0-1)
  lastReview: number;      // Timestamp
  nextReview: number;      // Timestamp
  interval: number;        // Days between reviews
  repetitions: number;     // Successful recall count
  lapses: number;          // Failed recall count
}

Key insight: The ambient card is optimized for recognition, not recall. You see the front (kanji), the reading (hiragana), and the meaning (English) simultaneously. There is no hidden side. The learning happens through repeated visual association, not through testing.

The Display Modes

An ambient system needs multiple display modes because β€œambient” means different things at different moments. Sometimes the user is deeply focused on code and any movement is a distraction. Sometimes they are waiting for a build and have cognitive bandwidth to spare.

type DisplayMode =
  | 'passive'      // Static display, rotates every 30-60 seconds
  | 'pulse'        // Brief highlight animation on rotation
  | 'notification' // macOS notification with card content
  | 'hover'        // Expanded view when cursor enters notch area
  | 'quiz'         // Active recall prompt (front only, click to reveal)
  | 'hidden';      // Suppressed during presentations/screenshare

interface DisplayConfig {
  mode: DisplayMode;
  rotationInterval: number;     // Seconds between card changes
  showReading: boolean;         // Show furigana/reading
  showMeaning: boolean;         // Show English gloss
  fontSize: {
    front: number;              // Kanji size in points
    reading: number;            // Reading size in points
    meaning: number;            // Meaning size in points
  };
  animation: 'none' | 'fade' | 'slide';
  pauseDuringScreenShare: boolean;
  pauseDuringFullscreen: boolean;
}

The most important mode is passive β€” a static display that sits in the notch area or menu bar, cycling through cards at a gentle pace. No popups, no interruptions, no notifications to dismiss. Just a word that is there when you glance up.

The Scheduler

The scheduler decides which card to show next. In a traditional SRS, the scheduler picks the most overdue card. In an ambient system, the scheduler must balance multiple concerns:

interface AmbientScheduler {
  // Core scheduling
  getNextCard(): AmbientCard;
  recordExposure(cardId: string, duration: number): void;
  recordInteraction(cardId: string, grade: Grade): void;

  // Ambient-specific
  getCardPool(size: number): AmbientCard[];  // Pre-fetch pool
  adjustForContext(context: UserContext): void;

  // Sync
  exportToAnki(): AnkiExport;
  importFromAnki(deck: AnkiDeck): void;
}

interface UserContext {
  isScreenSharing: boolean;
  isFullscreen: boolean;
  activeApp: string;
  idleSeconds: number;
  timeOfDay: number;       // 0-23
  recentExposureCount: number;
}

enum Grade {
  Again = 1,    // "I don't know this at all"
  Hard = 2,     // "I vaguely remember"
  Good = 3,     // "I know this"
  Easy = 4,     // "Too easy, show less often"
}

Key insight: An ambient scheduler has a unique constraint: the user may not be actively grading cards. Most exposures are passive β€” the card appeared, the user may or may not have glanced at it. The system must infer engagement from indirect signals (hover time, click-through, idle time) rather than explicit grades.


The choice of scheduling algorithm matters less than you might think for an ambient system, because most card rotations are passive exposures without explicit grading. But you still need an algorithm for two reasons: (1) to prioritize which cards to show when, and (2) to handle the subset of interactions where the user does actively engage.

SM-2: The Classic

SM-2 was created by Piotr Wozniak in 1987 and remains the foundation of Anki’s scheduling (though Anki now defaults to FSRS). It is beautifully simple.

interface SM2Card {
  easeFactor: number;    // >= 1.3, default 2.5
  interval: number;      // Days until next review
  repetitions: number;   // Consecutive correct answers
}

function sm2(card: SM2Card, quality: number): SM2Card {
  // quality: 0-5 (0 = blackout, 5 = perfect)

  if (quality < 3) {
    // Failed: reset to beginning
    return {
      easeFactor: card.easeFactor,
      interval: 1,
      repetitions: 0,
    };
  }

  // Passed: advance
  let interval: number;
  if (card.repetitions === 0) {
    interval = 1;
  } else if (card.repetitions === 1) {
    interval = 6;
  } else {
    interval = Math.round(card.interval * card.easeFactor);
  }

  // Update ease factor
  const easeFactor = Math.max(
    1.3,
    card.easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
  );

  return {
    easeFactor,
    interval,
    repetitions: card.repetitions + 1,
  };
}

SM-2 pros for ambient use:

SM-2 cons for ambient use:

FSRS: The Modern Alternative

FSRS (Free Spaced Repetition Scheduler) is based on the DSR (Difficulty, Stability, Retrievability) model of memory. It uses 19 learned parameters to predict optimal review times with 30% less review time for equivalent retention compared to SM-2.

// FSRS core formulas
const DECAY = -0.5;
const FACTOR = 19 / 81;  // 0.2346

interface FSRSCard {
  difficulty: number;     // 1.0 - 10.0
  stability: number;      // Days for R to decay from 1.0 to 0.9
  retrievability: number; // Current recall probability
  lastReview: Date;
}

function retrievability(stability: number, elapsedDays: number): number {
  return Math.pow(1 + (FACTOR * elapsedDays) / stability, DECAY);
}

function nextInterval(stability: number, desiredRetention: number): number {
  return (stability / FACTOR) * (
    Math.pow(desiredRetention, 1 / DECAY) - 1
  );
}

// With default parameters at 90% desired retention:
// stability = 1 day  β†’ interval = 1.0 days
// stability = 5 days β†’ interval = 5.0 days
// stability = 30 days β†’ interval = 30.0 days

The key insight of FSRS is that stability measures how long a memory lasts, not when to review. The review interval is derived from stability and your desired retention rate. If you want 90% recall probability, the interval is exactly when retrievability decays to 0.9.

// FSRS default parameters (w0 through w18)
// These are trained on aggregate Anki data
const DEFAULT_WEIGHTS: number[] = [
  0.40255,   // w0: initial stability for Again
  1.18385,   // w1: initial stability for Hard
  3.173,     // w2: initial stability for Good
  15.69105,  // w3: initial stability for Easy
  7.1949,    // w4: difficulty mean reversion
  0.5345,    // w5: difficulty initial
  1.4604,    // w6: difficulty grade modifier
  0.0046,    // w7: difficulty grade modifier
  1.54575,   // w8: stability increase base
  0.1192,    // w9: stability increase difficulty penalty
  1.01925,   // w10: stability increase stability decay
  1.9395,    // w11: stability on failure base
  0.11,      // w12: stability on failure difficulty
  0.29605,   // w13: stability on failure stability
  2.2698,    // w14: stability on failure retrievability
  0.2315,    // w15: hard penalty
  2.9898,    // w16: easy bonus
  0.51655,   // w17: short-term stability decay
  0.6925,    // w18: short-term stability base
];

Key insight: For an ambient system, FSRS’s retrievability calculation is particularly valuable. You can show cards whose retrievability has decayed to 0.7-0.85 β€” they are not yet due for active review but benefit from a passive β€œrefresh.” This creates a natural tier: cards below 0.7 need active study; cards between 0.7 and 0.9 benefit from ambient reinforcement; cards above 0.9 can rest.

Recommendation: Start with SM-2, Migrate to FSRS

For an ambient SRS prototype, start with SM-2. Here is why:

  1. SM-2 is 20 lines of code. FSRS is 100+ lines with 19 parameters.
  2. Most ambient exposures are passive β€” the grading granularity of FSRS is wasted when you do not know if the user even looked at the card.
  3. SM-2’s simplicity makes it easier to debug scheduling behavior.
  4. If you want FSRS later, femto-fsrs gives you a zero-dependency TypeScript implementation in ~100 lines.
  5. The ts-fsrs package provides a full-featured implementation if you need parameter optimization.

The migration path is clean: both algorithms track intervals and some notion of difficulty. You can switch schedulers without losing card history.


Pattern 1: The Passive Ticker

When to use: Default ambient mode. Cards rotate at a fixed interval without user interaction.

The passive ticker is the simplest and most important pattern. A card appears in the notch area, stays for 30-60 seconds, then fades to the next card. No interaction required. The user absorbs vocabulary through repeated peripheral exposure.

import { EventEmitter } from 'events';

interface TickerCard {
  id: string;
  front: string;
  reading: string;
  meaning: string;
}

interface TickerConfig {
  intervalMs: number;        // Time between rotations (default: 30000)
  poolSize: number;          // Cards to pre-fetch (default: 20)
  pauseOnIdle: boolean;      // Stop rotating when user is idle
  pauseOnFullscreen: boolean;
  maxExposuresPerCard: number; // Per day (default: 5)
}

class PassiveTicker extends EventEmitter {
  private cards: TickerCard[] = [];
  private currentIndex: number = 0;
  private timer: ReturnType<typeof setInterval> | null = null;
  private exposureCounts: Map<string, number> = new Map();
  private config: TickerConfig;

  constructor(
    private scheduler: AmbientScheduler,
    config: Partial<TickerConfig> = {}
  ) {
    super();
    this.config = {
      intervalMs: 30_000,
      poolSize: 20,
      pauseOnIdle: false,
      pauseOnFullscreen: true,
      maxExposuresPerCard: 5,
      ...config,
    };
  }

  async start(): Promise<void> {
    this.cards = await this.scheduler.getCardPool(this.config.poolSize);
    this.showCard(this.cards[0]);

    this.timer = setInterval(() => {
      this.advance();
    }, this.config.intervalMs);
  }

  stop(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  private advance(): void {
    this.currentIndex++;

    if (this.currentIndex >= this.cards.length) {
      // Refresh pool from scheduler
      this.cards = this.scheduler.getCardPool(this.config.poolSize);
      this.currentIndex = 0;
    }

    const card = this.cards[this.currentIndex];
    const todayCount = this.exposureCounts.get(card.id) ?? 0;

    if (todayCount >= this.config.maxExposuresPerCard) {
      // Skip over-exposed cards
      this.advance();
      return;
    }

    this.exposureCounts.set(card.id, todayCount + 1);
    this.scheduler.recordExposure(card.id, this.config.intervalMs / 1000);
    this.showCard(card);
  }

  private showCard(card: TickerCard): void {
    this.emit('card', {
      front: card.front,
      reading: card.reading,
      meaning: card.meaning,
      id: card.id,
    });
  }

  pause(): void {
    this.stop();
    this.emit('paused');
  }

  resume(): void {
    if (!this.timer) {
      this.timer = setInterval(() => this.advance(), this.config.intervalMs);
      this.emit('resumed');
    }
  }
}

Gotchas:

Connection to other patterns: The Passive Ticker feeds into the Engagement Tracker (Pattern 3) which infers whether the user is actually looking at the cards.


Pattern 2: The Retrievability Triage

When to use: When you want to intelligently select which cards to show based on their current memory state.

Not all cards benefit equally from ambient exposure. A card you just reviewed 10 minutes ago (retrievability ~1.0) gains nothing from being shown again. A card that has completely decayed (retrievability ~0.1) needs active study, not a passive glance. The sweet spot for ambient reinforcement is the middle zone.

interface RetrievabilityBand {
  name: string;
  min: number;
  max: number;
  action: 'rest' | 'ambient' | 'active' | 'relearn';
  weight: number;  // Selection probability weight
}

const BANDS: RetrievabilityBand[] = [
  { name: 'fresh',     min: 0.95, max: 1.0,  action: 'rest',    weight: 0 },
  { name: 'strong',    min: 0.85, max: 0.95, action: 'rest',    weight: 1 },
  { name: 'ambient',   min: 0.70, max: 0.85, action: 'ambient', weight: 5 },
  { name: 'fading',    min: 0.50, max: 0.70, action: 'ambient', weight: 3 },
  { name: 'weak',      min: 0.30, max: 0.50, action: 'active',  weight: 1 },
  { name: 'forgotten', min: 0.00, max: 0.30, action: 'relearn', weight: 0 },
];

function triageCards(cards: AmbientCard[], now: number): Map<string, AmbientCard[]> {
  const DECAY = -0.5;
  const FACTOR = 19 / 81;

  const buckets = new Map<string, AmbientCard[]>();
  for (const band of BANDS) {
    buckets.set(band.name, []);
  }

  for (const card of cards) {
    const elapsedDays = (now - card.srs.lastReview) / (1000 * 60 * 60 * 24);
    const R = Math.pow(
      1 + (FACTOR * elapsedDays) / card.srs.stability,
      DECAY
    );

    for (const band of BANDS) {
      if (R >= band.min && R < band.max) {
        buckets.get(band.name)!.push({ ...card, srs: { ...card.srs, retrievability: R } });
        break;
      }
    }
  }

  return buckets;
}

function selectAmbientCard(buckets: Map<string, AmbientCard[]>): AmbientCard | null {
  // Weighted random selection from ambient-eligible bands
  const candidates: { card: AmbientCard; weight: number }[] = [];

  for (const band of BANDS) {
    if (band.action !== 'ambient') continue;
    const cards = buckets.get(band.name) ?? [];
    for (const card of cards) {
      candidates.push({ card, weight: band.weight });
    }
  }

  if (candidates.length === 0) return null;

  const totalWeight = candidates.reduce((sum, c) => sum + c.weight, 0);
  let random = Math.random() * totalWeight;

  for (const { card, weight } of candidates) {
    random -= weight;
    if (random <= 0) return card;
  }

  return candidates[candidates.length - 1].card;
}

Key insight: The retrievability band between 0.70 and 0.85 is the β€œambient sweet spot.” These cards are strong enough that a passive glance can reinforce them, but weak enough that they benefit from the exposure. Cards below 0.50 are too far gone for passive reinforcement β€” they need active recall practice.

Gotchas:


Pattern 3: The Engagement Tracker

When to use: When you want to infer whether the user actually looked at an ambient card, without requiring explicit interaction.

This is the hardest problem in ambient SRS. In traditional Anki, the user presses a button (Again/Hard/Good/Easy) after each card. In an ambient system, the card just sits there. Did the user see it? Did they read it? Did they understand it?

We cannot know for certain, but we can make reasonable inferences from indirect signals.

interface EngagementSignal {
  type: 'cursor_near' | 'cursor_hover' | 'click' | 'dwell' | 'idle_glance' | 'active_dismiss';
  timestamp: number;
  duration?: number;
  cardId: string;
}

interface EngagementScore {
  likely_seen: boolean;
  attention_level: 'none' | 'peripheral' | 'glance' | 'focus' | 'interaction';
  confidence: number;       // 0-1
  impliedGrade?: Grade;     // Only set for 'interaction' level
}

class EngagementTracker {
  private signals: EngagementSignal[] = [];
  private mouseNearNotch: boolean = false;
  private userIdleSeconds: number = 0;

  // Notch area bounds (approximate for 14" MacBook Pro)
  private readonly NOTCH_REGION = {
    x: { min: 1200, max: 1824 },  // Center ~624px around notch
    y: { min: 0, max: 74 },        // Notch height: 74px
  };

  recordMousePosition(x: number, y: number, cardId: string): void {
    const nearNotch = (
      x >= this.NOTCH_REGION.x.min && x <= this.NOTCH_REGION.x.max &&
      y >= this.NOTCH_REGION.y.min && y <= this.NOTCH_REGION.y.max + 50 // 50px buffer
    );

    if (nearNotch && !this.mouseNearNotch) {
      this.signals.push({
        type: 'cursor_near',
        timestamp: Date.now(),
        cardId,
      });
    }

    this.mouseNearNotch = nearNotch;
  }

  recordIdleTime(seconds: number): void {
    this.userIdleSeconds = seconds;
  }

  scoreEngagement(cardId: string, displayDuration: number): EngagementScore {
    const cardSignals = this.signals.filter(s => s.cardId === cardId);

    // No signals at all
    if (cardSignals.length === 0) {
      // But if user was idle (not typing/moving mouse), they might have been
      // looking at the screen. Brief idle periods suggest natural glances.
      if (this.userIdleSeconds > 2 && this.userIdleSeconds < 30) {
        return {
          likely_seen: true,
          attention_level: 'peripheral',
          confidence: 0.3,
        };
      }
      return {
        likely_seen: false,
        attention_level: 'none',
        confidence: 0.8,
      };
    }

    // Check for explicit interaction
    const clicks = cardSignals.filter(s => s.type === 'click');
    if (clicks.length > 0) {
      return {
        likely_seen: true,
        attention_level: 'interaction',
        confidence: 1.0,
        impliedGrade: Grade.Good,
      };
    }

    // Check for hover (cursor stayed in notch area)
    const hovers = cardSignals.filter(s => s.type === 'cursor_hover');
    if (hovers.length > 0) {
      const totalHoverMs = hovers.reduce((sum, s) => sum + (s.duration ?? 0), 0);
      if (totalHoverMs > 2000) {
        return {
          likely_seen: true,
          attention_level: 'focus',
          confidence: 0.9,
        };
      }
      return {
        likely_seen: true,
        attention_level: 'glance',
        confidence: 0.7,
      };
    }

    // Cursor was near but didn't hover
    return {
      likely_seen: true,
      attention_level: 'peripheral',
      confidence: 0.4,
    };
  }

  clearSignals(): void {
    this.signals = [];
  }
}

Gotchas:

Connection to other patterns: The Engagement Tracker’s scores feed back into the scheduler. Cards with consistently high engagement scores can have their passive exposure count toward extending the review interval. Cards with consistently low engagement should be prioritized for active review.


Pattern 4: The Anki Bridge

When to use: When you want to sync ambient exposure data with an existing Anki collection, treating the ambient system as a supplement to regular Anki study.

Most Japanese learners already have an Anki collection. The ambient system should work with it, not replace it.

import Database from 'better-sqlite3';
import { createReadStream } from 'fs';
import { createGunzip } from 'zlib';
import * as unzipper from 'unzipper';
import { join } from 'path';

interface AnkiNote {
  id: number;
  mid: number;           // Model (note type) ID
  fields: string[];      // Field values, separated by \x1f in DB
  tags: string;
}

interface AnkiCard {
  id: number;
  nid: number;           // Note ID
  did: number;           // Deck ID
  due: number;
  ivl: number;           // Current interval (days, negative = seconds)
  factor: number;        // Ease factor (per mille, e.g., 2500 = 2.5)
  reps: number;          // Total reviews
  lapses: number;        // Total failures
  queue: number;         // 0=new, 1=learning, 2=review, 3=relearning
}

async function readApkgFile(apkgPath: string): Promise<{
  notes: AnkiNote[];
  cards: AnkiCard[];
}> {
  // .apkg is a zip file containing collection.anki2 (SQLite DB)
  const tempDir = join('/tmp', `anki-${Date.now()}`);

  await new Promise<void>((resolve, reject) => {
    createReadStream(apkgPath)
      .pipe(unzipper.Extract({ path: tempDir }))
      .on('close', resolve)
      .on('error', reject);
  });

  // Open the SQLite database
  // Modern Anki uses collection.anki21b (zstd compressed, protobuf)
  // Older format uses collection.anki2 or collection.anki21
  const dbPath = join(tempDir, 'collection.anki21')
    || join(tempDir, 'collection.anki2');

  const db = new Database(dbPath, { readonly: true });

  const notes: AnkiNote[] = db
    .prepare('SELECT id, mid, flds, tags FROM notes')
    .all()
    .map((row: any) => ({
      id: row.id,
      mid: row.mid,
      fields: row.flds.split('\x1f'),
      tags: row.tags,
    }));

  const cards: AnkiCard[] = db
    .prepare('SELECT id, nid, did, due, ivl, factor, reps, lapses, queue FROM cards')
    .all()
    .map((row: any) => ({
      id: row.id,
      nid: row.nid,
      did: row.did,
      due: row.due,
      ivl: row.ivl,
      factor: row.factor / 1000, // Convert from per mille
      reps: row.reps,
      lapses: row.lapses,
      queue: row.queue,
    }));

  db.close();
  return { notes, cards };
}

function ankiCardToAmbientCard(
  note: AnkiNote,
  card: AnkiCard,
  fieldMapping: { front: number; reading: number; meaning: number }
): AmbientCard {
  return {
    id: `anki-${card.id}`,
    front: note.fields[fieldMapping.front] ?? '',
    reading: note.fields[fieldMapping.reading] ?? '',
    meaning: note.fields[fieldMapping.meaning] ?? '',
    level: inferJLPTLevel(note.tags),
    tags: note.tags.trim().split(/\s+/),
    srs: {
      difficulty: mapEaseFactorToDifficulty(card.factor),
      stability: card.ivl,
      retrievability: 0, // Will be calculated
      lastReview: 0,     // Would need revlog for this
      nextReview: 0,
      interval: card.ivl,
      repetitions: card.reps,
      lapses: card.lapses,
    },
    exposureCount: 0,
    lastGlancedAt: 0,
    acknowledged: false,
  };
}

function mapEaseFactorToDifficulty(easeFactor: number): number {
  // Anki ease factor: 1.3 (hard) to 3.0+ (easy)
  // FSRS difficulty: 1.0 (easy) to 10.0 (hard)
  // Inverse relationship
  return Math.max(1, Math.min(10, 11 - (easeFactor * 3)));
}

function inferJLPTLevel(tags: string): JLPTLevel {
  const lower = tags.toLowerCase();
  if (lower.includes('n5') || lower.includes('jlpt5')) return 'N5';
  if (lower.includes('n4') || lower.includes('jlpt4')) return 'N4';
  if (lower.includes('n3') || lower.includes('jlpt3')) return 'N3';
  if (lower.includes('n2') || lower.includes('jlpt2')) return 'N2';
  if (lower.includes('n1') || lower.includes('jlpt1')) return 'N1';
  return 'N5'; // Default to easiest level
}

Key insight: The .apkg format is just a zip file containing a SQLite database. The database schema is well-documented by the AnkiDroid wiki. Fields are separated by \x1f (unit separator). The tricky part is not reading the data β€” it is mapping note types to the front/reading/meaning fields, since every Anki user structures their decks differently.

Gotchas:


Pattern 5: The Context-Aware Pauser

When to use: Always. An ambient system that displays flashcards during a Zoom presentation or screen share is worse than useless β€” it is embarrassing.

import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

interface ContextState {
  shouldPause: boolean;
  reason?: string;
}

class ContextAwarePauser {
  private checkIntervalMs: number = 5000;
  private timer: ReturnType<typeof setInterval> | null = null;
  private onPause: () => void;
  private onResume: () => void;
  private isPaused: boolean = false;

  constructor(onPause: () => void, onResume: () => void) {
    this.onPause = onPause;
    this.onResume = onResume;
  }

  start(): void {
    this.timer = setInterval(() => this.check(), this.checkIntervalMs);
  }

  stop(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  private async check(): Promise<void> {
    const state = await this.getContextState();

    if (state.shouldPause && !this.isPaused) {
      this.isPaused = true;
      this.onPause();
    } else if (!state.shouldPause && this.isPaused) {
      this.isPaused = false;
      this.onResume();
    }
  }

  private async getContextState(): Promise<ContextState> {
    // Check if screen is being shared/recorded
    const isScreenSharing = await this.checkScreenSharing();
    if (isScreenSharing) {
      return { shouldPause: true, reason: 'screen_sharing' };
    }

    // Check if a fullscreen app is active
    const isFullscreen = await this.checkFullscreen();
    if (isFullscreen) {
      return { shouldPause: true, reason: 'fullscreen' };
    }

    // Check if Do Not Disturb / Focus is enabled
    const isDND = await this.checkDoNotDisturb();
    if (isDND) {
      return { shouldPause: true, reason: 'do_not_disturb' };
    }

    return { shouldPause: false };
  }

  private async checkScreenSharing(): Promise<boolean> {
    try {
      // Check for common screen sharing processes
      const { stdout } = await execAsync(
        'pgrep -x "screencapture\\|ScreenSharingAgent\\|CaptiveNetworkHelper" 2>/dev/null || true'
      );
      if (stdout.trim()) return true;

      // More reliable: check CGDisplayStream / SCC framework
      // This requires native code (Swift/ObjC) for proper detection
      const { stdout: sharing } = await execAsync(
        `osascript -e 'tell application "System Events" to get name of every process whose background only is false' 2>/dev/null || true`
      );

      const sharingApps = ['zoom.us', 'Slack', 'Microsoft Teams', 'Discord', 'Google Chrome'];
      // Note: this is a heuristic. Proper detection requires
      // CGDisplayStreamCreate monitoring or SCC framework access.

      return false; // Placeholder β€” real implementation needs native code
    } catch {
      return false;
    }
  }

  private async checkFullscreen(): Promise<boolean> {
    try {
      const { stdout } = await execAsync(
        `osascript -e '
          tell application "System Events"
            set frontApp to first application process whose frontmost is true
            tell frontApp
              set appWindows to every window
              repeat with w in appWindows
                try
                  if value of attribute "AXFullScreen" of w is true then
                    return "true"
                  end if
                end try
              end repeat
            end tell
          end tell
          return "false"
        '`
      );
      return stdout.trim() === 'true';
    } catch {
      return false;
    }
  }

  private async checkDoNotDisturb(): Promise<boolean> {
    try {
      const { stdout } = await execAsync(
        `defaults read com.apple.controlcenter "NSStatusItem Visible FocusModes" 2>/dev/null || echo "0"`
      );
      return stdout.trim() === '1';
    } catch {
      return false;
    }
  }
}

Gotchas:


Pattern 6: The Notch Layout Engine

When to use: When rendering flashcard content in the constrained space around the MacBook notch.

The MacBook notch is 74 pixels high (at native resolution). The menu bar to the left and right of the notch is the same height. On a 14” MacBook Pro with a 3024x1964 native display running at the default scaled resolution, the effective menu bar height is approximately 37 points (74px / 2x Retina).

This is tiny. But it is enough.

// Layout calculations for notch-area display
interface NotchGeometry {
  screenWidth: number;       // Points (not pixels)
  notchWidth: number;        // ~194 points on 14"
  notchHeight: number;       // ~37 points (74px / 2x)
  menuBarHeight: number;     // Same as notch height
  leftRegion: Region;        // Left of notch
  rightRegion: Region;       // Right of notch
  belowNotch: Region;        // The thin strip below the camera
}

interface Region {
  x: number;
  y: number;
  width: number;
  height: number;
}

function calculateNotchGeometry(screenWidth: number): NotchGeometry {
  // 14" MacBook Pro at default scaling
  const notchWidth = 194;     // Points
  const notchHeight = 37;     // Points
  const menuBarHeight = 37;

  const notchLeft = (screenWidth - notchWidth) / 2;
  const notchRight = notchLeft + notchWidth;

  return {
    screenWidth,
    notchWidth,
    notchHeight,
    menuBarHeight,
    leftRegion: {
      x: 0,
      y: 0,
      width: notchLeft,
      height: menuBarHeight,
    },
    rightRegion: {
      x: notchRight,
      y: 0,
      width: screenWidth - notchRight,
      height: menuBarHeight,
    },
    belowNotch: {
      x: notchLeft,
      y: notchHeight - 10, // The ~5pt strip below the actual camera
      width: notchWidth,
      height: 10,
    },
  };
}

// Card layout for the right menu bar region
interface CardLayout {
  frontRect: { x: number; y: number; width: number; height: number };
  readingRect: { x: number; y: number; width: number; height: number };
  meaningRect: { x: number; y: number; width: number; height: number };
  totalWidth: number;
}

function layoutCard(
  front: string,
  reading: string,
  meaning: string,
  availableHeight: number
): CardLayout {
  // For a 37pt menu bar:
  // - Front (kanji): 18pt font, vertically centered
  // - Reading (kana): 9pt font, above the front
  // - Meaning (english): 9pt font, below the front or to the right

  const frontSize = 18;
  const metaSize = 9;
  const padding = 4;

  // Estimate widths (CJK chars are ~1em wide, latin ~0.6em)
  const frontWidth = front.length * frontSize;
  const readingWidth = reading.length * metaSize * 0.7;
  const meaningWidth = meaning.length * metaSize * 0.55;

  // Layout option: horizontal stack
  // [ι£ŸγΉγ‚‹] [γŸγΉγ‚‹] [to eat]
  const totalWidth = frontWidth + padding + readingWidth + padding + meaningWidth;

  const baseY = (availableHeight - frontSize) / 2;

  return {
    frontRect: {
      x: 0,
      y: baseY,
      width: frontWidth,
      height: frontSize,
    },
    readingRect: {
      x: frontWidth + padding,
      y: baseY + (frontSize - metaSize) / 2,
      width: readingWidth,
      height: metaSize,
    },
    meaningRect: {
      x: frontWidth + padding + readingWidth + padding,
      y: baseY + (frontSize - metaSize) / 2,
      width: meaningWidth,
      height: metaSize,
    },
    totalWidth,
  };
}

Key insight: You have about 37 points of vertical space and potentially hundreds of points of horizontal space in the right menu bar region. The constraint is vertical, not horizontal. Use a single-line horizontal layout: [ζΌ’ε­—] [γ‹γ‚“γ˜] [kanji] rather than trying to stack elements vertically.


Example 1: Minimal SM-2 in 15 Lines

The absolute simplest SRS you can implement. Good enough for a prototype.

function sm2(interval: number, ease: number, reps: number, quality: number) {
  if (quality < 3) return { interval: 1, ease, reps: 0 };
  const newEase = Math.max(1.3, ease + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
  const newInterval = reps === 0 ? 1 : reps === 1 ? 6 : Math.round(interval * newEase);
  return { interval: newInterval, ease: newEase, reps: reps + 1 };
}

// Usage
let card = { interval: 0, ease: 2.5, reps: 0 };
card = sm2(card.interval, card.ease, card.reps, 4); // Good answer
// β†’ { interval: 1, ease: 2.5, reps: 1 }
card = sm2(card.interval, card.ease, card.reps, 4);
// β†’ { interval: 6, ease: 2.5, reps: 2 }
card = sm2(card.interval, card.ease, card.reps, 3); // Hard answer
// β†’ { interval: 15, ease: 2.36, reps: 3 }
card = sm2(card.interval, card.ease, card.reps, 1); // Forgot
// β†’ { interval: 1, ease: 2.36, reps: 0 }

Example 2: FSRS Retrievability Decay Curve

Visualize when a card should be shown based on its stability.

const DECAY = -0.5;
const FACTOR = 19 / 81;

function retrievability(stability: number, elapsedDays: number): number {
  return Math.pow(1 + (FACTOR * elapsedDays) / stability, DECAY);
}

function daysUntilRetention(stability: number, targetR: number): number {
  return (stability / FACTOR) * (Math.pow(targetR, 1 / DECAY) - 1);
}

// A card with stability = 10 days
const S = 10;
console.log('Days β†’ Retrievability:');
for (const d of [0, 1, 3, 5, 7, 10, 14, 21, 30]) {
  const R = retrievability(S, d);
  const bar = 'β–ˆ'.repeat(Math.round(R * 40));
  console.log(`  ${String(d).padStart(2)}d: ${R.toFixed(3)} ${bar}`);
}
// Output:
//  0d: 1.000 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
//  1d: 0.988 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
//  3d: 0.964 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
//  5d: 0.940 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
//  7d: 0.917 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
// 10d: 0.882 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
// 14d: 0.839 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
// 21d: 0.775 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
// 30d: 0.706 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

console.log(`\nReview due at R=0.9: ${daysUntilRetention(S, 0.9).toFixed(1)} days`);
// β†’ Review due at R=0.9: 8.5 days

Example 3: Parse a JLPT N5 Word List

Load vocabulary from the open jlpt-word-list dataset.

interface JLPTWord {
  word: string;
  reading: string;
  meaning: string;
  level: JLPTLevel;
}

async function loadJLPTWords(level: JLPTLevel): Promise<JLPTWord[]> {
  const url = `https://raw.githubusercontent.com/jamsinclair/open-anki-jlpt-decks/main/src/${level.toLowerCase()}.csv`;
  const response = await fetch(url);
  const text = await response.text();

  return text
    .split('\n')
    .slice(1) // Skip header
    .filter(line => line.trim())
    .map(line => {
      const [word, reading, meaning] = line.split(',').map(s => s.trim().replace(/^"|"$/g, ''));
      return { word, reading, meaning, level };
    });
}

// Usage
const n5Words = await loadJLPTWords('N5');
console.log(`Loaded ${n5Words.length} N5 words`);
console.log(n5Words.slice(0, 5));
// [
//   { word: 'δΌšγ†', reading: 'あう', meaning: 'to meet', level: 'N5' },
//   { word: '青い', reading: 'γ‚γŠγ„', meaning: 'blue', level: 'N5' },
//   { word: '血い', reading: 'あかい', meaning: 'red', level: 'N5' },
//   ...
// ]

Example 4: macOS Menu Bar Status Item (Swift)

The native way to put content in the menu bar, rendered with SwiftUI.

import SwiftUI
import AppKit

@main
struct AmbientSRSApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Settings { EmptyView() }
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusItem: NSStatusItem!
    var timer: Timer?

    func applicationDidFinishLaunching(_ notification: Notification) {
        // Create a variable-width status item
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

        // Use a custom SwiftUI view for the button
        let hostingView = NSHostingView(rootView: FlashcardView(
            kanji: "ι£ŸγΉγ‚‹",
            reading: "γŸγΉγ‚‹",
            meaning: "to eat"
        ))
        hostingView.frame = NSRect(x: 0, y: 0, width: 160, height: 24)

        statusItem.button?.addSubview(hostingView)
        statusItem.button?.frame = hostingView.frame

        // Rotate every 30 seconds
        timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in
            self.rotateCard()
        }
    }

    func rotateCard() {
        // Fetch next card from scheduler and update the view
    }
}

struct FlashcardView: View {
    let kanji: String
    let reading: String
    let meaning: String

    var body: some View {
        HStack(spacing: 6) {
            Text(kanji)
                .font(.system(size: 16, weight: .medium))
            Text(reading)
                .font(.system(size: 10))
                .foregroundColor(.secondary)
            Text(meaning)
                .font(.system(size: 10))
                .foregroundColor(.secondary)
        }
        .padding(.horizontal, 4)
    }
}

Example 5: Card Selection with Weighted Sampling

Avoid showing the same cards repeatedly by using weighted random selection.

interface WeightedCard {
  card: AmbientCard;
  weight: number;
}

function weightCards(cards: AmbientCard[], now: number): WeightedCard[] {
  return cards.map(card => {
    let weight = 1.0;

    // Boost cards that are due for review
    const daysSinceReview = (now - card.srs.lastReview) / (86400 * 1000);
    const overdueFactor = daysSinceReview / card.srs.interval;
    if (overdueFactor > 1.0) {
      weight *= Math.min(overdueFactor, 3.0); // Cap at 3x boost
    }

    // Penalize cards shown recently
    const hoursSinceExposure = (now - card.lastGlancedAt) / (3600 * 1000);
    if (hoursSinceExposure < 1) {
      weight *= 0.1; // Heavy penalty for cards shown in last hour
    } else if (hoursSinceExposure < 4) {
      weight *= 0.5;
    }

    // Penalize over-exposed cards
    if (card.exposureCount > 10) {
      weight *= 0.3;
    }

    // Boost new cards slightly (introduction)
    if (card.srs.repetitions === 0) {
      weight *= 1.5;
    }

    return { card, weight };
  });
}

function sampleWeighted(weighted: WeightedCard[], count: number): AmbientCard[] {
  const result: AmbientCard[] = [];
  const pool = [...weighted];

  for (let i = 0; i < count && pool.length > 0; i++) {
    const totalWeight = pool.reduce((sum, w) => sum + w.weight, 0);
    let random = Math.random() * totalWeight;

    for (let j = 0; j < pool.length; j++) {
      random -= pool[j].weight;
      if (random <= 0) {
        result.push(pool[j].card);
        pool.splice(j, 1);
        break;
      }
    }
  }

  return result;
}

Example 6: Furigana Renderer for Constrained Space

Render kanji with small furigana (reading) above it, even in tight vertical space.

interface FuriganaSegment {
  base: string;          // Kanji or kana
  reading: string | null; // Furigana (null for kana-only segments)
}

function parseFurigana(word: string, reading: string): FuriganaSegment[] {
  // Simple heuristic: if word and reading differ, the kanji parts need furigana
  // For production, use a morphological analyzer like kuromoji

  if (word === reading) {
    return [{ base: word, reading: null }];
  }

  // Check if word is all kanji
  const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  if (!kanjiRegex.test(word)) {
    return [{ base: word, reading: null }];
  }

  // Simple case: entire word is kanji with a kana suffix
  // e.g., ι£ŸγΉγ‚‹ (γŸγΉγ‚‹) β†’ [食べ|たべ] + [γ‚‹|null]
  let kanjiEnd = word.length;
  let readingEnd = reading.length;

  // Match trailing kana
  while (kanjiEnd > 0 && readingEnd > 0) {
    if (word[kanjiEnd - 1] === reading[readingEnd - 1]) {
      kanjiEnd--;
      readingEnd--;
    } else {
      break;
    }
  }

  const segments: FuriganaSegment[] = [];

  if (kanjiEnd > 0) {
    segments.push({
      base: word.slice(0, kanjiEnd),
      reading: reading.slice(0, readingEnd),
    });
  }

  if (kanjiEnd < word.length) {
    segments.push({
      base: word.slice(kanjiEnd),
      reading: null,
    });
  }

  return segments;
}

// Usage
console.log(parseFurigana('ι£ŸγΉγ‚‹', 'γŸγΉγ‚‹'));
// β†’ [{ base: '食', reading: 'た' }, { base: 'べる', reading: null }]

console.log(parseFurigana('ε­¦ζ ‘', 'γŒγ£γ“γ†'));
// β†’ [{ base: 'ε­¦ζ ‘', reading: 'γŒγ£γ“γ†' }]

// For rendering at small sizes, skip furigana if height < 28pt
// and show reading inline instead: ι£ŸγΉγ‚‹ (γŸγΉγ‚‹)
function formatForHeight(
  segments: FuriganaSegment[],
  availableHeight: number
): { format: 'furigana' | 'inline'; text: string } {
  if (availableHeight >= 28) {
    return { format: 'furigana', text: '' }; // Use ruby rendering
  }

  // Inline format for constrained height
  const base = segments.map(s => s.base).join('');
  const readings = segments.filter(s => s.reading).map(s => s.reading);

  if (readings.length === 0) {
    return { format: 'inline', text: base };
  }

  return { format: 'inline', text: `${base} (${readings.join('')})` };
}

Example 7: SQLite Storage for Card State

Persist card state locally using SQLite β€” lightweight enough for a menu bar app.

import Database from 'better-sqlite3';

function initDatabase(dbPath: string): Database.Database {
  const db = new Database(dbPath);

  db.exec(`
    CREATE TABLE IF NOT EXISTS cards (
      id TEXT PRIMARY KEY,
      front TEXT NOT NULL,
      reading TEXT NOT NULL,
      meaning TEXT NOT NULL,
      level TEXT NOT NULL,
      tags TEXT DEFAULT '[]',
      difficulty REAL DEFAULT 5.0,
      stability REAL DEFAULT 1.0,
      retrievability REAL DEFAULT 1.0,
      last_review INTEGER DEFAULT 0,
      next_review INTEGER DEFAULT 0,
      interval_days REAL DEFAULT 0,
      repetitions INTEGER DEFAULT 0,
      lapses INTEGER DEFAULT 0,
      exposure_count INTEGER DEFAULT 0,
      last_glanced_at INTEGER DEFAULT 0,
      created_at INTEGER DEFAULT (unixepoch())
    );

    CREATE INDEX IF NOT EXISTS idx_next_review ON cards(next_review);
    CREATE INDEX IF NOT EXISTS idx_level ON cards(level);

    CREATE TABLE IF NOT EXISTS exposures (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      card_id TEXT NOT NULL REFERENCES cards(id),
      timestamp INTEGER NOT NULL,
      duration_ms INTEGER,
      engagement TEXT DEFAULT 'none',
      FOREIGN KEY (card_id) REFERENCES cards(id)
    );

    CREATE INDEX IF NOT EXISTS idx_exposures_card ON exposures(card_id);
  `);

  return db;
}

function getDueCards(db: Database.Database, limit: number = 20): AmbientCard[] {
  const now = Math.floor(Date.now() / 1000);

  const rows = db.prepare(`
    SELECT * FROM cards
    WHERE next_review <= ?
    ORDER BY next_review ASC
    LIMIT ?
  `).all(now, limit);

  return rows.map(rowToCard);
}

function recordExposure(
  db: Database.Database,
  cardId: string,
  durationMs: number,
  engagement: string
): void {
  const now = Math.floor(Date.now() / 1000);

  db.prepare(`
    INSERT INTO exposures (card_id, timestamp, duration_ms, engagement)
    VALUES (?, ?, ?, ?)
  `).run(cardId, now, durationMs, engagement);

  db.prepare(`
    UPDATE cards
    SET exposure_count = exposure_count + 1, last_glanced_at = ?
    WHERE id = ?
  `).run(now, cardId);
}

function rowToCard(row: any): AmbientCard {
  return {
    id: row.id,
    front: row.front,
    reading: row.reading,
    meaning: row.meaning,
    level: row.level as JLPTLevel,
    tags: JSON.parse(row.tags),
    srs: {
      difficulty: row.difficulty,
      stability: row.stability,
      retrievability: row.retrievability,
      lastReview: row.last_review,
      nextReview: row.next_review,
      interval: row.interval_days,
      repetitions: row.repetitions,
      lapses: row.lapses,
    },
    exposureCount: row.exposure_count,
    lastGlancedAt: row.last_glanced_at,
    acknowledged: false,
  };
}

Example 8: Notification-Based Flashcard Delivery

Use macOS native notifications as an alternative delivery mechanism β€” works on Macs without notches too.

import { exec } from 'child_process';

function sendFlashcardNotification(card: AmbientCard): void {
  const title = card.front;
  const subtitle = card.reading;
  const body = card.meaning;

  // Using osascript for native macOS notifications
  const script = `
    display notification "${body}" with title "${title}" subtitle "${subtitle}"
  `;

  exec(`osascript -e '${script}'`);
}

// Alternative: Use terminal-notifier for richer notifications
function sendRichNotification(card: AmbientCard): void {
  const cmd = [
    'terminal-notifier',
    `-title "${card.front}"`,
    `-subtitle "${card.reading}"`,
    `-message "${card.meaning}"`,
    `-group "ambient-srs"`,     // Replace previous notification
    `-timeout 30`,               // Auto-dismiss after 30s
    `-sound default`,
    `-appIcon /path/to/icon.png`,
  ].join(' ');

  exec(cmd);
}

// Schedule periodic notifications
class NotificationScheduler {
  private timer: ReturnType<typeof setInterval> | null = null;

  start(scheduler: AmbientScheduler, intervalMinutes: number = 5): void {
    this.timer = setInterval(() => {
      const card = scheduler.getNextCard();
      if (card) {
        sendFlashcardNotification(card);
        scheduler.recordExposure(card.id, 5); // Assume 5s glance
      }
    }, intervalMinutes * 60 * 1000);
  }

  stop(): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }
}

JLPT Word Lists

The Japanese Language Proficiency Test (JLPT) organizes vocabulary into five levels (N5 easiest to N1 hardest). These are the most structured and widely-used vocabulary lists for Japanese learners.

SourceFormatCoverageLicenseURL
open-anki-jlpt-decksCSV, .apkgN5-N1MITBest for import β€” clean CSV with word, reading, meaning
jlpt-word-listJSONN5-N1MITOrganized by level, includes word frequency data
jlpt-kanji-dictionaryJSONN5-N1 kanji + vocabMITRich structured data with EN + RU translations
JLPT_VocabularyJSON, CSVN5-N1MITCleaned data from tanos.co.uk
tanos.co.ukHTML, MP3N5-N1Fair useOriginal source, includes audio files
Kaggle JLPT datasetCSVN5-N1CC0Clean tabular format, good for data analysis

Recommendation: Start with open-anki-jlpt-decks. The CSV format is trivial to parse, the data is clean, and you get all five JLPT levels. Begin with N5 (800 words) and N4 (1,500 words) for a reasonable starting pool.

Kanji Datasets

SourceFormatCoverageURL
kanji-dataJSON2,136 joyo kanjiExtended with JLPT levels + WaniKani info
yomitan-jlpt-vocabYomitan formatN5-N1JLPT level tags for Yomitan dictionary

Anki Shared Decks

The Anki ecosystem has thousands of Japanese decks. The most popular ones for ambient use:

To import these, use the Anki Bridge pattern (Pattern 4) to extract the SQLite data from .apkg files.

WaniKani API

WaniKani is a commercial kanji learning service, but it has a public API that returns your study data. If you are already a WaniKani subscriber, you can pull your current SRS state and use it to seed the ambient system.

For open-source alternatives, KaniManabu is an open-source WaniKani-style SRS for macOS that uses its own kanji database.


This is the hardest technical challenge for a notch-based flashcard display. Japanese characters are visually complex β€” a single kanji like 鬱 (depression) has 29 strokes that must be distinguishable at whatever size you choose.

The Minimum Viable Kanji Size

Research and practical experience establish clear minimums:

Size (px/pt)ReadabilityNotes
12pxIllegibleToo small for any CJK character
16px (16x16)RecognizableLegacy bitmap CJK fonts used this grid; only works for simple characters
20pxMarginalCommon kanji readable, complex kanji (e.g., θ–¬, θ­°) become ambiguous
24pxAcceptableThe practical minimum for ambient display. Most N5-N3 kanji are clear
32pxGoodComplex kanji distinguishable. Ideal if space permits

For comparison, the Latin character β€œA” is readable at 7x7 pixels. The kanji 艦 requires at minimum 15x15 pixels to be recognizable. On Retina displays (2x), 24pt renders at 48 physical pixels β€” comfortably above the minimum.

Key insight: At 24pt on a Retina display, you get 48 physical pixels per character. This is sufficient for N5 and N4 vocabulary (mostly common, relatively simple kanji). For N1 vocabulary with complex characters, you may need to increase to 28-32pt or use a popover for detail.

Font Selection

Font choice dramatically affects small-size readability. Not all Japanese fonts are created equal at 18-24pt.

FontPlatformSmall-size qualityNotes
Hiragino SansmacOS built-inExcellentThe macOS system font for Japanese. Optimized for screen display at all sizes. Multiple weights available. Best default choice.
Noto Sans JPCross-platformVery goodGoogle’s pan-language font. Excellent Unicode coverage. Slightly wider than Hiragino.
SF Pro + SF JapanesemacOS/iOSGoodApple’s system font handles Japanese through fallback. Less refined than Hiragino at small sizes.
Source Han SansCross-platformVery goodAdobe’s open-source CJK font. Essentially the same design as Noto Sans CJK.
MeiryoWindowsGoodMicrosoft’s screen-optimized CJK font. Uses TrueType hinting for stroke reduction at small sizes.
Ark Pixel FontPixel artSpecial-purposeA pixel font designed for CJK at exact pixel sizes. 12px, 16px variants. Great for pixel-art aesthetic.

Recommendation: Use Hiragino Sans W3 (weight 3, regular) as the primary font for the menu bar display. It is already installed on every Mac, is optimized for screen rendering, and handles small sizes better than any cross-platform alternative. Fall back to Noto Sans JP for non-macOS platforms.

Rendering Techniques

// SwiftUI rendering for menu bar β€” optimized for CJK readability
struct KanjiLabel: View {
    let text: String
    let size: CGFloat

    var body: some View {
        Text(text)
            // Use Hiragino Sans explicitly for consistent CJK rendering
            .font(.custom("HiraginoSans-W3", size: size))
            // Slightly increase tracking for better legibility
            .tracking(0.5)
            // Ensure crisp rendering on Retina
            .drawingGroup() // Rasterize to metal for sharp edges
    }
}

struct AmbientCardView: View {
    let kanji: String
    let reading: String
    let meaning: String

    // Menu bar height constraint: ~24pt usable
    var body: some View {
        HStack(spacing: 6) {
            Text(kanji)
                .font(.custom("HiraginoSans-W6", size: 16))
                .foregroundColor(.primary)

            Text(reading)
                .font(.custom("HiraginoSans-W3", size: 10))
                .foregroundColor(.secondary)

            Divider()
                .frame(height: 12)

            Text(meaning)
                .font(.system(size: 10, weight: .regular))
                .foregroundColor(.secondary)
                .lineLimit(1)
        }
        .padding(.horizontal, 6)
        .frame(height: 24) // Max menu bar item height
    }
}

Key rendering considerations:

  1. Use weight W6 (bold) for kanji at small sizes. The thicker strokes survive subpixel rendering better than W3 (regular). Save W3 for the reading/meaning text.

  2. Anti-aliasing mode matters. macOS uses subpixel anti-aliasing by default on non-Retina, and grayscale anti-aliasing on Retina. Both work well for CJK at 16pt+.

  3. Avoid font substitution chains. If you specify a Latin font and let the system fall back to a CJK font, you may get mismatched weights or metrics. Specify the CJK font explicitly.

  4. Dark mode is your friend. White text on a dark background (the default menu bar in dark mode) renders CJK characters more clearly than dark text on light, because the anti-aliased edges spread outward into the dark background rather than inward.


MacBook Notch Dimensions

The MacBook notch area provides specific geometry to work with:

ModelNative ResolutionNotch HeightMenu Bar HeightEffective Height (scaled)
MacBook Pro 14” (M1/M2/M3/M4)3024 x 196474px native74px native~37pt at default scaling
MacBook Pro 16” (M1/M2/M3/M4)3456 x 223474px native74px native~37pt at default scaling
MacBook Air 13” (M2/M3/M4)2560 x 166474px native74px native~37pt at default scaling
MacBook Air 15” (M2/M3/M4)2880 x 186474px native74px native~37pt at default scaling

The menu bar sits in the 74px-high strip that flanks the notch. The area to the right of the notch (where status items live) is your target display zone. On a 14” MacBook Pro, this region is approximately 900 points wide and 37 points tall at default scaling β€” enough for a comfortable flashcard layout.

Existing Notch Ecosystem

Several apps have proven the notch area is a viable display surface:

AppWhat it displaysOpen sourceURL
boring.notchMusic controls, visualizer, calendar, clipboard, batteryYes (GPL-3.0)Full-featured notch enhancement
NotchBarCPU, memory, network, battery, media playbackYesSystem monitoring widgets
NotchNookDynamic Island-style notifications, widgets, shortcutsNo (commercial)Most polished commercial option
DynamicNotchKitLibrary for adding notch content to any appYes (MIT)SwiftUI library for notch integration
TopNotchHides the notch with a black menu barNo (free)Aesthetic-only, no content display

Key insight: boring.notch proves that complex, animated SwiftUI content can be rendered in the notch area. If it can display a music visualizer, it can display a flashcard. Study its codebase β€” it handles the hard parts of notch geometry, mouse event handling, and system integration.

Display Approaches

There are three ways to put flashcard content in/around the notch:

Approach 1: NSStatusItem (Menu Bar Item)

The simplest and most compatible approach. Create a standard menu bar item that displays the current flashcard. Works on all Macs, notch or not.

Approach 2: Notch Overlay Window

Create a borderless, always-on-top window that overlays the notch area. This is what boring.notch and NotchNook use.

Approach 3: macOS Notifications

Use UNUserNotificationCenter to deliver periodic notification banners with flashcard content.

Approach 4: Desktop Widget (WidgetKit)

macOS Sonoma+ supports desktop widgets via WidgetKit. A flashcard widget on the desktop acts as persistent ambient display.


AppPlatformAmbient?SRS?Japanese?CostNotes
MemorizeWidgetiOS/macOSYes (widget)BasicYesFreemacOS 14+ notification center and desktop widget
Flash Cards WidgetiOSYes (lock screen)NoYesFree”Every time you check the time, a flashcard appears”
AlgoAppiOS/macOSPartialYesYesFree+IAPNative macOS app with SRS, but not menu-bar focused
Fresh CardsiOS/macOSNoYesYes$4.99Clean SRS app, no ambient mode
MochiCross-platformNoYesYesFree+ProMarkdown-based flashcards with SRS
AnkiCross-platformNoYes (SM-2/FSRS)YesFree (desktop)The standard. No ambient mode.
KaniManabumacOSNoYesYes (kanji)Free (OSS)WaniKani-style SRS for macOS, open source

The gap in the market is clear: no existing tool combines (1) ambient/passive display, (2) spaced repetition scheduling, (3) Japanese-optimized rendering, and (4) Anki compatibility. The closest is MemorizeWidget, but it is a widget, not a menu bar item, and its SRS implementation is basic.

What is Missing

Every existing tool requires you to open an app and sit down to study. The tools that are β€œambient” (widgets, lock screen cards) lack spaced repetition intelligence. The tools that have good SRS (Anki, WaniKani) lack ambient delivery. Nobody has combined them.

The opportunity is a thin display layer that:

  1. Reads cards from an existing SRS system (Anki, or a local SM-2/FSRS scheduler)
  2. Renders them in the menu bar / notch area / as notifications
  3. Tracks passive exposure and feeds engagement data back to the scheduler
  4. Stays out of the way during focused work, presentations, and screen sharing

Principle 1: Never Interrupt

The ambient display must never steal focus, play a sound, or require dismissal. It is not a notification β€” it is information that exists in the periphery. If the user notices it, great. If they don’t, that’s fine too.

WRONG: Pop-up dialog β†’ "Time to review! What does ι£ŸγΉγ‚‹ mean?"
WRONG: Notification banner β†’ *ding* "ι£ŸγΉγ‚‹ β€” to eat"
RIGHT: Menu bar text that quietly changes every 30 seconds
RIGHT: Notch overlay that fades between cards

Principle 2: Information Dense, Visually Minimal

You have ~160 points of width and ~24 points of height. Every pixel must earn its place.

The optimal layout for a menu bar flashcard:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ι£ŸγΉγ‚‹  γŸγΉγ‚‹  Β·  to eat          β”‚  ← Single line, 24pt height
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   ↑ W6    ↑ W3     ↑ System font
   16pt    10pt       10pt
   Kanji   Reading    Meaning

Principle 3: Progressive Disclosure

Hover over the card to see more. Click to interact.

Default (menu bar):
  ι£ŸγΉγ‚‹ γŸγΉγ‚‹ Β· to eat

Hover (popover, 300x200pt):
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  ι£ŸγΉγ‚‹                              β”‚
  β”‚  γŸγΉγ‚‹                              β”‚
  β”‚  to eat; to have a meal              β”‚
  β”‚                                      β”‚
  β”‚  Level: N5  Β·  Verb (ichidan)        β”‚
  β”‚  Stability: 12.3 days                β”‚
  β”‚  Next review: 3 days                 β”‚
  β”‚                                      β”‚
  β”‚  [I know this βœ“]  [Show less often]  β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Click β†’ Opens quiz mode or navigates to Anki

Principle 4: Visual Freshness through Variation

Showing the exact same layout for 8 hours creates banner blindness. Vary the presentation:

Principle 5: Respect the Work Context

interface WorkContextRules {
  // Pause during screen sharing
  screenShare: 'pause';

  // Pause during fullscreen apps
  fullscreen: 'pause';

  // Reduce frequency during focused work
  // (detected by minimal window switching)
  focusedWork: 'slow_rotation';  // 60s β†’ 120s interval

  // Increase frequency during idle
  // (no keyboard/mouse activity for 30+ seconds)
  idle: 'faster_rotation';       // 60s β†’ 20s interval

  // Hide during presentations (Keynote, PowerPoint, Google Slides)
  presentation: 'hide';

  // Normal during casual browsing, email, chat
  casual: 'normal';
}

For a macOS-native ambient SRS application:

LayerTechnologyRationale
DisplaySwift + SwiftUINative menu bar integration, notch overlay, efficient rendering
Scheduling EngineTypeScript (embedded)Use ts-fsrs or femto-fsrs; easier to iterate on algorithm logic
StorageSQLite (via swift-sqlite or better-sqlite3)Lightweight, file-based, compatible with Anki’s format
IPCLocal HTTP server or stdin/stdoutConnect Swift display to TypeScript scheduler
Anki BridgeTypeScriptParse .apkg, read/write SQLite, field mapping

Architecture Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   macOS System                   β”‚
β”‚                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚   Swift App (Display) β”‚  β”‚  Anki Desktop    β”‚ β”‚
β”‚  β”‚                        β”‚  β”‚  (optional)      β”‚ β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”‚  β”‚ NSStatusItem     β”‚ β”‚          β”‚             β”‚
β”‚  β”‚  β”‚ (menu bar view)  β”‚ β”‚          β”‚ .apkg       β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚          β”‚ export      β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚          β–Ό             β”‚
β”‚  β”‚  β”‚ Notch Overlay    β”‚ β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚  β”‚ (NSWindow)       β”‚ β”‚  β”‚  Anki Bridge     β”‚ β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚  (TypeScript)    β”‚ β”‚
β”‚  β”‚          β”‚             β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”‚          β”‚ request     β”‚          β”‚             β”‚
β”‚  β”‚          β”‚ next card   β”‚          β”‚ import      β”‚
β”‚  β”‚          β–Ό             β”‚          β–Ό             β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚  β”‚ Local HTTP       β”‚β—„β”œβ”€β”€β”€ Scheduler        β”‚ β”‚
β”‚  β”‚  β”‚ Server           β”‚ β”‚  β”‚ (TypeScript)     β”‚ β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚                  β”‚ β”‚
β”‚  β”‚                        β”‚  β”‚  SM-2 or FSRS    β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  Card selection  β”‚ β”‚
β”‚                               β”‚  Exposure trackingβ”‚ β”‚
β”‚                               β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                                       β”‚             β”‚
β”‚                               β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚                               β”‚  SQLite DB       β”‚ β”‚
β”‚                               β”‚  cards.db        β”‚ β”‚
β”‚                               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Alternative: Pure Swift Implementation

If you want to avoid the TypeScript/Swift boundary, implement everything in Swift:

// Pure Swift SM-2 implementation
struct SM2State {
    var easeFactor: Double = 2.5
    var interval: Double = 0
    var repetitions: Int = 0
}

func sm2Review(state: SM2State, quality: Int) -> SM2State {
    guard quality >= 3 else {
        return SM2State(easeFactor: state.easeFactor, interval: 1, repetitions: 0)
    }

    let newInterval: Double
    switch state.repetitions {
    case 0: newInterval = 1
    case 1: newInterval = 6
    default: newInterval = (state.interval * state.easeFactor).rounded()
    }

    let q = Double(quality)
    let newEF = max(1.3, state.easeFactor + 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))

    return SM2State(
        easeFactor: newEF,
        interval: newInterval,
        repetitions: state.repetitions + 1
    )
}

Alternative: Tauri-Based Cross-Platform App

For a cross-platform approach, Tauri v2 supports transparent, always-on-top windows with HUD effects on macOS. You could build the entire app in TypeScript/React with a Rust backend:

{
  "windows": [
    {
      "label": "flashcard-hud",
      "width": 300,
      "height": 40,
      "x": 800,
      "y": 0,
      "decorations": false,
      "transparent": true,
      "alwaysOnTop": true,
      "skipTaskbar": true,
      "resizable": false,
      "shadow": false
    }
  ]
}

Tauri’s HudWindow effect on macOS provides the translucent, system-integrated appearance you want. However, positioning a window in the actual notch area requires private API usage that Tauri does not wrap β€” you would need a Tauri plugin with native Swift code.

Build vs. Integrate Decision

Before building from scratch, consider extending an existing open-source notch app:

ApproachEffortRiskOutcome
Fork boring.notch and add a flashcard widgetLowMedium (keeping fork updated)Fastest to market, but tied to their architecture
Use DynamicNotchKit in a new Swift appMediumLow (stable library)Clean architecture, focused app
Pure NSStatusItem menu bar appLowVery lowWorks everywhere, no notch dependency
Tauri app with custom positioningHighHigh (cross-platform complexity)Most portable, but hardest to get right on macOS

Recommendation: Start with a pure NSStatusItem menu bar app for the MVP. It works on every Mac, requires no private APIs, and the 24pt height is sufficient for kanji display. Add notch overlay as a premium feature later using DynamicNotchKit.


Spaced Repetition Algorithms

AlgorithmLines of CodePersonalizationFailure HandlingBest For
SM-2~20None (same formula for everyone)Harsh reset to day 1Prototypes, simple systems
FSRS~100-200Trained on your review historySmart β€” preserves partial stabilityProduction apps with enough data
Leitner System~10NoneBox demotionPhysical flashcards, extreme simplicity
SM-17/18500+ (proprietary)Deep personalizationSophisticatedSuperMemo only (not available for integration)
Custom neural1000+FullArbitraryResearch projects with large datasets
No algorithm (random)~3NoneN/AOnly useful for ambient-only, no active review

Ambient Delivery Mechanisms

MechanismVisibilityIntrusivenessInteractivityPlatform Compatibility
Menu bar status itemHigh (always visible)NoneClick β†’ popoverAll macOS
Notch overlayHighNone to lowHover β†’ expandMacBooks with notch
Desktop widgetMedium (if desktop visible)NoneLimited (WidgetKit)macOS Sonoma+
macOS notificationMedium (briefly visible)Medium (sound, badge)Dismiss/clickAll macOS
Wallpaper overlayLow (usually covered)NoneNoneAll macOS
Touch BarMediumNoneTouchMacBook Pro 2016-2020
Electron/Tauri overlayHighLowFullCross-platform

Japanese Learning Tools with SRS

ToolSRS AlgorithmAmbient ModeKanji OptimizedAnki CompatibleCost
AnkiSM-2 / FSRSNoVia decksNativeFree (desktop)
WaniKaniModified SRSNoYes (primary focus)Via API export$9/mo
KaniManabuCustom SRSNoYesNo (own format)Free (OSS)
DuolingoHMM-basedNotifications onlyPartialNoFree + $7/mo
MemriseModified SM-2NoVia coursesNoFree + $9/mo
Ambient SRS (proposed)SM-2 β†’ FSRSYes (primary mode)YesImport .apkgFree (build it)

Implementation Technologies

TechnologyLanguageMenu Bar SupportNotch SupportBundle SizeStartup Time
Swift + SwiftUISwiftNative (NSStatusItem)Via DynamicNotchKit~5 MB<0.5s
TauriRust + JS/TSCustom windowRequires plugin~10 MB~1s
ElectronJS/TSVia Tray APICustom window~150 MB~3s
SwiftUI + ts-fsrs (hybrid)Swift + TSNativeVia DynamicNotchKit~15 MB~1s
Objective-C + AppKitObjCNative (NSStatusItem)Manual window mgmt~3 MB<0.3s

Don’tDo InsteadWhy
Show cards during screen sharingDetect screen capture and pauseYour colleagues do not need to see your flashcards during a standup
Use notifications as the primary deliveryUse static menu bar display, notifications as supplementNotification fatigue sets in within days; users disable them
Reset SRS state on every ambient exposureTrack passive exposures separately from active reviewsPassive exposure is not proof of recall β€” treating it as a review inflates intervals and causes forgetting
Show the same card for 5+ minutesRotate every 30-60 secondsBanner blindness kicks in after ~30 seconds; if they haven’t noticed it, they won’t
Display complex kanji at 12ptUse 16pt minimum, prefer 18-24pt with bold weightIllegible characters create frustration, not learning
Use a Latin font with CJK fallbackSpecify Hiragino Sans explicitly for Japanese textFallback fonts produce mismatched metrics and inconsistent weight
Let the scheduler ignore exposure countCap ambient exposures per card per day at 3-5Diminishing returns per exposure; spread the budget across more cards
Build the full FSRS optimizer for a prototypeStart with SM-2, add FSRS when you have enough data to trainFSRS shines with personalization, which requires 1000+ reviews of historical data
Show cards in random orderUse weighted selection based on retrievability and recencyRandom order wastes exposures on well-known cards and underexposes weak ones
Combine front and back on the same display for active studySeparate front/back for quiz mode; combined display is for passive mode onlyCombining them removes the recall challenge that makes active SRS effective
Require user to manually import Anki decks via CLIBuild a GUI deck picker with drag-and-drop .apkg supportThe target user is a language learner, not a developer
Track mouse position continuouslySample every 500ms and only in the notch regionContinuous tracking is a CPU drain and a privacy concern
Play sounds on card rotationSilence by default; optional subtle sound on interaction onlyAmbient means ambient β€” sounds destroy the β€œbackground” quality
Build an Electron app for a menu bar widgetUse native Swift or Tauri β€” Electron adds 150MB of overheadA menu bar app should be invisible in resource usage

Spaced Repetition Algorithms

TypeScript/JavaScript Implementations

Anki Integration

Japanese Vocabulary Data Sources

CJK Typography and Rendering

macOS Development

Notch Apps and Libraries

Research on Learning and Memory

Flashcard Apps


For anyone who wants to build this:

Phase 1: Menu Bar MVP (1-2 weeks)

Phase 2: Intelligence (2-4 weeks)

Phase 3: Polish (2-4 weeks)

Phase 4: Ecosystem (4+ weeks)


This article advocates for a specific, opinionated approach: start with the simplest possible implementation (menu bar + SM-2 + N5 word list), validate that ambient exposure actually improves your retention, and only then invest in FSRS, notch overlays, and Anki integration. The research supports the concept. The technology exists. The gap in the market is real. The question is whether you will build it before someone else does.


Edit page
Share this post on:

Previous Post
Compounding Research Corpora
Next Post
API Mom as Intelligent Router