From 5099fd13b298401399b7963e38344ccd817e8466 Mon Sep 17 00:00:00 2001 From: vgomx <50592489+vgomx@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:58:44 +0200 Subject: [PATCH 1/2] Content is chosen from a catalogue rather than handed over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every install wrote the whole library: twenty-two decks and a hundred and sixty-five cards across four languages, plus all twenty-four grammar notes, most of it in languages the reader had not picked and would never open. That is the opposite of a catalogue. There is nothing to add when everything is already there, and no way to tell what you chose from what you were given. A fresh install now arrives with one starter pack in the language it opens in — measured on a wiped database: one deck, six cards, one note, one language. A pack is a rule, the words that exercise it, and the verbs to drill it on. That shape was already in the app and unconnected: the notes are the grammar themes, six per language and exactly the ones people trip over — de or het, ser or estar, what triggers the subjunctive, separable verbs come apart — and the decks are the practice. A pack pairs them. It is a view over that content rather than a second copy. A pack names a deck id and note ids, and installs them through the same buildDeck the first seed uses, so a pack added months later writes the records a fresh install would and cannot drift from the material it offers. The verbs are a focus list, not data. The drill already loads every verb the language has — NL, ES and PT ship whole tables — so what a pack contributes is which of them the theme is about. It narrows the pool rather than reweighting it, because the point of the pack is that it is these verbs, and the distractors still come from the whole language so a wrong answer does not give itself away. Checked: with Separable verbs installed, the drill asks about ophouden. Four of the eight Dutch lemmas I first wrote — opbellen, uitgaan, meedoen, opruimen — are not in the table Wiktionary gave us. They are replaced with verbs that are. A focus list naming verbs the drill cannot ask about is a pack that quietly does nothing. Nobody loses anything. ensureSeeded is still guarded on the database being empty, so an existing library is untouched, and a pack whose deck is already on the shelf reads as added — a weaker claim than the packs store makes, and the right one for a database seeded before the catalogue existed. Verified by clearing the store against two decks: two marked added, three still offered. ensureNotesSeeded is now a no-op rather than deleted, so a database seeded by an older build keeps every note it has. Notes arrive with the pack that explains them. The store goes to v6 and the backup to format 6, both carrying the packs; restoring decks without them would offer back what the reader had already picked. Co-Authored-By: Claude Opus 5 --- src/data/backup.ts | 19 ++- src/data/db.ts | 141 +++++++++++++--- src/data/packs.ts | 186 +++++++++++++++++++++ src/data/seed.ts | 48 +++++- src/state/store.tsx | 40 ++++- src/tools/conjugation/ConjugationDrill.tsx | 41 ++++- src/tools/flashcards/DeckList.tsx | 30 +++- src/tools/flashcards/PackCatalogue.tsx | 146 ++++++++++++++++ 8 files changed, 611 insertions(+), 40 deletions(-) create mode 100644 src/data/packs.ts create mode 100644 src/tools/flashcards/PackCatalogue.tsx diff --git a/src/data/backup.ts b/src/data/backup.ts index b3fdf74..90f0e8e 100644 --- a/src/data/backup.ts +++ b/src/data/backup.ts @@ -1,6 +1,6 @@ import { exportAll, importAll, type ImportCounts } from './db'; import { APP_VERSION } from '../legalNotices'; -import type { Card, Deck, Note, PracticeDay, StreakExtension, ReviewLogEntry } from './types'; +import type { Card, Deck, LanguageCode, Note, PracticeDay, StreakExtension, ReviewLogEntry } from './types'; /** Identifies the file as ours before anything reads its contents. */ export const BACKUP_FORMAT = 'lingo-toolbox/backup'; @@ -12,13 +12,13 @@ export const BACKUP_FORMAT = 'lingo-toolbox/backup'; */ /** * 2 added notes, 3 the practice log, 4 the repaired days, 5 the extensions that - * replaced them. A version 4 file's repairs are read as extensions already spent + * replaced them, 6 the packs. A version 4 file's repairs are read as extensions already spent * on the day they repaired. An older file has no key at all for what it * predates, which is why the reader treats those as optional rather than as * missing fields — an old backup is still a complete backup of everything the * app had when it was written. */ -export const BACKUP_VERSION = 5; +export const BACKUP_VERSION = 6; export interface Backup { format: typeof BACKUP_FORMAT; @@ -34,6 +34,8 @@ export interface Backup { /** Streak extensions, held and spent. Restoring without these hands back points that were already spent, and breaks the streaks they were holding. */ extensions: StreakExtension[]; + /** Which catalogue packs were added. */ + packs: { id: string; language: LanguageCode; at: number }[]; } /** @@ -44,7 +46,7 @@ export interface Backup { * backup on a laptop should not drag a phone's theme along with it. */ export async function buildBackup(now: number = Date.now()): Promise { - const { decks, cards, reviews, notes, practice, extensions } = await exportAll(); + const { decks, cards, reviews, notes, practice, extensions, packs } = await exportAll(); return { format: BACKUP_FORMAT, version: BACKUP_VERSION, @@ -56,6 +58,7 @@ export async function buildBackup(now: number = Date.now()): Promise { notes, practice, extensions, + packs, }; } @@ -102,6 +105,9 @@ const looksLikeExtension = (v: unknown): boolean => isObject(v) && typeof v.id === 'string' && typeof v.at === 'number' && typeof v.cost === 'number'; /** Version 4's shape, so a backup written by it can still be read. */ +const looksLikePack = (v: unknown): boolean => isObject(v) + && typeof v.id === 'string' && typeof v.language === 'string' && typeof v.at === 'number'; + const looksLikeRepair = (v: unknown): boolean => isObject(v) && typeof v.day === 'string' && typeof v.at === 'number' && typeof v.cost === 'number'; @@ -155,6 +161,9 @@ export function parseBackup(text: string): Backup { if (raw.extensions !== undefined && (!Array.isArray(raw.extensions) || !raw.extensions.every(looksLikeExtension))) { throw new BackupError('That backup has a streak extension with missing fields.'); } + if (raw.packs !== undefined && (!Array.isArray(raw.packs) || !raw.packs.every(looksLikePack))) { + throw new BackupError('That backup has a pack with missing fields.'); + } if (raw.repairs !== undefined && (!Array.isArray(raw.repairs) || !raw.repairs.every(looksLikeRepair))) { throw new BackupError('That backup has a repaired day with missing fields.'); } @@ -180,6 +189,7 @@ export function parseBackup(text: string): Backup { ...((raw.repairs ?? []) as { day: string; at: number; cost: number }[]) .map((r) => ({ id: `repair-${r.day}`, at: r.at, cost: r.cost, usedOn: r.day })), ], + packs: (raw.packs ?? []) as { id: string; language: LanguageCode; at: number }[], }; } @@ -200,6 +210,7 @@ export async function restoreBackup(backup: Backup): Promise { notes: backup.notes, practice: backup.practice, extensions: backup.extensions, + packs: backup.packs, }); } diff --git a/src/data/db.ts b/src/data/db.ts index dfc6fe4..d49dc3e 100644 --- a/src/data/db.ts +++ b/src/data/db.ts @@ -1,18 +1,20 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; import type { Card, Deck, Direction, Grade, LanguageCode, Note, PracticeDay, PracticeTool, Prefs, StreakExtension, ReviewLogEntry } from './types'; import { asLevel } from './types'; -import { buildSeed, buildSeedNotes, WORKSPACES } from './seed'; +import { buildDeck, SEED, SEED_NOTES, WORKSPACES } from './seed'; +import { packById, starterFor, type Pack } from './packs'; import { schedule, scheduleOf, withSchedule } from './scheduler'; const DB_NAME = 'lingo-toolbox'; /** * 2 added the notes store, 3 the practice log, 4 the repaired days, 5 the streak - * extensions that replaced them. The upgrade below is written to + * extensions that replaced them, 6 the installed packs. The upgrade below is + * written to * run from whatever version a reader is on rather than assuming an empty * database, which is what version 1's did — it created all three stores * unconditionally, which is only correct the first time anyone opens the app. */ -const DB_VERSION = 5; +const DB_VERSION = 6; const PREFS_KEY = 'lingo-toolbox:prefs'; interface LingoDB extends DBSchema { @@ -56,6 +58,19 @@ interface LingoDB extends DBSchema { key: string; value: StreakExtension; }; + /** + * Which catalogue packs a reader has added. + * + * The decks and notes a pack installs are ordinary records and could be read + * back to guess at this, but a guess is all it would be: a deck can be + * renamed, emptied or deleted, and none of that means the pack was never + * added. This says what was chosen, which is a different fact from what + * survives of it. + */ + packs: { + key: string; + value: { id: string; language: LanguageCode; at: number }; + }; } let dbPromise: Promise> | null = null; @@ -95,6 +110,9 @@ function getDB() { if (oldVersion < 5) { db.createObjectStore('extensions', { keyPath: 'id' }); } + if (oldVersion < 6) { + db.createObjectStore('packs', { keyPath: 'id' }); + } }, }); } @@ -150,23 +168,86 @@ export async function migrateLevels(): Promise<{ cards: number; decks: number }> */ let seeding: Promise | null = null; -export function ensureSeeded(): Promise { +/* ── the catalogue ───────────────────────────────────────────────────────── */ + +/** Which packs have been added, newest first. */ +export async function installedPacks(): Promise<{ id: string; language: LanguageCode; at: number }[]> { + const db = await getDB(); + return (await db.getAll('packs')).sort((a, b) => b.at - a.at); +} + +/** + * Adds a pack: its deck, its cards, its notes, and the record that it was + * chosen. + * + * Idempotent in the way that matters. The deck and its cards are keyed by ids + * derived from the pack's own content, so adding a pack twice writes the same + * rows rather than a second copy — but a deck already there is left exactly as + * it is, because the reader has been grading it and a re-add must not reset + * their schedule. Notes are the same: present is left alone. + */ +export async function installPack(pack: Pack, now: number = Date.now()): Promise { + const db = await getDB(); + const seedDeck = SEED[pack.language].find((d) => d.id === pack.deck); + if (!seedDeck) return; + + const existingDeck = await db.get('decks', pack.deck); + const existingNotes = new Set((await db.getAll('notes')).map((n) => n.id)); + + const { deck, cards } = buildDeck(pack.language, seedDeck, now); + const notes = SEED_NOTES[pack.language] + .filter((n) => pack.notes.includes(n.id) && !existingNotes.has(n.id)) + .map((n) => ({ ...n, language: pack.language, createdAt: now })); + + const tx = db.transaction(['decks', 'cards', 'notes', 'packs'], 'readwrite'); + await Promise.all([ + ...(existingDeck ? [] : [tx.objectStore('decks').put(deck)]), + ...(existingDeck ? [] : cards.map((c) => tx.objectStore('cards').put(c))), + ...notes.map((n) => tx.objectStore('notes').put(n)), + tx.objectStore('packs').put({ id: pack.id, language: pack.language, at: now }), + tx.done, + ]); +} + +/** + * The verbs the drill should favour, from the packs a reader has added. + * + * Empty means no opinion, and the drill asks about the whole language — which + * is what it did before packs existed and what it should keep doing for anyone + * whose packs name no verbs. + */ +export async function focusVerbs(language: LanguageCode): Promise { + const installed = await installedPacks(); + const verbs = installed + .filter((p) => p.language === language) + .flatMap((p) => packById(p.id)?.verbs ?? []); + return [...new Set(verbs)]; +} + +/** + * A fresh install arrives with one pack, in the language it opens in. + * + * It used to arrive with everything: twenty-two decks and a hundred and + * sixty-five cards across all four workspaces, most of them in languages the + * reader had not chosen and would never see. That is the opposite of a + * catalogue — there is nothing to add when everything is already there, and no + * way to tell what you picked from what you were given. + * + * Guarded on the database being empty, so nobody who already has decks loses + * them or gains a starter beside them. + */ +export function ensureSeeded(language: LanguageCode = 'NL'): Promise { if (!seeding) { seeding = (async () => { const db = await getDB(); if (await db.count('decks')) return; - - const { decks, cards } = buildSeed(); - const tx = db.transaction(['decks', 'cards'], 'readwrite'); - await Promise.all([ - ...decks.map((d) => tx.objectStore('decks').put(d)), - ...cards.map((c) => tx.objectStore('cards').put(c)), - tx.done, - ]); + const starter = starterFor(language); + if (starter) await installPack(starter); })().catch((err) => { // Let a failed seed be retried rather than caching the rejection forever. seeding = null; throw err; + }); } return seeding; @@ -322,12 +403,19 @@ export async function deleteNote(id: string): Promise { * emptiness check would mean the starter notes only ever reached people who * installed after they existed. */ +/** + * Notes arrive with the pack that explains them, not in a heap at install. + * + * This used to write all twenty-four of them — every rule in every language — + * the first time the app opened. Under a catalogue that is the same mistake the + * deck seed was making: the notes *are* the grammar themes, so handing them all + * over at once gives away the thing the packs are offering. + * + * Kept as a no-op rather than deleted so that a database seeded by an older + * build is left exactly as it is. Nobody loses a note they already have. + */ export async function ensureNotesSeeded(): Promise { - const db = await getDB(); - if (await db.count('notes')) return; - const notes = buildSeedNotes(); - const tx = db.transaction('notes', 'readwrite'); - await Promise.all([...notes.map((n: Note) => tx.store.put(n)), tx.done]); + /* Intentionally empty — see above. */ } export async function reviewsSince(since: number): Promise { @@ -700,17 +788,19 @@ export async function reviewsPerDay(days = 7, now: number = Date.now()): Promise export async function exportAll(): Promise<{ decks: Deck[]; cards: Card[]; reviews: ReviewLogEntry[]; notes: Note[]; practice: PracticeDay[]; extensions: StreakExtension[]; + packs: { id: string; language: LanguageCode; at: number }[]; }> { const db = await getDB(); - const [decks, cards, reviews, notes, practice, extensions] = await Promise.all([ + const [decks, cards, reviews, notes, practice, extensions, packs] = await Promise.all([ db.getAll('decks'), db.getAll('cards'), db.getAll('reviews'), db.getAll('notes'), db.getAll('practice'), db.getAll('extensions'), + db.getAll('packs'), ]); - return { decks, cards, reviews, notes, practice, extensions }; + return { decks, cards, reviews, notes, practice, extensions, packs }; } export interface ImportCounts { @@ -738,6 +828,7 @@ export async function importAll( data: { decks: Deck[]; cards: Card[]; reviews: ReviewLogEntry[]; notes?: Note[]; practice?: PracticeDay[]; extensions?: StreakExtension[]; + packs?: { id: string; language: LanguageCode; at: number }[]; }, ): Promise { const db = await getDB(); @@ -747,6 +838,11 @@ export async function importAll( // Restoring the practice without the extensions would hand back the points // that were already spent, and quietly break every streak they were holding. const incomingExtensions = data.extensions ?? []; + /* Which packs were chosen. A restore can rebuild the decks from the file and + still lose this, and then the catalogue offers back what the reader already + picked — the deck-on-the-shelf fallback covers most of it, but not a pack + whose deck was later deleted. */ + const incomingPacks = data.packs ?? []; const has = { decks: new Set(existing.decks.map((d) => d.id)), cards: new Set(existing.cards.map((c) => c.id)), @@ -754,6 +850,7 @@ export async function importAll( notes: new Set(existing.notes.map((n) => n.id)), practice: new Set(existing.practice.map((p) => p.id)), extensions: new Set(existing.extensions.map((e) => e.id)), + packs: new Set(existing.packs.map((p) => p.id)), }; const fresh = { @@ -763,9 +860,10 @@ export async function importAll( notes: incomingNotes.filter((n) => !has.notes.has(n.id)), practice: incomingPractice.filter((p) => !has.practice.has(p.id)), extensions: incomingExtensions.filter((e) => !has.extensions.has(e.id)), + packs: incomingPacks.filter((p) => !has.packs.has(p.id)), }; - const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions'], 'readwrite'); + const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions', 'packs'], 'readwrite'); await Promise.all([ ...fresh.decks.map((d) => tx.objectStore('decks').put(d)), ...fresh.cards.map((c) => tx.objectStore('cards').put(c)), @@ -773,6 +871,7 @@ export async function importAll( ...fresh.notes.map((n) => tx.objectStore('notes').put(n)), ...fresh.practice.map((p) => tx.objectStore('practice').put(p)), ...fresh.extensions.map((e) => tx.objectStore('extensions').put(e)), + ...fresh.packs.map((p) => tx.objectStore('packs').put(p)), tx.done, ]); @@ -828,7 +927,7 @@ export function savePrefs(prefs: Prefs): void { /** Clears every store — used by the "Reset local data" action in Settings. */ export async function resetAll(): Promise { const db = await getDB(); - const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions'], 'readwrite'); + const tx = db.transaction(['decks', 'cards', 'reviews', 'notes', 'practice', 'extensions', 'packs'], 'readwrite'); await Promise.all([ tx.objectStore('decks').clear(), tx.objectStore('cards').clear(), diff --git a/src/data/packs.ts b/src/data/packs.ts new file mode 100644 index 0000000..e575863 --- /dev/null +++ b/src/data/packs.ts @@ -0,0 +1,186 @@ +import type { LanguageCode } from './types'; + +/** + * The catalogue: grammar themes you can add to a workspace. + * + * A pack is a rule, the words that exercise it, and the verbs to drill it on. + * Not a deck with a nicer name — a deck is a list of words, and a list of words + * does not tell you where the prefix goes in a separable verb or why everything + * is -inho. The note is what makes it a grammar pack; the cards are what make + * it practice. + * + * It is a view over content that already exists rather than a second copy of + * it. `deck` and `notes` are ids into SEED and SEED_NOTES, so a pack cannot + * drift from the material it offers, and adding one months later builds the + * same records the first install did — see buildDeck. + * + * `verbs` is a focus list, not data. The drill already loads every verb the + * language has: NL, ES and PT ship whole conjugation tables, thousands of + * words, and it picks from all of them. What a pack contributes is *which* of + * them this theme is about, so drilling a pack asks about its own verbs + * instead of the language's entire vocabulary. + */ +export interface Pack { + id: string; + language: LanguageCode; + /** The theme, as a learner would name it. */ + name: string; + /** One sentence: what the rule is, not what the pack contains. */ + blurb: string; + /** The seeded deck this installs. */ + deck: string; + /** Grammar notes it installs alongside. Ids into SEED_NOTES. */ + notes: string[]; + /** Lemmas the drill should favour while this pack is installed. */ + verbs?: string[]; + /** + * Offered first in its language, and the one a fresh install arrives with. + * + * Exactly one per language. It is the phrase pack rather than the most + * interesting grammar, because the first thing a workspace needs is + * something to say, not a rule about how to say it. + */ + starter?: boolean; +} + +/** + * The catalogue itself. + * + * Ordered as a course would be: what to say, then the rules that keep tripping + * people up, then the ones you only meet once you are reading. Every pack has + * a note except where the language genuinely has no rule to state — a list of + * false friends is a warning, not a grammar. + */ +export const PACKS: Pack[] = [ + /* ── Dutch ─────────────────────────────────────────────────────────────── */ + { + id: 'nl-starter', language: 'NL', name: 'Everyday phrases', starter: true, + blurb: 'The sentences a day actually needs, and the rule that the verb comes second.', + deck: 'nl-everyday', notes: ['nl-word-order'], + }, + { + id: 'nl-separable-pack', language: 'NL', name: 'Separable verbs', + blurb: 'Verbs that come apart in a main clause and put themselves back together in a subordinate one.', + deck: 'nl-separable', notes: ['nl-separable'], + /* Checked against NL.json rather than written from memory: four of the + eight this list started with — opbellen, uitgaan, meedoen, opruimen — + are not in the table Wiktionary gave us, and a focus list naming verbs + the drill cannot ask about is a pack that quietly does nothing. */ + verbs: ['meenemen', 'aankomen', 'afspreken', 'weggaan', 'terugkomen', 'opgeven', 'uitnodigen', 'ophouden'], + }, + { + id: 'nl-particles-pack', language: 'NL', name: 'Little words', + blurb: 'toch, wel, even, hoor — the words that carry the tone and never translate.', + deck: 'nl-particles', notes: ['nl-particles', 'nl-er'], + }, + { + id: 'nl-market-pack', language: 'NL', name: 'At the market', + blurb: 'Food, quantities and asking for them — with the de/het problem the nouns bring with them.', + deck: 'nl-market', notes: ['nl-de-het', 'nl-adjective-e'], + }, + { + id: 'nl-work-pack', language: 'NL', name: 'At work', + blurb: 'Meetings, deadlines and the polite forms an office runs on.', + deck: 'nl-work', notes: [], + }, + + /* ── Portuguese ────────────────────────────────────────────────────────── */ + { + id: 'pt-starter', language: 'PT', name: 'Everyday phrases', starter: true, + blurb: 'The sentences a day actually needs, and who você is talking to.', + deck: 'pt-everyday', notes: ['pt-voce', 'pt-contractions'], + }, + { + id: 'pt-verbs-pack', language: 'PT', name: 'Everyday verbs', + blurb: 'The verbs everything else is built from — including the two that both mean "to be".', + deck: 'pt-verbs', notes: ['pt-ser-estar', 'pt-gerund'], + verbs: ['ser', 'estar', 'ter', 'ir', 'fazer', 'poder', 'querer', 'dizer', 'ver', 'saber'], + }, + { + id: 'pt-cafe-pack', language: 'PT', name: 'At the café', + blurb: 'Ordering, paying and the diminutive that turns a coffee into a cafezinho.', + deck: 'pt-cafe', notes: ['pt-diminutive'], + }, + { + id: 'pt-false-friends-pack', language: 'PT', name: 'False friends', + blurb: 'Words that look like English and mean something else entirely.', + deck: 'pt-false-friends', notes: [], + }, + { + id: 'pt-feelings-pack', language: 'PT', name: 'How you feel', + blurb: 'Saying how you are, and the por/para choice that keeps coming with it.', + deck: 'pt-feelings', notes: ['pt-por-para'], + }, + + /* ── Spanish ───────────────────────────────────────────────────────────── */ + { + id: 'es-starter', language: 'ES', name: 'Moods', starter: true, + blurb: 'Saying how you are — and the two verbs that both mean "to be".', + deck: 'es-feelings', notes: ['es-ser-estar'], + }, + { + id: 'es-verbs-pack', language: 'ES', name: 'Irregular verbs', + blurb: 'The verbs that refuse the pattern, and the two past tenses they refuse it in.', + deck: 'es-verbs', notes: ['es-preterito', 'es-subjunctive'], + verbs: ['ser', 'estar', 'ir', 'tener', 'hacer', 'poder', 'querer', 'decir', 'venir', 'poner', 'saber', 'dar'], + }, + { + id: 'es-kitchen-pack', language: 'ES', name: 'Kitchen Spanish', + blurb: 'Food and cooking, and the gender the nouns bring with them.', + deck: 'es-kitchen', notes: ['es-gender'], + }, + { + id: 'es-travel-pack', language: 'ES', name: 'Getting around', + blurb: 'Directions and transport, with the por/para choice that decides half of them.', + deck: 'es-travel', notes: ['es-por-para', 'es-personal-a'], + }, + { + id: 'es-linking-pack', language: 'ES', name: 'Linking words', + blurb: 'The joins that turn sentences into paragraphs.', + deck: 'es-linking', notes: [], + }, + { + id: 'es-idioms-pack', language: 'ES', name: 'Idioms that lie', + blurb: 'Phrases whose words tell you nothing about what they mean.', + deck: 'es-idioms', notes: [], + }, + + /* ── English ───────────────────────────────────────────────────────────── */ + { + id: 'en-starter', language: 'EN', name: 'Words worth knowing', starter: true, + blurb: 'Precise words for things you already say the long way round.', + deck: 'en-precise', notes: ['en-countable'], + }, + { + id: 'en-phrasal-pack', language: 'EN', name: 'Phrasal verbs', + blurb: 'Verb plus particle, where the particle changes everything — and can be split.', + deck: 'en-phrasal', notes: ['en-phrasal-split'], + }, + { + id: 'en-linking-pack', language: 'EN', name: 'Linking words', + blurb: 'however, but, although — the joins, and which one takes which clause.', + deck: 'en-linking', notes: ['en-however'], + }, + { + id: 'en-work-pack', language: 'EN', name: 'At work', + blurb: 'Meetings and email, and the tense the updates are written in.', + deck: 'en-work', notes: ['en-present-perfect'], + }, + { + id: 'en-directions-pack', language: 'EN', name: 'Getting around', + blurb: 'Directions and transport, with the articles they keep needing.', + deck: 'en-directions', notes: ['en-articles', 'en-adjective-order'], + }, + { + id: 'en-idioms-pack', language: 'EN', name: 'Everyday idioms', + blurb: 'Phrases everyone uses and nobody explains.', + deck: 'en-idioms', notes: [], + }, +]; + +export const packsFor = (language: LanguageCode) => PACKS.filter((p) => p.language === language); + +export const starterFor = (language: LanguageCode) => + PACKS.find((p) => p.language === language && p.starter) ?? packsFor(language)[0]; + +export const packById = (id: string) => PACKS.find((p) => p.id === id); diff --git a/src/data/seed.ts b/src/data/seed.ts index da1f100..e7b8df6 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -92,7 +92,7 @@ interface SeedDeck { * A starter workspace so the app is never empty on first open. Everything here is * ordinary vocabulary; the user can delete the decks and add their own. */ -const SEED: Record = { +export const SEED: Record = { EN: [ { id: 'en-phrasal', @@ -468,6 +468,50 @@ const id = (deckId: string, index: number) => `${deckId}-${index}`; * Builds the starter decks and cards. Cards are staggered so the first session has * a realistic mix rather than everything arriving new at once. */ +/** + * One seeded deck, turned into records. + * + * Split out of buildSeed so a pack added from the catalogue months later is + * built by the same rule as one that shipped with the app — the alternative is + * two places that decide what a new deck's cards look like, which drift. + */ +export function buildDeck(language: LanguageCode, seedDeck: SeedDeck, now: number): { deck: Deck; cards: Card[] } { + const deck: Deck = { + id: seedDeck.id, + language, + name: seedDeck.name, + accent: seedDeck.accent, + reversed: seedDeck.reversed, + tags: seedDeck.tags, + createdAt: now, + }; + const cards: Card[] = seedDeck.cards.map((c, i) => { + // Roughly half of each deck starts as review cards already due, so the + // first session isn't a wall of brand-new words. + const seeded = i % 2 === 0 && i < 6; + return { + id: id(seedDeck.id, i), + deckId: seedDeck.id, + front: c.front, + back: c.back, + phonetic: c.phonetic, + illustration: c.illustration, + tags: c.tags, + level: c.level, + // The deck's answer, unless the card carries its own. + reversed: c.reversed ?? seedDeck.reversed, + createdAt: now, + state: seeded ? 'review' : 'new', + due: seeded ? now - (i + 1) * 60 * 60 * 1000 : now, + interval: seeded ? 1 + i : 0, + ease: START_EASE, + reps: seeded ? 1 + i : 0, + lapses: 0, + }; + }); + return { deck, cards }; +} + export function buildSeed(now: number = Date.now()): { decks: Deck[]; cards: Card[] } { const decks: Deck[] = []; const cards: Card[] = []; @@ -534,7 +578,7 @@ interface SeedNote { * Each is short on purpose. A rule you have to scroll is one you will not read * halfway through a review, which is the moment this exists for. */ -const SEED_NOTES: Record = { +export const SEED_NOTES: Record = { EN: [ { id: 'en-phrasal-split', diff --git a/src/state/store.tsx b/src/state/store.tsx index 6226779..0f322a7 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -4,6 +4,7 @@ import type { Card, Deck, Direction, Grade, LanguageCode, Note, PracticeTool, Pr import * as db from '../data/db'; import { dueDirections } from '../data/scheduler'; import { WORKSPACES } from '../data/seed'; +import { PACKS, type Pack } from '../data/packs'; interface StoreValue { ready: boolean; @@ -50,6 +51,10 @@ interface StoreValue { points: db.Points; /** Buys one extension. False if the slots are full or the points are short. */ buyExtension: () => Promise; + /** Catalogue packs added in this workspace, by pack id. */ + installed: Set; + /** Adds a pack — its deck, its cards and its notes — then re-reads. */ + addPack: (pack: Pack) => Promise; grade: (card: Card, direction: Direction, grade: Grade) => Promise; /** Notes that a tool was used for its exercise today. Cheap to call on every answer. */ @@ -92,6 +97,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { const [notes, setNotes] = React.useState([]); const [streak, setStreak] = React.useState(0); const [points, setPoints] = React.useState({ earned: 0, spent: 0, balance: 0, held: 0, used: 0 }); + const [installed, setInstalled] = React.useState>(new Set()); const [weeklyReviews, setWeeklyReviews] = React.useState(() => Array(7).fill(0)); // `tick` exists only to re-run the due derivations as minute-scale cards come @@ -128,13 +134,14 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { * after the first one those days are covered. */ await db.settleExtensions(); - const [nextDecks, nextCards, nextNotes, nextStreak, nextWeek, nextPoints] = await Promise.all([ + const [nextDecks, nextCards, nextNotes, nextStreak, nextWeek, nextPoints, nextPacks] = await Promise.all([ db.listDecks(language), db.listCardsForLanguage(language), db.listNotes(language), db.computeStreak(), db.reviewsPerDay(), db.points(), + db.installedPacks(), ]); setDecks(nextDecks); setCards(nextCards); @@ -142,12 +149,27 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { setStreak(nextStreak); setWeeklyReviews(nextWeek); setPoints(nextPoints); + /* + * Added, or already here. + * + * The packs store only knows about choices made since the catalogue + * existed. Anyone using the app before it shipped has all twenty-two decks + * from the old seed and no pack records at all, so the catalogue would + * offer them everything they already own. A pack whose deck is on the shelf + * counts as added — which is a weaker claim than the store's, and exactly + * the right one for the case it covers. + */ + const here = new Set(nextDecks.map((d) => d.id)); + setInstalled(new Set([ + ...nextPacks.map((p) => p.id), + ...PACKS.filter((p) => here.has(p.deck)).map((p) => p.id), + ])); }, []); React.useEffect(() => { let cancelled = false; (async () => { - await db.ensureSeeded(); + await db.ensureSeeded(prefs.language); if (cancelled) return; // Its own gate, so a reader who already had decks still gets the notes. await db.ensureNotesSeeded(); @@ -327,6 +349,13 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { * already been missed — which is a walk over the same records rather than a * number to adjust. */ + /* A pack writes a deck, its cards and its notes, so everything the screens + read has moved — a full re-read rather than three local patches. */ + const addPack = React.useCallback(async (pack: Pack) => { + await db.installPack(pack); + await refresh(prefs.language); + }, [prefs.language, refresh]); + const buyExtension = React.useCallback(async () => { const done = await db.buyExtension(); if (done) await refresh(prefs.language); @@ -370,8 +399,9 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { const reset = React.useCallback(async () => { await db.resetAll(); - await db.ensureSeeded(); - await db.ensureNotesSeeded(); + // The workspace they are standing in, so a reset lands them where a fresh + // install would rather than back in Dutch. + await db.ensureSeeded(prefs.language); await refresh(prefs.language); }, [prefs.language, refresh]); @@ -415,6 +445,8 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { streak, points, buyExtension, + installed, + addPack, weeklyReviews, practise, grade, diff --git a/src/tools/conjugation/ConjugationDrill.tsx b/src/tools/conjugation/ConjugationDrill.tsx index 8a93688..0746e1f 100644 --- a/src/tools/conjugation/ConjugationDrill.tsx +++ b/src/tools/conjugation/ConjugationDrill.tsx @@ -4,6 +4,7 @@ import { Button, Card, Icon, Input, ProgressBar, Tabs, Tag, playSound, usePrefer import { useChrome } from '../../shell/chrome'; import { DOCK_HEIGHT } from '../../shell/Dock'; import { useStore } from '../../state/store'; +import { packsFor } from '../../data/packs'; import { EmptyTool } from '../EmptyTool'; import { HAS_CONJUGATION, cellName, groupsOf, loadConjugation, mark, @@ -51,11 +52,25 @@ function pick( language: Parameters[0], misses: Record, avoid: string | null, + focus: string[] = [], ): Question | null { const cells = data.cells.filter((c) => groups.has(cellName(language, c).group)); if (!cells.length) return null; - const words = Object.keys(data.words); + /* + * The verbs the packs you have added are about, where there are any. + * + * The table holds every verb Wiktionary had — thousands of them — and asking + * across all of it is right for someone with no packs and wrong for someone + * who added Separable verbs this morning. So a focus narrows the pool rather + * than reweighting it: the point of the pack is that it is *these* verbs. + * + * Filtered against the table rather than trusted, and dropped entirely if + * nothing survives — a focus list that matched no verb would leave the drill + * with nothing to ask, which is worse than asking broadly. + */ + const focused = focus.filter((w) => data.words[w]); + const words = focused.length ? focused : Object.keys(data.words); const pool: { word: string; cell: string; weight: number }[] = []; for (const word of words) { const table = data.words[word].c; @@ -90,7 +105,8 @@ function pick( options.push(siblings.splice(Math.floor(Math.random() * siblings.length), 1)[0]); } while (options.length < 4) { - const other = data.words[words[Math.floor(Math.random() * words.length)]].c[chosen.cell]; + const all = Object.keys(data.words); + const other = data.words[all[Math.floor(Math.random() * all.length)]].c[chosen.cell]; if (other && !options.includes(other)) options.push(other); else break; } @@ -325,7 +341,18 @@ function QuestionCard({ } export function ConjugationDrill() { - const { language, workspace, prefs, practise } = useStore(); + const { language, workspace, prefs, practise, installed } = useStore(); + + /* + * The verbs this workspace's added packs are about. + * + * Module-scope stable: it is a dependency of the picker, and a fresh array + * every render would be a new one on every keystroke of an answer. + */ + const focus = React.useMemo( + () => [...new Set(packsFor(language).filter((p) => installed.has(p.id)).flatMap((p) => p.verbs ?? []))], + [language, installed], + ); const [data, setData] = React.useState(null); const [state, setState] = React.useState<'loading' | 'ready' | 'unavailable'>('loading'); @@ -408,14 +435,14 @@ export function ConjugationDrill() { const next = React.useCallback((avoid: string | null = null) => { if (!data) return; - setQuestion(pick(data, groups, language, misses, avoid)); + setQuestion(pick(data, groups, language, misses, avoid, focus)); setTyped(''); setVerdict(null); }, [data, groups, language, misses]); // A new question whenever the scope changes, so the screen is never showing a // cell the reader has just switched off. React.useEffect(() => { - if (state === 'ready' && data && groups.size) setQuestion((q) => q ?? pick(data, groups, language, misses, null)); + if (state === 'ready' && data && groups.size) setQuestion((q) => q ?? pick(data, groups, language, misses, null, focus)); }, [state, data, groups, language, misses]); React.useEffect(() => { @@ -456,7 +483,7 @@ export function ConjugationDrill() { setOutgoing(null); setTyped(''); setVerdict(null); - if (data) setQuestion(pick(data, groups, language, misses, null)); + if (data) setQuestion(pick(data, groups, language, misses, null, focus)); }; /** Freeze what is on screen, then ask the next one — the two overlap. */ @@ -658,7 +685,7 @@ export function ConjugationDrill() { else if (!on) nextGroups.add(g.id); setGroups(nextGroups); save(GROUP_KEY, [...nextGroups]); - setQuestion(pick(data, nextGroups, language, misses, null)); + setQuestion(pick(data, nextGroups, language, misses, null, focus)); setTyped(''); setVerdict(null); setOutgoing(null); // From the top: a run half-answered in one set of tenses and // half in another is not a score of anything. diff --git a/src/tools/flashcards/DeckList.tsx b/src/tools/flashcards/DeckList.tsx index 2466278..949ec6e 100644 --- a/src/tools/flashcards/DeckList.tsx +++ b/src/tools/flashcards/DeckList.tsx @@ -7,6 +7,8 @@ import { levelRange } from '../../data/types'; import type { CEFRLevel } from '../../data/types'; import { LevelFilter } from './LevelFilter'; import { EmptyTool } from '../EmptyTool'; +import { PackCatalogue } from './PackCatalogue'; +import { packsFor } from '../../data/packs'; import { isDue } from '../../data/scheduler'; const page: React.CSSProperties = { @@ -16,8 +18,12 @@ const page: React.CSSProperties = { }; export function DeckList() { + const [catalogue, setCatalogue] = React.useState(false); const isMobile = useIsMobile(); - const { decks, cardsInDeck, dueCount, workspace } = useStore(); + const { decks, cardsInDeck, dueCount, workspace, language, installed } = useStore(); + /* How many packs this workspace still has to offer. Both the button and the + empty state are about this number, so it is worked out once. */ + const remaining = packsFor(language).filter((p) => !installed.has(p.id)).length; const navigate = useNavigate(); const totalCards = decks.reduce((n, d) => n + cardsInDeck(d.id).length, 0); @@ -116,14 +122,34 @@ export function DeckList() { )} + + {/* The way in to the catalogue, and only while there is something in + it to add. A door to an empty room teaches you to stop opening + it. */} + {remaining > 0 && ( +
+ +
+ )} + setCatalogue(false)} /> + {decks.length === 0 ? ( + + + } /> ) : ( // A guard rather than a state you can reach: a chip with no cards diff --git a/src/tools/flashcards/PackCatalogue.tsx b/src/tools/flashcards/PackCatalogue.tsx new file mode 100644 index 0000000..d18d11b --- /dev/null +++ b/src/tools/flashcards/PackCatalogue.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { Badge, Button, Card, Dialog, Icon, playSound } from 'lingo-ds'; +import { useStore } from '../../state/store'; +import { packsFor, type Pack } from '../../data/packs'; +import { SEED, SEED_NOTES } from '../../data/seed'; +import { HAS_CONJUGATION } from '../../data/conjugation'; + +/** + * The catalogue: grammar themes you can add to this workspace. + * + * Content used to be pushed. Every install wrote twenty-two decks across four + * languages whether or not the reader would ever open them, which left nothing + * to choose and no way to tell what you had picked from what you had been + * given. A fresh install now arrives with one pack and this is where the rest + * lives. + * + * It sits with the decks rather than in the rail: a pack becomes decks, and + * this is where decks are. It is also where somebody notices they want more to + * practise, which is the moment worth catching. + */ + +/** What a pack would add, counted from the material it names. */ +function contents(pack: Pack) { + const deck = SEED[pack.language].find((d) => d.id === pack.deck); + const notes = SEED_NOTES[pack.language].filter((n) => pack.notes.includes(n.id)); + return { + cards: deck?.cards.length ?? 0, + notes: notes.length, + /* Only where the language has tables to drill. A verb list in a workspace + with no conjugation data is a promise nothing can keep. */ + verbs: HAS_CONJUGATION[pack.language] ? pack.verbs?.length ?? 0 : 0, + noteTitles: notes.map((n) => n.title), + }; +} + +function Row({ pack, added, onAdd }: { pack: Pack; added: boolean; onAdd: () => void }) { + const [busy, setBusy] = React.useState(false); + const has = contents(pack); + + const add = async () => { + if (busy || added) return; + setBusy(true); + await onAdd(); + // The sound a thing arriving makes, not a celebration — the pack is the + // start of the work rather than the end of it. + playSound('toggle'); + setBusy(false); + }; + + return ( + +
+
+
+

+ {pack.name} +

+ {pack.starter && Starter} +
+ +

+ {pack.blurb} +

+ + {/* What arrives, named by the tool it arrives in — a count of "items" + would be true and useless. */} +
+ + {has.cards} cards + + {has.notes > 0 && ( + + {has.notes === 1 ? '1 note' : `${has.notes} notes`} + + )} + {has.verbs > 0 && ( + + {has.verbs} verbs to drill + + )} +
+ + {/* The rules by name. This is the part that makes it a grammar pack + rather than a word list, so it is stated rather than counted. */} + {has.noteTitles.length > 0 && ( +

+ {has.noteTitles.join(' · ')} +

+ )} +
+ +
+ {added ? ( + /* Not a disabled button. There is nothing to press, and a greyed + control invites the press anyway. */ + + Added + + ) : ( + + )} +
+
+
+ ); +} + +export function PackCatalogue({ open, onClose }: { open: boolean; onClose: () => void }) { + const { workspace, language, installed, addPack } = useStore(); + const packs = packsFor(language); + const left = packs.filter((p) => !installed.has(p.id)).length; + + return ( + + {/* The body is padded at the sides only — a footer would close it off and + there is none, so it closes its own box, as the FAQ dialog does. */} +
+ {packs.map((p) => ( + addPack(p)} /> + ))} +
+
+ ); +} From 5070f73204199d7b3921e4b0593aebe60fd8a8a8 Mon Sep 17 00:00:00 2001 From: vgomx <50592489+vgomx@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:12:07 +0200 Subject: [PATCH 2/2] Call them decks, fill them out, and let a phone make one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the catalogue was wrong about, all found by looking at it. **Decks, not packs.** The reader is offered a deck: that is the word this app already uses for a set of cards you practise, and a second word for the thing that becomes one would be a word to learn for something they can already name. The code keeps `Pack` for the offer, because `Deck` is the record it installs and one of them has to give. **They were thin.** Six to eight cards is a deck you finish before it teaches you anything. 165 cards across 22 decks becomes 301, none of them under ten, most between fourteen and seventeen. Two things the checks caught rather than I did. Forty-one of the cards I wrote were words those decks already had — whereas, sin embargo, de collega, ficar — and the later copy is dropped, so the originals and their illustrations stand. And no card names an illustration that does not exist, because the palette was built by reading public/openmoji first rather than by guessing and finding out. **New deck was desktop-only.** The plus sat in the deck sidebar's header, and a phone never shows that sidebar — so on an app whose point is the cards you write, writing one was possible at a desk and nowhere else. That is the honest answer to "why can't the user add their own": on a phone they could not. It is in the top bar now, which every screen and every size has. 44px under a thumb and 36 under a pointer, rather than the 14px glyph it was. Only inside Flashcards and Review, though — a button that makes a deck while you are reading an etymology has wandered away from the thing it acts on. Measured across five routes and two widths: present on the four where it belongs, absent on the two where it does not. The deck list's empty state names both ways in as well, since the catalogue is a starting point rather than the only source of cards. Co-Authored-By: Claude Opus 5 --- src/data/backup.ts | 2 +- src/data/seed.ts | 139 +++++++++++++++++++++++++ src/shell/AppShell.tsx | 46 +++++--- src/tools/flashcards/DeckList.tsx | 11 +- src/tools/flashcards/PackCatalogue.tsx | 11 +- 5 files changed, 186 insertions(+), 23 deletions(-) diff --git a/src/data/backup.ts b/src/data/backup.ts index 90f0e8e..2b00f1f 100644 --- a/src/data/backup.ts +++ b/src/data/backup.ts @@ -162,7 +162,7 @@ export function parseBackup(text: string): Backup { throw new BackupError('That backup has a streak extension with missing fields.'); } if (raw.packs !== undefined && (!Array.isArray(raw.packs) || !raw.packs.every(looksLikePack))) { - throw new BackupError('That backup has a pack with missing fields.'); + throw new BackupError('That backup has an added deck with missing fields.'); } if (raw.repairs !== undefined && (!Array.isArray(raw.repairs) || !raw.repairs.every(looksLikeRepair))) { throw new BackupError('That backup has a repaired day with missing fields.'); diff --git a/src/data/seed.ts b/src/data/seed.ts index e7b8df6..7bb1f64 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -109,6 +109,14 @@ export const SEED: Record = { { front: 'call off', back: 'to cancel something already arranged', illustration: '274C', level: 'B1', tags: ['verb'] }, { front: 'take on', back: 'to accept work or responsibility', illustration: '1F4AA', level: 'B2', tags: ['verb'] }, { front: 'come across', back: 'to find by chance, or to give an impression', illustration: '1F440', level: 'B2', tags: ['verb'] }, + { front: 'put up with', back: 'to tolerate', level: 'B2', tags: ['verb'] }, + { front: 'get over', back: 'to recover from', level: 'B1', tags: ['verb'] }, + { front: 'turn down', back: 'to refuse, or to lower', illustration: '274C', level: 'B1', tags: ['verb'] }, + { front: 'work out', back: 'to figure out — and to exercise', illustration: '1F4AA', level: 'B1', tags: ['verb'] }, + { front: 'run out of', back: 'to have none left', level: 'A2', tags: ['verb'] }, + { front: 'bring about', back: 'to cause to happen', level: 'B2', tags: ['verb'] }, + { front: 'sort out', back: 'to fix or organise', level: 'B1', tags: ['verb'] }, + { front: 'go over', back: 'to review something carefully', illustration: '1F440', level: 'B1', tags: ['verb'] }, ], }, { @@ -125,6 +133,12 @@ export const SEED: Record = { { front: 'ambiguous', back: 'open to more than one reading', phonetic: '/æmˈbɪɡjuəs/', illustration: '1F914', level: 'B2', tags: ['adj'] }, { front: 'pragmatic', back: 'guided by what works rather than by theory', phonetic: '/præɡˈmætɪk/', level: 'B2', tags: ['adj'] }, { front: 'succinct', back: 'said in few words', phonetic: '/səkˈsɪŋkt/', illustration: '2702', level: 'C1', tags: ['adj'] }, + { front: 'tenuous', back: 'so slight it barely holds', level: 'C1', tags: ['adj'] }, + { front: 'scrupulous', back: 'careful to do the right thing', level: 'C1', tags: ['adj'] }, + { front: 'innocuous', back: 'harmless, and duller than it looks', level: 'C1', tags: ['adj'] }, + { front: 'volatile', back: 'liable to change without warning', illustration: '1F525', level: 'B2', tags: ['adj'] }, + { front: 'astute', back: 'quick to see what matters', illustration: '1F440', level: 'C1', tags: ['adj'] }, + { front: 'redundant', back: 'more than is needed', level: 'B2', tags: ['adj'] }, ], }, { @@ -139,6 +153,10 @@ export const SEED: Record = { { front: 'cut corners', back: 'to do something cheaply or carelessly', illustration: '2702', level: 'B2', tags: ['idiom'] }, { front: 'on the fence', back: 'undecided between two options', level: 'B2', tags: ['idiom'] }, { front: 'a blessing in disguise', back: 'something bad that turns out well', illustration: '1F3AD', level: 'B2', tags: ['idiom'] }, + { front: 'the last straw', back: 'the small thing that finally breaks it', level: 'B2', tags: ['idiom'] }, + { front: 'bite the bullet', back: 'to get an unpleasant thing over with', level: 'B2', tags: ['idiom'] }, + { front: 'hit the sack', back: 'to go to bed', illustration: '1F634', level: 'B1', tags: ['idiom'] }, + { front: 'call it a day', back: 'to stop working for now', illustration: '23F0', level: 'B1', tags: ['idiom'] }, ], }, { @@ -156,6 +174,14 @@ export const SEED: Record = { { front: 'to miss a train', back: 'to arrive after it has already gone', illustration: '1F682', level: 'A2', tags: ['verb'] }, { front: 'to change at', back: 'to get off and take another line partway', level: 'A2', tags: ['verb'] }, { front: 'timetable', back: 'the printed list of departure times', illustration: '23F0', level: 'A2', tags: ['noun'] }, + { front: 'head towards', back: 'to go in the direction of', level: 'A2', tags: ['phrase'] }, + { front: 'on your left', back: 'to your left', level: 'A1', tags: ['phrase'] }, + { front: 'the far end', back: 'the end furthest from here', level: 'B1', tags: ['phrase'] }, + { front: 'a dead end', back: 'a road with no way through', illustration: '274C', level: 'B1', tags: ['noun'] }, + { front: 'the crossing', back: 'where you walk across the road', level: 'A2', tags: ['noun'] }, + { front: 'two stops away', back: 'two stops from here', illustration: '1F68C', level: 'A2', tags: ['phrase'] }, + { front: 'the platform', back: 'where you wait for the train', illustration: '1F686', level: 'A2', tags: ['noun'] }, + { front: "it's a short walk", back: "it isn't far on foot", illustration: '1F45F', level: 'A2', tags: ['phrase'] }, ], }, { @@ -173,6 +199,12 @@ export const SEED: Record = { { front: 'to sign off on', back: 'to give something formal approval', level: 'B2', tags: ['verb'] }, { front: 'a backlog', back: 'work that has piled up while you were elsewhere', level: 'B2', tags: ['noun'] }, { front: 'to run something by someone', back: 'to check an idea with them before acting', level: 'B2', tags: ['verb'] }, + { front: 'the deliverable', back: 'the thing you owe by the deadline', illustration: '23F0', level: 'B2', tags: ['noun'] }, + { front: 'to loop someone in', back: 'to add them to the conversation', illustration: '1F4AC', level: 'B2', tags: ['phrase'] }, + { front: 'a blocker', back: 'the thing stopping the work', illustration: '274C', level: 'B2', tags: ['noun'] }, + { front: 'to follow up', back: 'to come back to it later', level: 'B1', tags: ['phrase'] }, + { front: 'the takeaway', back: 'the one thing worth remembering', illustration: '1F4DD', level: 'B2', tags: ['noun'] }, + { front: 'out of office', back: 'away, and not answering', illustration: '1F3E0', level: 'A2', tags: ['phrase'] }, ], }, // Abstract by nature, so no illustrations — see the note on SeedCard. @@ -191,6 +223,12 @@ export const SEED: Record = { { front: 'hence', back: 'from this it follows', level: 'C1', tags: ['adverb'] }, { front: 'albeit', back: 'although — before a short phrase, not a clause', level: 'C1', tags: ['conjunction'] }, { front: 'notwithstanding', back: 'despite, in spite of', level: 'C1', tags: ['preposition'] }, + { front: 'provided that', back: 'as long as', level: 'B2', tags: ['linking'] }, + { front: 'in other words', back: 'put another way', level: 'B1', tags: ['linking'] }, + { front: 'even so', back: 'despite that', level: 'B2', tags: ['linking'] }, + { front: 'as a result', back: 'so, therefore', level: 'B1', tags: ['linking'] }, + { front: 'on the other hand', back: 'looking at the opposite side', level: 'B1', tags: ['linking'] }, + { front: 'that said', back: 'having admitted that', level: 'B2', tags: ['linking'] }, ], }, ], @@ -208,6 +246,15 @@ export const SEED: Record = { { front: 'tá tudo bem', back: 'everything is fine — está, worn down in speech', illustration: '1F44D', level: 'A1', tags: ['phrase'] }, { front: 'sei lá', back: 'I dunno — a shrug with words', illustration: '1F914', level: 'A2', tags: ['phrase'] }, { front: 'dar um jeitinho', back: 'to find a way around it, improvised', phonetic: '/daʁ ũ ʒejˈtʃĩɲu/', illustration: '1F527', level: 'B1', tags: ['phrase'] }, + { front: 'tudo bem?', back: 'everything good? — the standard hello', illustration: '1F44B', level: 'A1', tags: ['phrase'] }, + { front: 'valeu', back: 'cheers, thanks — informal', illustration: '1F44D', level: 'A2', tags: ['phrase'] }, + { front: 'nossa!', back: 'wow — from Nossa Senhora, and said constantly', level: 'A2', tags: ['phrase'] }, + { front: 'com licença', back: 'excuse me — when passing or leaving', level: 'A1', tags: ['phrase'] }, + { front: 'desculpa', back: 'sorry', illustration: '1F64F', level: 'A1', tags: ['phrase'] }, + { front: 'tá bom', back: 'alright, fine', level: 'A1', tags: ['phrase'] }, + { front: 'daqui a pouco', back: 'in a little while', illustration: '23F0', level: 'A2', tags: ['phrase'] }, + { front: 'de nada', back: "you're welcome", level: 'A1', tags: ['phrase'] }, + { front: 'beleza?', back: 'all good? — literally beauty', level: 'B1', tags: ['phrase'] }, ], }, { @@ -222,6 +269,10 @@ export const SEED: Record = { { front: 'livraria', back: 'bookshop — a library is a biblioteca', phonetic: '/livɾaˈɾia/', illustration: '1F4DA', level: 'A2', tags: ['noun'] }, { front: 'pasta', back: 'folder or briefcase — the food is massa', illustration: '1F45C', level: 'A2', tags: ['noun'] }, { front: 'êxito', back: 'success — not an exit', illustration: '1F3C6', level: 'B1', tags: ['noun'] }, + { front: 'assistir', back: 'to watch — not to assist', illustration: '1F440', level: 'A2', tags: ['verb'] }, + { front: 'costume', back: 'habit — not a costume', level: 'B1', tags: ['noun'] }, + { front: 'realizar', back: 'to carry out — only sometimes to realise', level: 'B1', tags: ['verb'] }, + { front: 'atualmente', back: 'currently — not actually', level: 'B1', tags: ['adv'] }, ], }, { @@ -239,6 +290,13 @@ export const SEED: Record = { { front: 'um suco de laranja', back: 'an orange juice — suco, where Portugal says sumo', illustration: '1F34A', level: 'A1', tags: ['noun'] }, { front: 'um copo de água', back: 'a glass of water', illustration: '1F4A7', level: 'A1', tags: ['phrase'] }, { front: 'a conta, por favor', back: 'the bill, please', level: 'A1', tags: ['phrase'] }, + { front: 'um pastel', back: 'a fried pastry, savoury', level: 'A2', tags: ['noun'] }, + { front: 'uma coxinha', back: 'a teardrop of shredded chicken in dough', level: 'A2', tags: ['noun'] }, + { front: 'um misto quente', back: 'a toasted ham and cheese', illustration: '1F9C0', level: 'A2', tags: ['noun'] }, + { front: 'sem açúcar', back: 'without sugar', level: 'A1', tags: ['phrase'] }, + { front: 'para viagem', back: 'to take away', level: 'A2', tags: ['phrase'] }, + { front: 'uma água com gás', back: 'sparkling water', level: 'A1', tags: ['noun'] }, + { front: 'está ótimo', back: "it's great", illustration: '1F60A', level: 'A1', tags: ['phrase'] }, ], }, { @@ -256,6 +314,11 @@ export const SEED: Record = { { front: 'dar certo', back: 'to work out, to come off', level: 'B1', tags: ['verb'] }, { front: 'estar a fim de', back: 'to be up for something', level: 'B1', tags: ['verb'] }, { front: 'se dar bem', back: 'to get on well with someone', level: 'B1', tags: ['verb'] }, + { front: 'dar', back: 'to give — and half the idioms in the language', level: 'A1', tags: ['verb'] }, + { front: 'conseguir', back: 'to manage to, to pull off', illustration: '1F4AA', level: 'B1', tags: ['verb'] }, + { front: 'precisar', back: 'to need', level: 'A1', tags: ['verb'] }, + { front: 'achar', back: 'to think, to reckon — and to find', illustration: '1F914', level: 'A2', tags: ['verb'] }, + { front: 'deixar', back: 'to leave something, to let', level: 'A2', tags: ['verb'] }, ], }, { @@ -272,6 +335,14 @@ export const SEED: Record = { { front: 'que saco', back: 'what a drag', illustration: '1F644', level: 'B1', tags: ['phrase'] }, { front: 'não aguento mais', back: 'I cannot take any more of it', illustration: '1F612', level: 'B1', tags: ['phrase'] }, { front: 'estou de boa', back: 'I am fine — relaxed, nothing wrong', illustration: '1F60C', level: 'B1', tags: ['phrase'] }, + { front: 'com sono', back: 'sleepy', illustration: '1F634', level: 'A1', tags: ['phrase'] }, + { front: 'com pressa', back: 'in a hurry', illustration: '23F0', level: 'A2', tags: ['phrase'] }, + { front: 'com raiva', back: 'angry', illustration: '1F620', level: 'A2', tags: ['phrase'] }, + { front: 'com saudade', back: 'missing someone or somewhere', illustration: '1F614', level: 'B1', tags: ['phrase'] }, + { front: 'chateado', back: 'annoyed, upset', level: 'B1', tags: ['adj'] }, + { front: 'animado', back: 'excited, up for it', illustration: '1F389', level: 'A2', tags: ['adj'] }, + { front: 'cansado', back: 'tired', illustration: '1F634', level: 'A1', tags: ['adj'] }, + { front: 'tranquilo', back: 'relaxed — and also no worries', illustration: '1F642', level: 'A2', tags: ['adj'] }, ], }, ], @@ -288,6 +359,15 @@ export const SEED: Record = { { front: 'alsjeblieft', back: 'please — and also here you go', phonetic: '/ɑlsjəˈblift/', illustration: '1F64F', level: 'A1', tags: ['phrase'] }, { front: 'doe maar normaal', back: 'just act normal — a whole national attitude', illustration: '1F610', level: 'B1', tags: ['phrase'] }, { front: 'afspraak', back: 'an appointment or an agreement', phonetic: '/ˈɑfspraːk/', illustration: '23F0', level: 'A2', tags: ['noun'] }, + { front: 'even', back: 'just, for a moment — softens any request', level: 'A1', tags: ['adv'] }, + { front: 'hoor', back: 'a tag that takes the edge off what you just said', level: 'A2', tags: ['particle'] }, + { front: 'het maakt niet uit', back: "it doesn't matter", level: 'A2', tags: ['phrase'] }, + { front: 'ik snap het', back: 'I get it', illustration: '1F642', level: 'A1', tags: ['phrase'] }, + { front: 'geen probleem', back: 'no problem', illustration: '1F44D', level: 'A1', tags: ['phrase'] }, + { front: 'tot straks', back: 'see you later today', illustration: '1F44B', level: 'A1', tags: ['phrase'] }, + { front: 'sorry, hoor', back: 'sorry — the hoor makes it lighter, not heavier', level: 'A2', tags: ['phrase'] }, + { front: 'dat is jammer', back: "that's a shame", illustration: '1F614', level: 'A2', tags: ['phrase'] }, + { front: 'weet je wat', back: 'you know what — the way a suggestion starts', level: 'B1', tags: ['phrase'] }, ], }, { @@ -303,6 +383,14 @@ export const SEED: Record = { { front: 'uitgaan', back: 'to go out — ik ga uit', phonetic: '/ˈœytɣaːn/', illustration: '1F6AA', level: 'A2', tags: ['verb'] }, { front: 'afspreken', back: 'to arrange to meet — ik spreek af', phonetic: '/ˈɑfspreːkə(n)/', illustration: '1F91D', level: 'B1', tags: ['verb'] }, { front: 'meevallen', back: 'to turn out better than feared', illustration: '1F605', level: 'B1', tags: ['verb'] }, + { front: 'weggaan', back: 'to leave — ik ga weg', illustration: '1F6AA', level: 'A2', tags: ['verb'] }, + { front: 'terugkomen', back: 'to come back — ik kom terug', level: 'A2', tags: ['verb'] }, + { front: 'opgeven', back: 'to give up — ik geef op', level: 'B1', tags: ['verb'] }, + { front: 'uitnodigen', back: 'to invite — ik nodig uit', illustration: '1F389', level: 'B1', tags: ['verb'] }, + { front: 'ophouden', back: 'to stop — hou op!', illustration: '274C', level: 'B1', tags: ['verb'] }, + { front: 'aanraken', back: 'to touch — raak niet aan', level: 'B1', tags: ['verb'] }, + { front: 'uitzoeken', back: 'to figure out, to pick out — ik zoek uit', illustration: '1F440', level: 'B2', tags: ['verb'] }, + { front: 'opschieten', back: 'to hurry up — schiet op!', illustration: '23F0', level: 'B1', tags: ['verb'] }, ], }, { @@ -320,6 +408,15 @@ export const SEED: Record = { { front: 'lekker', back: 'tasty — and by extension, good in general', illustration: '1F60B', level: 'A1', tags: ['adj'] }, { front: 'mag ik...?', back: 'may I have...?', level: 'A1', tags: ['phrase'] }, { front: 'de rekening', back: 'the bill', level: 'A2', tags: ['noun'] }, + { front: 'de kip', back: 'chicken', level: 'A1', tags: ['noun'] }, + { front: 'het ei', back: 'egg', illustration: '1F95A', level: 'A1', tags: ['noun'] }, + { front: 'de vis', back: 'fish', illustration: '1F41F', level: 'A1', tags: ['noun'] }, + { front: 'de wortel', back: 'carrot', illustration: '1F955', level: 'A1', tags: ['noun'] }, + { front: 'het pond', back: '500 grams — what a Dutch market means by a pound', level: 'A2', tags: ['noun'] }, + { front: 'een onsje', back: '100 grams, roughly — always asked for in the diminutive', level: 'B1', tags: ['noun'] }, + { front: 'vers', back: 'fresh', level: 'A1', tags: ['adj'] }, + { front: 'goedkoop', back: 'cheap', illustration: '1F4B0', level: 'A1', tags: ['adj'] }, + { front: 'mag het ietsje meer zijn', back: 'can it be slightly more — the question every counter asks', level: 'B1', tags: ['phrase'] }, ], }, { @@ -337,6 +434,9 @@ export const SEED: Record = { { front: 'thuiswerken', back: 'to work from home', illustration: '1F3E0', level: 'A2', tags: ['verb'] }, { front: 'het rooster', back: 'the schedule, the rota', illustration: '23F0', level: 'B1', tags: ['noun'] }, { front: 'het overleg', back: 'talking it through together before deciding', level: 'B1', tags: ['noun'] }, + { front: 'de deadline', back: 'the deadline — borrowed whole', illustration: '23F0', level: 'A2', tags: ['noun'] }, + { front: 'even bellen', back: 'to give someone a quick call', level: 'A2', tags: ['phrase'] }, + { front: 'ik ben er even niet', back: "I'm away for a bit", level: 'B1', tags: ['phrase'] }, ], }, // Dutch particles: the hardest thing to look up and the easiest to drill. @@ -354,6 +454,12 @@ export const SEED: Record = { { front: 'gezellig', back: 'warm, companionable, good to be in', level: 'B1', tags: ['adj'] }, { front: 'maar', back: 'go on, help yourself — not the "but" you know', level: 'B2', tags: ['particle'] }, { front: 'eens', back: 'sometime — turns an order into a suggestion', level: 'B2', tags: ['particle'] }, + { front: 'zeg', back: 'hey, say — tacked on to get attention', level: 'B1', tags: ['particle'] }, + { front: 'dus', back: 'so — often just a filler on the way to the point', level: 'A2', tags: ['particle'] }, + { front: 'echt waar', back: 'really? — said back to something surprising', illustration: '1F440', level: 'A2', tags: ['phrase'] }, + { front: 'eigenlijk', back: 'actually, when you think about it', illustration: '1F914', level: 'A2', tags: ['adv'] }, + { front: 'gewoon', back: 'just, simply — and also ordinary', level: 'A2', tags: ['adv'] }, + { front: 'misschien', back: 'maybe', level: 'A1', tags: ['adv'] }, ], }, ], @@ -373,6 +479,14 @@ export const SEED: Record = { { front: 'a fuego lento', back: 'on a low heat', phonetic: '/a ˈfwe.ɣo ˈlen.to/', illustration: '1F525', level: 'B1', tags: ['phrase'] }, { front: 'el aliño', back: 'the dressing', phonetic: '/el aˈli.ɲo/', illustration: '1F957', level: 'B1', tags: ['noun'] }, { front: 'soso', back: 'bland, under-salted', phonetic: '/ˈso.so/', illustration: '1F615', level: 'B1', tags: ['adj'] }, + { front: 'el horno', back: 'the oven', illustration: '1F525', level: 'A1', tags: ['noun'] }, + { front: 'la olla', back: 'the pot', level: 'A1', tags: ['noun'] }, + { front: 'el cuchillo', back: 'the knife', level: 'A1', tags: ['noun'] }, + { front: 'probar', back: 'to taste — and to try anything', level: 'A2', tags: ['verb'] }, + { front: 'aliñar', back: 'to dress a salad', level: 'B1', tags: ['verb'] }, + { front: 'el aceite de oliva', back: 'olive oil', level: 'A1', tags: ['noun'] }, + { front: 'a la plancha', back: 'cooked on the griddle', level: 'A2', tags: ['phrase'] }, + { front: 'está riquísimo', back: "it's delicious — the -ísimo does the work", illustration: '1F60A', level: 'A2', tags: ['phrase'] }, ], }, { @@ -387,6 +501,10 @@ export const SEED: Record = { { front: 'no tener pelos en la lengua', back: 'to speak bluntly', phonetic: '/no teˈneɾ ˈpe.los/', illustration: '1F624', level: 'C1', tags: ['idiom'] }, { front: 'echar de menos', back: 'to miss someone', phonetic: '/eˈtʃaɾ de ˈme.nos/', illustration: '1F494', level: 'B1', tags: ['idiom'] }, { front: 'dar en el clavo', back: 'to hit the nail on the head', phonetic: '/daɾ en el ˈkla.βo/', illustration: '1F528', level: 'B2', tags: ['idiom'] }, + { front: 'costar un ojo de la cara', back: 'to cost an eye from your face', illustration: '1F440', level: 'B2', tags: ['idiom'] }, + { front: 'estar como una cabra', back: 'to be completely mad', level: 'B2', tags: ['idiom'] }, + { front: 'ponerse las pilas', back: 'to get your act together — put your batteries in', illustration: '1F4AA', level: 'B1', tags: ['idiom'] }, + { front: 'tirar la toalla', back: 'to throw in the towel', level: 'B1', tags: ['idiom'] }, ], }, { @@ -403,6 +521,12 @@ export const SEED: Record = { { front: 'poder', back: 'to be able — yo puedo', phonetic: '/poˈðeɾ/', illustration: '1F4AA', level: 'A1', tags: ['verb'] }, { front: 'huir', back: 'to flee — yo huyo', phonetic: '/wiɾ/', illustration: '1F3C3', level: 'B1', tags: ['verb'] }, { front: 'valer', back: 'to be worth — yo valgo', phonetic: '/baˈleɾ/', illustration: '1F4B0', level: 'B1', tags: ['verb'] }, + { front: 'venir', back: 'to come — vengo, vienes, viene', level: 'A1', tags: ['verb'] }, + { front: 'poner', back: 'to put — pongo, and puse in the preterite', level: 'A2', tags: ['verb'] }, + { front: 'salir', back: 'to leave, to go out — salgo', illustration: '1F6AA', level: 'A1', tags: ['verb'] }, + { front: 'conocer', back: 'to know a person or place — conozco', illustration: '1F91D', level: 'A2', tags: ['verb'] }, + { front: 'pedir', back: 'to ask for — pido, pidió', level: 'A2', tags: ['verb'] }, + { front: 'dormir', back: 'to sleep — duermo, durmió', illustration: '1F634', level: 'A1', tags: ['verb'] }, ], }, { @@ -420,6 +544,11 @@ export const SEED: Record = { { front: 'el horario', back: 'the timetable', illustration: '23F0', level: 'A2', tags: ['noun'] }, { front: 'perder el tren', back: 'to miss the train', illustration: '1F682', level: 'A2', tags: ['verb'] }, { front: 'hacer transbordo', back: 'to change lines partway', level: 'B1', tags: ['verb'] }, + { front: 'la vuelta', back: 'the change, and the return', level: 'A2', tags: ['noun'] }, + { front: 'perderse', back: 'to get lost', level: 'A2', tags: ['verb'] }, + { front: 'a la vuelta de la esquina', back: 'just around the corner', level: 'B1', tags: ['phrase'] }, + { front: '¿está lejos?', back: 'is it far?', level: 'A1', tags: ['phrase'] }, + { front: 'todo recto', back: 'straight ahead', level: 'A1', tags: ['phrase'] }, ], }, { @@ -436,6 +565,13 @@ export const SEED: Record = { { front: 'me hace ilusión', back: 'I am looking forward to it', illustration: '1F929', level: 'B1', tags: ['phrase'] }, { front: 'estoy hecho polvo', back: 'I am shattered — literally, turned to dust', illustration: '1F62A', level: 'B1', tags: ['phrase'] }, { front: 'me da corte', back: 'it makes me self-conscious', illustration: '1FAE2', level: 'B1', tags: ['phrase'] }, + { front: 'tener ganas de', back: 'to feel like doing something', level: 'A2', tags: ['phrase'] }, + { front: 'estar harto', back: 'to be fed up', illustration: '1F624', level: 'B1', tags: ['phrase'] }, + { front: 'darle vergüenza', back: 'to be embarrassed — the shame gives itself to you', illustration: '1F971', level: 'B1', tags: ['phrase'] }, + { front: 'estar agobiado', back: 'to be overwhelmed', illustration: '1F630', level: 'B1', tags: ['adj'] }, + { front: 'qué rabia', back: 'how annoying', illustration: '1F620', level: 'B1', tags: ['phrase'] }, + { front: 'estar ilusionado', back: 'to be excited about something coming', illustration: '1F389', level: 'B1', tags: ['adj'] }, + { front: 'tener sueño', back: 'to be sleepy', illustration: '1F634', level: 'A1', tags: ['phrase'] }, ], }, { @@ -453,6 +589,9 @@ export const SEED: Record = { { front: 'a pesar de', back: 'in spite of', level: 'B2', tags: ['phrase'] }, { front: 'no obstante', back: 'nevertheless', level: 'C1', tags: ['phrase'] }, { front: 'de ahí que', back: 'hence, and that is why', level: 'C1', tags: ['phrase'] }, + { front: 'en cambio', back: 'whereas, on the other hand', level: 'B1', tags: ['linking'] }, + { front: 'o sea', back: 'that is to say — and a filler everywhere', level: 'B1', tags: ['linking'] }, + { front: 'de hecho', back: 'in fact', level: 'B1', tags: ['linking'] }, ], }, ], diff --git a/src/shell/AppShell.tsx b/src/shell/AppShell.tsx index 4d57a65..d27b61b 100644 --- a/src/shell/AppShell.tsx +++ b/src/shell/AppShell.tsx @@ -215,6 +215,8 @@ export function AppShell() { * all, and the error went nowhere because the handler was async. */ const [naming, setNaming] = React.useState(false); + /* Flashcards and everything under it — a deck, a card, a session. */ + const onCards = location.pathname.startsWith('/app/cards') || location.pathname.startsWith('/app/review'); const [deckName, setDeckName] = React.useState(''); const newDeck = async () => { @@ -354,23 +356,10 @@ export function AppShell() {
-
+ {/* Just the label now. The plus that used to sit at its right has + moved to the top bar, where a phone can reach it too. */} +
Decks - {/* Stays a bare button rather than becoming an IconButton: this sits - in an 11px caps label row, and the smallest IconButton is a 28px - box — 44 under a finger — which would make the plus heavier than - the word it sits beside and push the deck list down. It borrows - the sound instead: `tap`, the default an IconButton here would - have had, because this is a press that goes and does something - rather than one that flips a state. */} -
@@ -390,7 +379,7 @@ export function AppShell() { })} {!visibleDecks.length && ( - {search ? 'No deck matches that.' : 'No decks yet. Add one with the plus above.'} + {search ? 'No deck matches that.' : 'No decks yet. Add one with the plus in the bar.'} )}
@@ -543,6 +532,29 @@ export function AppShell() { {title} + {/* + * New deck, in the bar rather than in the deck sidebar's header. + * + * It lived beside the word "Decks" in that sidebar, which a phone + * never shows — so the one way to write your own deck was reachable + * on a desktop and nowhere else, on an app whose whole point is the + * cards you write. The bar is on every screen and every size. + * + * Only inside Flashcards, though. It is an action on decks, and a + * button that makes one while you are reading an etymology is a + * control that has wandered away from what it acts on. + */} + {onCards && ( + + { playSound('tap'); setDeckName(''); setNaming(true); }} + > + + + + )} {/* Filled by whichever screen renders ; empty otherwise. */} {/* Parts the screen's own actions from the streak and the help menu. diff --git a/src/tools/flashcards/DeckList.tsx b/src/tools/flashcards/DeckList.tsx index 949ec6e..9a33aa8 100644 --- a/src/tools/flashcards/DeckList.tsx +++ b/src/tools/flashcards/DeckList.tsx @@ -129,7 +129,7 @@ export function DeckList() { {remaining > 0 && (
)} @@ -142,11 +142,16 @@ export function DeckList() { icon="layers" accent="var(--tool-flashcards)" title="No decks yet" - description={`A pack is a rule, the words that exercise it, and the verbs to drill it on. There ${remaining === 1 ? 'is 1' : `are ${remaining}`} for ${workspace.name}.`} + /* Both ways in, because the catalogue is a starting point rather + than the only source of cards. Writing your own has always been + the plus in the sidebar, and an empty state that only offered the + catalogue made it look like the app's decks were the app's to + give. */ + description={`Add one of the ${remaining} ready-made ${workspace.name} decks — a rule, the words that exercise it, and the verbs to drill it on — or write your own from the plus in the sidebar.`} action={
} diff --git a/src/tools/flashcards/PackCatalogue.tsx b/src/tools/flashcards/PackCatalogue.tsx index d18d11b..488b4c9 100644 --- a/src/tools/flashcards/PackCatalogue.tsx +++ b/src/tools/flashcards/PackCatalogue.tsx @@ -8,6 +8,13 @@ import { HAS_CONJUGATION } from '../../data/conjugation'; /** * The catalogue: grammar themes you can add to this workspace. * + * Called decks to the reader and packs in the code, deliberately. What arrives + * is a deck — the word the app already uses for a set of cards you practise — + * and inventing a second one for the thing that becomes a deck would make + * people learn a word for something they can already name. The code cannot + * borrow it: `Deck` is the record this installs, so `Pack` stays as the name + * for the offer rather than for the thing offered. + * * Content used to be pushed. Every install wrote twenty-two decks across four * languages whether or not the reader would ever open them, which left nothing * to choose and no way to tell what you had picked from what you had been @@ -126,10 +133,10 @@ export function PackCatalogue({ open, onClose }: { open: boolean; onClose: () =>