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:
- How spaced repetition algorithms work and which one to implement for ambient use (SM-2 vs. FSRS)
- The landscape of existing ambient/passive learning tools and why none of them nail it
- Technical requirements for rendering CJK characters at 24px height in a notch-area HUD
- Where to source Japanese vocabulary data (JLPT word lists, Anki decks, WaniKani exports)
- UX design principles for flashcards you glance at, not study
- A complete implementation plan using TypeScript for the scheduling engine and Swift for the display layer
- Whether βosmosis learningβ actually works, according to the research
- The Problem
- Does Osmosis Learning Work?
- Core Concepts
- The Algorithm: SM-2 vs FSRS
- Patterns
- Small Examples
- Data Sources
- Rendering CJK at Small Sizes
- The Notch as a Display Surface
- Existing Tools Landscape
- UX Design for Ambient Flashcards
- Implementation Architecture
- Comparisons
- Anti-Patterns
- 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:
- Day 1-14: Enthusiastic daily reviews, 20 minutes each
- Day 15-30: Reviews pile up, sessions become 40+ minutes
- Day 31-45: Guilt spiral, skipped days compound
- 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
- Zero-friction review: Cards appear without you doing anything. No app to open, no queue to face.
- Hundreds of micro-exposures per day: Each 2-3 seconds, accumulating to 10-15 minutes of passive exposure.
- Reinforcement between active sessions: The ambient system keeps words βwarmβ in memory between your real Anki sessions.
- Kanji recognition through repeated visual exposure: Even without active recall, seeing characters repeatedly builds recognition.
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:
- Entire algorithm fits in 20 lines
- No dependencies, no trained parameters
- Easy to reason about: βease factor goes up when you get it right, down when you donβtβ
- Battle-tested across decades
SM-2 cons for ambient use:
- Same formula for everyone β no personalization
- Harsh on lapses β one failure resets the card to day 1
- The ease factor can get stuck in a βease hellβ spiral
- No concept of βretrievabilityβ β just raw intervals
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:
- SM-2 is 20 lines of code. FSRS is 100+ lines with 19 parameters.
- 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.
- SM-2βs simplicity makes it easier to debug scheduling behavior.
- If you want FSRS later, femto-fsrs gives you a zero-dependency TypeScript implementation in ~100 lines.
- 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:
- If
poolSizeis too small, the user sees the same cards repeatedly in a single session. 20-30 is a good default. - The
maxExposuresPerCardlimit prevents a single card from dominating the rotation β without it, due cards get shown disproportionately. - Recursive
advance()calls need a depth guard to prevent stack overflow if all cards are at max exposure.
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:
- Without the FSRS retrievability model, you cannot calculate R. If using SM-2, approximate it with
interval / daysSinceLastReviewas a rough βfreshnessβ score. - The band thresholds (0.70, 0.85) are educated guesses. You should instrument and adjust based on actual retention data.
- New cards (never reviewed) have no retrievability β they need a separate introduction flow.
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:
- Mouse tracking near the notch is a proxy, not proof. The user might be reaching for a menu bar icon, not looking at the flashcard.
- System idle time is available via
IOKiton macOS (CGEventSourceSecondsSinceLastEventType). You need native code for this. - Privacy-conscious users will object to mouse tracking. Make it optional and clearly documented.
- On multi-monitor setups, the notch coordinates shift. You need to query
NSScreen.mainfor the actual screen geometry.
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:
- Modern Anki (2.1.54+) uses
collection.anki21bwith zstd compression and protobuf encoding. You may need thefzstdnpm package to decompress. - The
duefield in the cards table is relative to the collection creation date for review cards, but an absolute day count for new cards. You need thecrt(creation timestamp) field from thecoltable to calculate actual dates. - Field mapping is the hardest part. Japanese decks have wildly inconsistent field names: βExpressionβ vs βFrontβ vs βWordβ vs βVocabularyβ for the same thing. You will need a configuration UI for this.
- Writing back to the Anki database is risky. Prefer exporting exposure data as a separate log that the user can optionally import.
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:
- Screen sharing detection on macOS is surprisingly hard without native code. The
CGDisplayStreamAPI is the reliable way, but it requires entitlements. - AppleScript calls are slow (~100ms each). Do not run them more than once every 5 seconds.
- Zoomβs screen sharing can start mid-meeting without warning. Poll frequently enough to catch it within 5-10 seconds.
- Do Not Disturb status changed in macOS Monterey+ with Focus modes. The defaults path may vary.
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.
| Source | Format | Coverage | License | URL |
|---|---|---|---|---|
| open-anki-jlpt-decks | CSV, .apkg | N5-N1 | MIT | Best for import β clean CSV with word, reading, meaning |
| jlpt-word-list | JSON | N5-N1 | MIT | Organized by level, includes word frequency data |
| jlpt-kanji-dictionary | JSON | N5-N1 kanji + vocab | MIT | Rich structured data with EN + RU translations |
| JLPT_Vocabulary | JSON, CSV | N5-N1 | MIT | Cleaned data from tanos.co.uk |
| tanos.co.uk | HTML, MP3 | N5-N1 | Fair use | Original source, includes audio files |
| Kaggle JLPT dataset | CSV | N5-N1 | CC0 | Clean 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
| Source | Format | Coverage | URL |
|---|---|---|---|
| kanji-data | JSON | 2,136 joyo kanji | Extended with JLPT levels + WaniKani info |
| yomitan-jlpt-vocab | Yomitan format | N5-N1 | JLPT level tags for Yomitan dictionary |
Anki Shared Decks
The Anki ecosystem has thousands of Japanese decks. The most popular ones for ambient use:
- Core 2K/6K/10K β frequency-ordered vocabulary from newspapers
- Tango N5/N4/N3 β JLPT-ordered with example sentences
- RTK (Remembering the Kanji) β kanji-focused with mnemonics
- WaniKani Ultimate β community export of WaniKani levels
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) | Readability | Notes |
|---|---|---|
| 12px | Illegible | Too small for any CJK character |
| 16px (16x16) | Recognizable | Legacy bitmap CJK fonts used this grid; only works for simple characters |
| 20px | Marginal | Common kanji readable, complex kanji (e.g., θ¬, θ°) become ambiguous |
| 24px | Acceptable | The practical minimum for ambient display. Most N5-N3 kanji are clear |
| 32px | Good | Complex 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.
| Font | Platform | Small-size quality | Notes |
|---|---|---|---|
| Hiragino Sans | macOS built-in | Excellent | The macOS system font for Japanese. Optimized for screen display at all sizes. Multiple weights available. Best default choice. |
| Noto Sans JP | Cross-platform | Very good | Googleβs pan-language font. Excellent Unicode coverage. Slightly wider than Hiragino. |
| SF Pro + SF Japanese | macOS/iOS | Good | Appleβs system font handles Japanese through fallback. Less refined than Hiragino at small sizes. |
| Source Han Sans | Cross-platform | Very good | Adobeβs open-source CJK font. Essentially the same design as Noto Sans CJK. |
| Meiryo | Windows | Good | Microsoftβs screen-optimized CJK font. Uses TrueType hinting for stroke reduction at small sizes. |
| Ark Pixel Font | Pixel art | Special-purpose | A 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:
-
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.
-
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+.
-
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.
-
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:
| Model | Native Resolution | Notch Height | Menu Bar Height | Effective Height (scaled) |
|---|---|---|---|---|
| MacBook Pro 14β (M1/M2/M3/M4) | 3024 x 1964 | 74px native | 74px native | ~37pt at default scaling |
| MacBook Pro 16β (M1/M2/M3/M4) | 3456 x 2234 | 74px native | 74px native | ~37pt at default scaling |
| MacBook Air 13β (M2/M3/M4) | 2560 x 1664 | 74px native | 74px native | ~37pt at default scaling |
| MacBook Air 15β (M2/M3/M4) | 2880 x 1864 | 74px native | 74px 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:
| App | What it displays | Open source | URL |
|---|---|---|---|
| boring.notch | Music controls, visualizer, calendar, clipboard, battery | Yes (GPL-3.0) | Full-featured notch enhancement |
| NotchBar | CPU, memory, network, battery, media playback | Yes | System monitoring widgets |
| NotchNook | Dynamic Island-style notifications, widgets, shortcuts | No (commercial) | Most polished commercial option |
| DynamicNotchKit | Library for adding notch content to any app | Yes (MIT) | SwiftUI library for notch integration |
| TopNotch | Hides the notch with a black menu bar | No (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.
- Pros: Standard API, no hacks, works everywhere, respects menu bar spacing
- Cons: Limited to ~24pt height, competes with other menu bar items for space
- Best for: MVP / prototype
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.
- Pros: Full control over rendering, can animate, can expand on hover
- Cons: Uses private/undocumented window levels, may break with macOS updates
- Best for: Polished production app
Approach 3: macOS Notifications
Use UNUserNotificationCenter to deliver periodic notification banners with flashcard content.
- Pros: No custom UI code, works system-wide, respects Do Not Disturb
- Cons: Notifications are dismissive, not ambient; limited formatting; can be annoying
- Best for: Supplement to the main display, not primary delivery
Approach 4: Desktop Widget (WidgetKit)
macOS Sonoma+ supports desktop widgets via WidgetKit. A flashcard widget on the desktop acts as persistent ambient display.
- Pros: Native, stays visible, iOS/macOS code sharing
- Cons: Widgets have limited interactivity, update frequency is system-controlled
- Best for: Desktop-focused users who work with visible desktop
Menu Bar / Widget Flashcard Apps
| App | Platform | Ambient? | SRS? | Japanese? | Cost | Notes |
|---|---|---|---|---|---|---|
| MemorizeWidget | iOS/macOS | Yes (widget) | Basic | Yes | Free | macOS 14+ notification center and desktop widget |
| Flash Cards Widget | iOS | Yes (lock screen) | No | Yes | Free | βEvery time you check the time, a flashcard appearsβ |
| AlgoApp | iOS/macOS | Partial | Yes | Yes | Free+IAP | Native macOS app with SRS, but not menu-bar focused |
| Fresh Cards | iOS/macOS | No | Yes | Yes | $4.99 | Clean SRS app, no ambient mode |
| Mochi | Cross-platform | No | Yes | Yes | Free+Pro | Markdown-based flashcards with SRS |
| Anki | Cross-platform | No | Yes (SM-2/FSRS) | Yes | Free (desktop) | The standard. No ambient mode. |
| KaniManabu | macOS | No | Yes | Yes (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:
- Reads cards from an existing SRS system (Anki, or a local SM-2/FSRS scheduler)
- Renders them in the menu bar / notch area / as notifications
- Tracks passive exposure and feeds engagement data back to the scheduler
- 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
- The kanji is the visual anchor β largest, heaviest weight
- The reading is secondary β smaller, lighter weight
- A thin separator (middle dot
Β·or thin pipe) divides Japanese from English - The meaning is tertiary β smallest, lightest weight
- No borders, no background color, no icons
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:
- Alternate between showing/hiding the reading (test recognition vs. just exposure)
- Occasionally show only the kanji (pure recognition challenge)
- Use a subtle color tint to indicate card βfreshnessβ β warm colors for due cards, cool for recently reviewed
- Every ~10th card, show a βprogress nudgeβ: β12 cards reviewed todayβ
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';
}
Recommended Stack
For a macOS-native ambient SRS application:
| Layer | Technology | Rationale |
|---|---|---|
| Display | Swift + SwiftUI | Native menu bar integration, notch overlay, efficient rendering |
| Scheduling Engine | TypeScript (embedded) | Use ts-fsrs or femto-fsrs; easier to iterate on algorithm logic |
| Storage | SQLite (via swift-sqlite or better-sqlite3) | Lightweight, file-based, compatible with Ankiβs format |
| IPC | Local HTTP server or stdin/stdout | Connect Swift display to TypeScript scheduler |
| Anki Bridge | TypeScript | Parse .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:
| Approach | Effort | Risk | Outcome |
|---|---|---|---|
| Fork boring.notch and add a flashcard widget | Low | Medium (keeping fork updated) | Fastest to market, but tied to their architecture |
| Use DynamicNotchKit in a new Swift app | Medium | Low (stable library) | Clean architecture, focused app |
| Pure NSStatusItem menu bar app | Low | Very low | Works everywhere, no notch dependency |
| Tauri app with custom positioning | High | High (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
| Algorithm | Lines of Code | Personalization | Failure Handling | Best For |
|---|---|---|---|---|
| SM-2 | ~20 | None (same formula for everyone) | Harsh reset to day 1 | Prototypes, simple systems |
| FSRS | ~100-200 | Trained on your review history | Smart β preserves partial stability | Production apps with enough data |
| Leitner System | ~10 | None | Box demotion | Physical flashcards, extreme simplicity |
| SM-17/18 | 500+ (proprietary) | Deep personalization | Sophisticated | SuperMemo only (not available for integration) |
| Custom neural | 1000+ | Full | Arbitrary | Research projects with large datasets |
| No algorithm (random) | ~3 | None | N/A | Only useful for ambient-only, no active review |
Ambient Delivery Mechanisms
| Mechanism | Visibility | Intrusiveness | Interactivity | Platform Compatibility |
|---|---|---|---|---|
| Menu bar status item | High (always visible) | None | Click β popover | All macOS |
| Notch overlay | High | None to low | Hover β expand | MacBooks with notch |
| Desktop widget | Medium (if desktop visible) | None | Limited (WidgetKit) | macOS Sonoma+ |
| macOS notification | Medium (briefly visible) | Medium (sound, badge) | Dismiss/click | All macOS |
| Wallpaper overlay | Low (usually covered) | None | None | All macOS |
| Touch Bar | Medium | None | Touch | MacBook Pro 2016-2020 |
| Electron/Tauri overlay | High | Low | Full | Cross-platform |
Japanese Learning Tools with SRS
| Tool | SRS Algorithm | Ambient Mode | Kanji Optimized | Anki Compatible | Cost |
|---|---|---|---|---|---|
| Anki | SM-2 / FSRS | No | Via decks | Native | Free (desktop) |
| WaniKani | Modified SRS | No | Yes (primary focus) | Via API export | $9/mo |
| KaniManabu | Custom SRS | No | Yes | No (own format) | Free (OSS) |
| Duolingo | HMM-based | Notifications only | Partial | No | Free + $7/mo |
| Memrise | Modified SM-2 | No | Via courses | No | Free + $9/mo |
| Ambient SRS (proposed) | SM-2 β FSRS | Yes (primary mode) | Yes | Import .apkg | Free (build it) |
Implementation Technologies
| Technology | Language | Menu Bar Support | Notch Support | Bundle Size | Startup Time |
|---|---|---|---|---|---|
| Swift + SwiftUI | Swift | Native (NSStatusItem) | Via DynamicNotchKit | ~5 MB | <0.5s |
| Tauri | Rust + JS/TS | Custom window | Requires plugin | ~10 MB | ~1s |
| Electron | JS/TS | Via Tray API | Custom window | ~150 MB | ~3s |
| SwiftUI + ts-fsrs (hybrid) | Swift + TS | Native | Via DynamicNotchKit | ~15 MB | ~1s |
| Objective-C + AppKit | ObjC | Native (NSStatusItem) | Manual window mgmt | ~3 MB | <0.3s |
| Donβt | Do Instead | Why |
|---|---|---|
| Show cards during screen sharing | Detect screen capture and pause | Your colleagues do not need to see your flashcards during a standup |
| Use notifications as the primary delivery | Use static menu bar display, notifications as supplement | Notification fatigue sets in within days; users disable them |
| Reset SRS state on every ambient exposure | Track passive exposures separately from active reviews | Passive exposure is not proof of recall β treating it as a review inflates intervals and causes forgetting |
| Show the same card for 5+ minutes | Rotate every 30-60 seconds | Banner blindness kicks in after ~30 seconds; if they havenβt noticed it, they wonβt |
| Display complex kanji at 12pt | Use 16pt minimum, prefer 18-24pt with bold weight | Illegible characters create frustration, not learning |
| Use a Latin font with CJK fallback | Specify Hiragino Sans explicitly for Japanese text | Fallback fonts produce mismatched metrics and inconsistent weight |
| Let the scheduler ignore exposure count | Cap ambient exposures per card per day at 3-5 | Diminishing returns per exposure; spread the budget across more cards |
| Build the full FSRS optimizer for a prototype | Start with SM-2, add FSRS when you have enough data to train | FSRS shines with personalization, which requires 1000+ reviews of historical data |
| Show cards in random order | Use weighted selection based on retrievability and recency | Random order wastes exposures on well-known cards and underexposes weak ones |
| Combine front and back on the same display for active study | Separate front/back for quiz mode; combined display is for passive mode only | Combining them removes the recall challenge that makes active SRS effective |
| Require user to manually import Anki decks via CLI | Build a GUI deck picker with drag-and-drop .apkg support | The target user is a language learner, not a developer |
| Track mouse position continuously | Sample every 500ms and only in the notch region | Continuous tracking is a CPU drain and a privacy concern |
| Play sounds on card rotation | Silence by default; optional subtle sound on interaction only | Ambient means ambient β sounds destroy the βbackgroundβ quality |
| Build an Electron app for a menu bar widget | Use native Swift or Tauri β Electron adds 150MB of overhead | A menu bar app should be invisible in resource usage |
Spaced Repetition Algorithms
- SM-2 Algorithm Explained β Clear walkthrough of the original Wozniak algorithm with worked examples
- What spaced repetition algorithm does Anki use? β Official Anki FAQ explaining the transition from SM-2 to FSRS
- A technical explanation of FSRS β Deep dive into the FSRS algorithmβs mathematical foundations
- FSRS ABC Wiki β Beginner-friendly introduction to FSRS concepts
- FSRS Algorithm Wiki β Complete specification of the FSRS algorithm
- Implementing FSRS in 100 Lines β Minimal Rust implementation with full formula derivations
- FSRS vs SM-2 Comparison β Practical comparison for Anki users
- FSRS Benchmark Results β Data showing FSRSβs 99.6% superiority rate over SM-2
- Spaced Repetition Systems Have Gotten Way Better β Excellent overview of why FSRS matters
- SM-2+ Improvement β A modified SM-2 with better handling of lapses
TypeScript/JavaScript Implementations
- ts-fsrs β Full-featured TypeScript FSRS implementation (npm: ts-fsrs)
- femto-fsrs β Zero-dependency FSRS 5 in ~100 lines of TypeScript
- femto-fsrs blog post β Context and motivation for the minimal implementation
- cnnrhill/sm-2 β ES6 implementation of SM-2 with clear documentation
- thyagoluciano/sm2 β Simple SM-2 implementation in TypeScript
Anki Integration
- Ankiβs .apkg format β Concise explanation of the zip/SQLite structure
- AnkiDroid Database Structure β Complete schema documentation for the Anki database
- Processing .apkg files β Step-by-step guide to extracting and parsing .apkg data
- Understanding the Anki APKG Format β Detailed walkthrough with examples
- ankisync2 β Python library for safely reading/writing .apkg and .anki2 files
- Whatβs Inside an Anki Collection File? β Shell-based exploration of the .apkg format
Japanese Vocabulary Data Sources
- open-anki-jlpt-decks β Open source JLPT vocabulary as CSV and .apkg (MIT license)
- jlpt-word-list β JLPT word lists in JSON format organized by level
- jlpt-kanji-dictionary β Structured kanji and vocabulary JSON with EN/RU translations
- JLPT_Vocabulary β All JLPT words in JSON and CSV format
- kanji-data β JSON kanji dataset with JLPT levels and WaniKani mapping
- tanos.co.uk JLPT Resources β Original JLPT word lists with MP3 audio files
- Kaggle JLPT Words β Clean tabular JLPT dataset (CC0 license)
- yomitan-jlpt-vocab β JLPT level tags for Yomitan dictionary integration
- KaniManabu β Open source WaniKani-style SRS for macOS
CJK Typography and Rendering
- Minimum pixel grid for kanji β Discussion of minimum resolution requirements for CJK display
- Meiryo font β Microsoftβs screen-optimized CJK font with TrueType hinting for small sizes
- Noto Sans JP β Googleβs open-source pan-CJK font family
- Ark Pixel Font β Pixel-perfect CJK font designed for exact pixel sizes (12px, 16px)
- CJK Typesetting Principles β Comprehensive guide to CJK text rendering
- CJK Font Optimization Guide 2026 β Modern best practices for web/screen CJK rendering
- Best Fonts for CJK Websites β Practical font selection for Asian language display
macOS Development
- Creating a Menu Bar App with Swift & SwiftUI β Step-by-step guide to macOS menu bar apps
- Custom Status Bar View with SwiftUI β Rendering custom SwiftUI views in NSStatusItem
- macOS Menu Bar App with SwiftUI β Complete menu bar app tutorial
- Creating Status Bar Apps on macOS β AppCodaβs comprehensive status bar app guide
- Pushing NSStatusItem Beyond Appleβs Limits β Advanced techniques for menu bar customization
- A Menu Bar Only macOS App Using AppKit β Pure AppKit approach without SwiftUI
Notch Apps and Libraries
- boring.notch β Open source macOS notch enhancement with music, calendar, clipboard (GPL-3.0)
- DynamicNotchKit β SwiftUI library for integrating content with the MacBook notch (MIT)
- NotchBar β Open source system monitoring widgets for the notch area
- NotchNook β Commercial Dynamic Island-style notch app for macOS
- TopNotch β Hides the notch with a black menu bar
- Fullscreen apps above the MacBook notch β Technical deep dive on notch area window management
- Tauri Window Customization β Transparent, always-on-top windows for cross-platform apps
Research on Learning and Memory
- Incidental vocabulary acquisition meta-analysis β Cambridge meta-analysis confirming incidental L2 vocabulary learning effectiveness
- Effects of exposure frequency on vocabulary acquisition β Research on how exposure frequency and processing depth affect vocabulary learning
- Repeated exposure and multimodal input β PMC study on spaced, multi-session vocabulary acquisition
- Subliminal perception and peripheral vision β HCI research on subliminal priming at different visual angles
- Subliminal perception of complex visual stimuli β Evidence for unconscious learning of visual symbol sequences
- Multiple exposures and vocabulary instruction β Vanderbilt research on why single exposure is insufficient
- The Science of Microlearning β Evidence base for bite-sized, spaced learning sessions
Flashcard Apps
- Anki β The standard open-source spaced repetition system
- MemorizeWidget β Widget-based flashcard app for iOS/macOS
- Flash Cards Widget β Lock screen flashcard widget
- AlgoApp β Cross-platform flashcard app with advanced SRS
- Fresh Cards β iOS/macOS SRS flashcard app
- Mochi β Markdown-based flashcards with spaced repetition
For anyone who wants to build this:
Phase 1: Menu Bar MVP (1-2 weeks)
- Create a Swift macOS app with
NSStatusItem - Implement SM-2 in Swift (20 lines)
- Load N5 vocabulary from CSV (800 words)
- Display
[kanji] [reading] Β· [meaning]in menu bar - Rotate cards every 30 seconds via
Timer - Add click β popover with card details
- Persist card state in SQLite
- Add βI know thisβ / βShow moreβ buttons in popover
Phase 2: Intelligence (2-4 weeks)
- Implement weighted card selection (recency, overdue, exposure count)
- Add context detection (fullscreen, screen sharing, Do Not Disturb)
- Track exposure count per card per day
- Add settings: rotation interval, font size, show/hide reading
- Import .apkg files via drag-and-drop
- Migrate to FSRS using femto-fsrs (when you have 500+ reviews)
Phase 3: Polish (2-4 weeks)
- Add notch overlay using DynamicNotchKit
- Implement hover-to-expand with full card details
- Add macOS notification delivery as supplement
- Desktop widget via WidgetKit
- Keyboard shortcut to toggle display mode
- Statistics: cards seen today, streak, retention estimate
- LaunchAtLogin support
- Dark mode / light mode adaptive colors
Phase 4: Ecosystem (4+ weeks)
- Anki sync (read .apkg, write exposure data)
- WaniKani API integration
- Custom deck creation UI
- Multiple language support (Chinese, Korean)
- Audio pronunciation (TTS or pre-recorded)
- Share decks between users
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.