A customizable flash-card study app where teachers ("admins") publish class-wide decks and any visitor can also build their own personal decks.
Study Stacks is a single-player learning loop with three study modes (Flip, Quiz, Sprint), per-card mastery, daily streaks, and ecosystem badges. Decks come in two flavors: ecosystem decks (admin-authored, account-wide, every visitor sees them) and user decks (visitor-authored, cross-world, private to the owner). Both kinds live side-by-side in the same Library.
Students and admins use the same Library. Eligibility-gated actions show or hide inside the per-deck modal based on isAdmin and the deck's scope — there is no separate Admin view.
- Class decks — every ecosystem deck (drafts hidden from non-admins).
- My decks — the visitor's own user decks (always visible to them, drafts included).
- + Create a new deck — open to every visitor; creates a user-scoped deck by default. Admins can flip the scope to
ecosysteminside EditDeck.
Clicking any deck opens a portal-mounted modal whose header shows the deck title, card count, and difficulty, plus an icon row of whichever of these the current visitor is allowed:
- View Analytics — admin + ecosystem only. Opens a printable per-student leaderboard in a new browser tab via
openResultsInNewTab— the canonical "give the admin a results export" pattern across Topia SDK apps (replaces CSV download). - Edit — owner gate: user decks for their owner, ecosystem decks for admins. Switches to the EditDeck screen.
- Delete — same gate as Edit. Opens an inline
ConfirmationModal; on confirm the DELETE fires and the dispatch updates from inside the modal itself.
Below the icon row are the three mode cards: Flip, Quiz, Sprint. Pick one → modal closes, the study session starts.
- Flip — read the front (text + optional image), recall, tap to flip, self-rate (Got it / Almost / Missed).
- Quiz — multiple choice with the correct back + up to 3 distractors sampled from sibling cards. Decks with fewer cards still render the same UI with whatever distractors are available. Distractors are deduped case-insensitively (so a
"4+4 → 8"and"2+6 → 8"pair won't produce two "8" options). - Sprint — 60-second timed Quiz-style challenge with a pulsing timer.
Server picks each session's cards using a small, explainable SM-2-style algorithm — see Spaced repetition.
- 3D card flip on tap; correct-flip micro-celebration (green burst + optional soft chime).
- Wrong-answer feedback reveals the correct back so students still learn.
- Streak ring visible during a session; pulses on increment.
- Badge grants fire a Topia toast.
- End-of-session screen with concrete numbers, streak callout, new-badge celebration, and a "Study Again" CTA that re-opens the mode picker for the same deck.
- Mute toggle in the Home greeting;
prefers-reduced-motiondisables every animation.
- CardEditor — front (text + optional image URL), back, optional hint; reorder via up/down buttons.
- ImportCardsModal — bulk-paste / CSV import for large decks.
- Save is blocked when the deck has zero cards OR any incomplete card; the form names the offenders. A card is "complete" when it has a back and either front text or a front image URL (
isCardCompletein shared types). - Decks cap at 100 cards (
MAX_CARDS_PER_DECK), server-enforced. - Grades only apply to ecosystem decks. Ecosystem decks default to the
"all"sentinel; an explicitGradeType[]is stored only when you pick a real subset. HelpersexpandGrades/normalizeGrades/isAllGradeskeep the rest of the code from caring about the encoding.
Study Stacks uses the dropped asset that triggered the iframe as the entry point for the app. Decks do not live on the key asset — ecosystem decks live on the ecosystem data object (account-wide), and user decks live on the visitor data object (cross-world per visitor). That means dropping multiple Study Stacks desks in one world is fine: they all show the same decks and the same per-visitor mastery.
| Unique Name Pattern | Description |
|---|---|
StudyStacks_keyAsset |
(Convention) Unique name to give the desk. Optional today — the iframe-launching asset is used directly; this is reserved for v2+. |
The app calls Ecosystem.grantInventoryItem with the exact name strings below. Provision a BADGE item for each one to enable that badge. If a badge isn't provisioned, the eligibility check still runs but the grant is a no-op (logged warning) — students simply won't see that badge in the catalog until it's provisioned.
| Unique name (placeholder) | Title | Trigger |
|---|---|---|
FirstStep |
First Step | Complete your first session. |
Bookworm |
Bookworm | 50 cards studied lifetime. |
Scholar |
Scholar | 250 cards studied lifetime. |
Master |
Master | 1,000 cards studied lifetime. |
DeckDone |
Deck Done | Reach mastery 5 on every card in any deck. |
Polyglot |
Polyglot | Earn Deck Done on decks in 3 different subjects. |
ComebackKid |
Comeback Kid | Move a card from mastery 0 or 1 to mastery 5 in one session. |
Streaker |
Streaker | 7-day streak. |
Marathoner |
Marathoner | 30-day streak. |
SpeedDemon |
Speed Demon | Score 20+ correct in one Sprint session. |
Perfectionist |
Perfectionist | 100% accuracy on a Quiz session of 10+ cards. |
Study Stacks lives on two SDK-owned data objects. Nothing on the key asset, nothing on the world.
{
studyStacksDecks: { [deckId: string]: DeckType }, // every ecosystem deck
}Each ecosystem DeckType carries its own results map for the per-deck leaderboard:
deck.results = { [profileId: string]: "{displayName}|{sessions}" }(Pipe-delimited string, mirroring the leaderboard convention used elsewhere in the stack.)
{
studyStacksDecks: { [deckId: string]: DeckType }, // this visitor's personal decks
studyStacksData: {
decks: { [deckId]: { cards: { [cardId]: CardMasteryType }, sessionsCompleted, lastStudiedAt } },
streak: { current, longest, lastDay }, // lastDay is YYYY-MM-DD in UTC
totalCardsStudied: number,
totalSessionsCompleted: number,
},
}A DeckType has id, scope ("user" | "ecosystem"), title, subject, difficulty, status ("draft" | "published"), and cards. Ecosystem decks additionally carry grades, createdByProfileId, createdByDisplayName, and results. User decks omit those four fields entirely.
A CardType has id, front, back, optional hint, and optional imageUrl (http(s) only; sanitized server-side).
Full definitions and helpers (expandGrades, normalizeGrades, isAllGrades, isCardComplete, parseDeckResultsValue, formatDeckResultsValue, STUDY_STACK_BADGES) live in shared/types/StudyStacksTypes.ts.
server/utils/computeNextCards.ts is a small, explainable SM-2-style ranker:
priority(card) = (5 - mastery) * 2 + timesWrong + recencyPenalty(lastSeenAt)recencyPenaltyramps from 0 to a cap of 2 as days-since-last-seen grows past 6.- For Flip / Quiz, the top-N cards by priority are picked.
- For Sprint, the priority is bypassed — the deck is shuffled and looped until the timer expires.
- For Quiz, three distractors are sampled server-side per card so the client never sees an answer it doesn't render. Distractors are deduped against the correct answer AND against each other (case-insensitive trim).
server/utils/evaluateBadges.ts is a pure function over studyDataAfter + the session result + the full decks map. It produces the set of badges the visitor newly qualifies for, then awardBadge grants each one idempotently (no-op if the visitor already owns it).
Every card you study carries a mastery level from 0 to 5 (MasteryLevelType is typed 0|1|2|3|4|5). It changes each time you answer that card, in handleAnswerCard.ts, then is clamped back into 0–5:
| Mode | Result | Mastery |
|---|---|---|
| Flip | ✓ Got it | +2 |
| Flip | ◐ Almost | 0 |
| Flip | ✗ Missed | −1 |
| Quiz | correct | +1 |
| Quiz | wrong | −1 |
| Sprint | correct | +1 |
| Sprint | wrong | 0 (never drops) |
newMastery = clamp(0, 5, prev + delta).
A card is "mastered" when its mastery hits 5. Starting from 0, that takes:
- 3 "Got it"s in Flip (0→2→4→5, the last +2 clamps at 5),
- 5 correct answers in Quiz or Sprint.
Wrong answers can knock a card back down (except Sprint, which only ever holds or raises).
This shows up in two distinct places:
a) The mastery % ring on each deck card (Deck.tsx) is an average across all cards:
mastery% = (sum of every card's mastery) / (cardCount × 5) × 100
So the ring reads 100% only when every card is at level 5. It's a continuous progress indicator, not a pass/fail flag.
b) "Fully mastered" for the Deck Done badge (evaluateBadges.ts) — the strict definition:
A deck is fully mastered when it has at least one card and every card's mastery ≥ 5.
This is evaluated server-side at the end of each session (handleCompleteSession.ts). When it's true:
- you earn the Deck Done badge, and
- if you've fully mastered decks across 3+ different subjects, you also earn Polyglot.
Both definitions agree: a deck is "mastered" ⟺ 100% ring ⟺ all cards at level 5.
All routes accept the standard Topia session credentials (assetId, interactiveNonce, interactivePublicKey, profileId, sceneDropId, urlSlug, visitorId, …) via query parameters — client/src/utils/backendAPI.ts attaches them automatically.
| Method | Route | Body / Query | Description | Admin gate? |
|---|---|---|---|---|
GET |
/api/config |
— | Merged config: ecosystem decks (drafts hidden from non-admins), user decks, visitor study data, badges, isAdmin. | — |
GET |
/api/decks/:deckId |
?scope=user|ecosystem |
One deck + this visitor's mastery for it. | — |
POST |
/api/decks |
{ deck, scope } |
Create or update a deck. Routes to the right data object by scope. |
Ecosystem only |
DELETE |
/api/decks/:deckId |
?scope=user|ecosystem |
Delete a deck. (Visitor mastery is keyed by cardId and is preserved.) |
Ecosystem only |
POST |
/api/session/start |
{ deckId, mode, scope } |
Mint a sessionId and return the chosen card sequence (plus distractors for quiz/sprint). | — |
POST |
/api/session/answer |
{ sessionId, cardId, mode, rating? | isCorrect?, msTaken } |
Record one card answer; returns the updated mastery. | — |
POST |
/api/session/complete |
{ sessionId } |
Finalize the session: update streak/totals, evaluate badges, write per-deck leaderboard row for ecosystem scope. | — |
GET |
/api/system/health |
— | Health check (build version + env names). | — |
Every endpoint returns { success: true, ...data } on success or { success: false, message } on failure (per .ai/rules.md RESPONSE SCHEMA).
When ecosystem inventory is updated, append ?forceRefreshInventory=true to the iframe URL — the client reads it from useSearchParams and forwards it to /api/config, which busts the 6-hour ecosystem inventory cache.
Create a .env file in the repo root (one level above this app's directory).
| Variable | Description | Required |
|---|---|---|
INTERACTIVE_KEY |
Topia interactive app key | Yes |
INTERACTIVE_SECRET |
Topia interactive app secret | Yes |
INSTANCE_DOMAIN |
Topia API domain (api.topia.io for production, api-stage.topia.io for staging) |
Yes |
INSTANCE_PROTOCOL |
https for production/staging, http only for local |
Yes |
NODE_ENV |
Node environment | No |
PORT |
Server port (defaults to 3000) |
No |
INTERACTIVE_KEY / INTERACTIVE_SECRET come from the Topia integrations dashboard (or dev dashboard for staging).
# from this app's root
npm install
cd client && npm install && cd ../server && npm install && cd ..
# create a .env one level above the app root, populated with the variables above
# run the dev server (Vite client + Express server concurrently)
npm run dev
# server-only
npm run dev --workspace=server
# run server tests (Jest)
npm --prefix server testThis project uses the Topia SDK's CSS classes for chrome (.btn, .card, .input, .h1–.h4, .p1–.p4), layered with Tailwind for layout via CSS cascade layers (@layer tailwind, sdk;). App-specific component classes are prefixed .ss-* and live unlayered in client/src/styles/components.css. Design tokens (--ss-*) live in client/src/styles/tokens.css.
Every UI change must meet WCAG 2.1 AA. The Study Stacks UI in particular:
- All interactive elements are
<button>/<a>/<input>with visible:focus-visiblerings. - Card flip + correct/wrong feedback all gate on
prefers-reduced-motion. - Min student body text 18px; admin authoring uses ~16px to fit the dense forms.
- Tap targets ≥ 44px in student flows.
- No information conveyed by color alone — correct/wrong always pair color with an icon and a text label.
If anything about how the SDK works is unclear (Interactive Keys, JWT signing, iframes vs webhooks, session credentials, dropped-asset operations, backend validation), read .ai/sdk-fundamentals.md.
- SDK Developer docs
- Implementation reference:
.ai/templates/plan.md - Style guide:
.ai/style-guide.md· Accessibility:.ai/accessibility.md