Imported from xiangthebung/totem (
AGENTS.md). Install upstream withnpx skills add xiangthebung/totem. Copyright stays with the author.
Working on Totem
The user-facing description is in README.md and every sentence in it is
checked by src/lib/__tests__/docs.test.ts. This file is the other half: how to
navigate the code, and the things that will make you ship a bug that looks
correct in a diff.
CLAUDE.md is a one-line pointer at this file.
Expo has changed
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
writing any code. SDK 57 moved several APIs that older answers still describe —
expo-audio replaced expo-av, expo-file-system is class-based (File,
Directory, Paths), and expo-notifications handlers gained
shouldShowBanner/shouldShowList.
Verify with
npm run check
typecheck → lint → test → verify:icons → verify:motion. All five pass
on a clean tree. The tests need no simulator and no network; they cover
src/lib only, which is deliberate — everything in there is pure and imports
nothing from React Native, which is what makes the suite run in under two
seconds. Nothing under src/app or src/components is tested.
To see it actually run, npx expo export --platform web and serve the output;
do not start expo start from a script that has to return.
Navigation map
The data layer
src/lib/types.ts The whole data model. A task carries two independent
clocks — `dueAt` (when the chore is due) and `recallDueAt`
(when your memory of its glyph needs practice). Neither
drives the other, and confusing them is the single easiest
way to break the design.
src/lib/store.ts zustand + AsyncStorage. Every action is synchronous and
free of I/O; anything touching the OS is driven off the
resulting state elsewhere. `merge`/`normalizeTask` repair
persisted tasks, which is where fields added in later
versions get their defaults.
src/lib/query.ts Every lens the screens look through, as pure functions
over an array. Reads no clock — `now` is always a
parameter.
src/lib/date.ts Calendar-day arithmetic in the device's local zone.
src/lib/recurrence.ts Repeat rules. `nextOccurrence` must return a moment
strictly in the future, or null.
src/lib/memory.ts SM-2 in miniature. `applyGrade` is pure, which is what
lets the drill screen run it speculatively and print the
resulting interval on each button. `buildDrill` decides
which cards a run hands out and which of them may be
graded; `shouldNudge` is the day-one offer.
src/lib/reorder.ts Drag-to-reorder as arithmetic: which slot a finger
means, who slides to make room. No renderer needed.
src/lib/parse.ts Quick-add. Long, and the length is nearly all gates: a
phrase is only read as a date when the sentence around it
says it is one.
src/lib/totem.ts The vocabulary (24 colours, 200 objects, 16 motions of
which 5 are assignable), `randomTotem`, and the WCAG
colour maths every legibility decision runs through.
src/lib/soundmap.ts Object -> tone family. Keyed by plain string, not by the
icon union, so a new object cannot break the build for the
sake of a sound.
src/lib/tone-assets.ts GENERATED by scripts/gen-tones.mjs. Do not edit by hand.
src/lib/palette.ts The design tokens. No React, no store — that is what lets
the contrast rules be asserted in a Node test.
src/theme.ts `usePalette()` plus a re-export of lib/palette. Screens
import from here.
src/lib/a11y.ts Reduce Motion, OS setting and in-app switch combined.
src/lib/notify.ts Local reminders. The whole schedule is torn down and
rebuilt from the task list on every change.
src/lib/audio.ts Tone playback (voice pool, fades) and voice-memo files.
src/lib/memos.ts Which recordings may be deleted. Pure: the filesystem
arrives as the `MemoStorage` port that audio.ts
implements, which is what makes the undo window — the
part that can delete the wrong recording — testable.
The screens
src/app/_layout.tsx Root: theme, store hydration, reminder reconcile.
src/app/(tabs)/index.tsx Today. Overdue, due, completed-today, drill bar.
src/app/(tabs)/upcoming.tsx One heading per day from tomorrow on.
src/app/(tabs)/browse.tsx Smart views, lists, tags.
src/app/(tabs)/search.tsx Text plus the filter panel. Always reveals titles.
src/app/compose.tsx The add screen. Deals the totem before a word is
typed; writes the task exactly once, on the button.
src/app/task/[id].tsx The editor. Every field writes straight to the store.
src/app/recall/[id].tsx One card's recall.
src/app/review.tsx The drill. Queue frozen at start — see below.
Three modes: due (graded), quick (the nudge,
ungraded), all ("Quiz me anyway", graded only
where due). Keyed on the mode so switching remounts.
src/app/onboarding.tsx The tour. Opens itself once (`settings.introSeen`),
replays from Settings. Deals a real totem.
src/app/settings.tsx Including the symbol legend.
src/components/TaskSections.tsx
Every list body, and the drag-to-reorder: a long
press lifts a row, the section's own PanResponder
follows it, `store.reorderTasks` writes it back.
src/components/RecallNudge.tsx
"Can you name these three?" — shown by `shouldNudge`.
src/components/motion.ts All sixteen motions, their keyframes, and the fit
solver. Pure; no store behind it, which is what lets
scripts/verify-motion.mjs load it standalone.
Task-to-file index
| Want to | Go to |
|---|---|
| change what a row shows | components/TaskRow.tsx, then components/TaskSections.tsx |
| add a quick-add phrase | lib/parse.ts (a take(...) pass), then a test in lib/__tests__/parse.test.ts, then the README's list — docs.test.ts checks the two agree |
| add a totem object | scripts/verify-icons.mjs CANDIDATES, run it with --print, paste into lib/totem.ts, then map a sound in lib/soundmap.ts |
| add a motion | types.ts MotionKind, components/motion.ts (TRACKS, MOTION_DURATION, MOTION_LABEL), lib/mnemonics.ts MOTION_PARTICIPLE, lib/totem.ts MOTIONS. All four are type-enforced except MOTIONS. |
| change a colour | lib/palette.ts — and expect __tests__/palette.test.ts to tell you if it stops clearing AA |
| change what a reminder says | lib/notify.ts |
| change the tour | app/onboarding.tsx; the closing buttons are the only thing that writes revealAll |
| change what a drill asks or records | lib/memory.ts buildDrill, then app/review.tsx |
| change how a drag lands | lib/reorder.ts (tested), then the responder in components/TaskSections.tsx |
Invariants
-
Nothing in
lib/query.tsmay read the clock.nowis a parameter everywhere. This is what lets a ticking screen move a task from Upcoming into Today without writing to the store, and what makes the whole file testable. -
Never add or subtract
86_400_000from a local midnight. Local midnights are 23 or 25 hours apart on the two days a year the clocks change, so the sum lands an hour off and silently misses the day. UseaddDaysanddaysBetweenfromlib/date.ts. This bug shipped in four separate places (forSmartView,countViews,splitByWeek,completionStreak) and in the fortnightly-repeat cycle check, which is why it is written down here rather than fixed once. Elapsed durations —now + intervalDays * DAY_MS— are fine; it is only calendar boundaries that are affected. -
A selector that builds a fresh array must go through
useShallow. zustand v5 compares selector output withObject.is, so subscribing to a raw.filter()re-renders forever.lib/store.tsexports pre-wrapped hooks so a call site cannot reintroduce it. -
A totem is assigned once and never reassigned. Motions are stored by name, so all sixteen must keep rendering even though only five can be dealt. Narrow assignment (
ASSIGNABLE_MOTIONS), never the type. -
No two open tasks may share a colour+object pair.
randomTotemenforces it, and anything that creates a task — includingduplicateTask— has to go through it rather than copying a totem. -
strength: 0means "no evidence", not "forgotten". Anything that renders a verdict has to checkhasBeenReviewedfirst, which is why that argument onstrengthLabelis required rather than optional. -
Tap targets are
minWidth/minHeight, neverhitSlop.react-native-web'sPressableignoreshitSlopoutright, so a slop-based target is its declared size on the web build. UseHITfrom the theme. -
Animations take
NATIVE_DRIVERfromcomponents/motion.ts. HardcodinguseNativeDriver: truefills the browser console with a paragraph aboutpod installon every animation. -
No gesture library.
react-native-reanimated,react-native-gesture-handlerandreact-native-workletswere removed because nothing imported them. Swipe and drag-to-reorder both run onPanResponder; adding a library back for one gesture would mean two gesture systems negotiating over the same rows. -
A press inside a swipeable row asks
swiped()first.SwipeableRowhands it to its function child; every control inTaskRowchecks it before acting. On the web a mouse swipe ends in aclickon whichever control it started over, and a row that answered it opened the task it had just deleted. It is a render prop, not a context: the first version was a context, the row called the hook one component above the provider and read the default "no" every time. -
Every
TextInputspreadsinputResetfrom@/theme. It is the browser's focus ring, off, and nothing on a device.
Traps
Looks wrong, is correct.
revealAlldefaults totrueand the Settings row is its inverse. The stored flag describes the app's default state (names visible); the row asks you to turn on the interesting behaviour. Both are deliberate; changing either without the other inverts the feature.TotemGlyphdraws the artwork smaller than thesizeit is given. That headroom is the fix for totems being cropped mid-motion, andartFractionsolves for it per motion. Drawing atsizeputs the ink outside the box.- The bloom behind a glyph is painted from the corrected colour, not the raw hex. Using the raw hex renders one totem in two different colours in one row.
describeRecurrencesorts the weekdays before comparing. The stored order is whatever the picker produced.
Looks fine, is not.
expo-env.d.tsand.expo/typesare gitignored and generated. Typecheck was measured to pass without them on a fresh clone, but if that changes, this is where to look.- The web build is
output: "single". It wasstatic, which prerendered the light theme and hydrated into the dark one — a React #418 on every load, visible only in the console. - A monthly repeat on the 29th–31st settles onto the shortest month it meets
once it has been completed through a February, because the rule stores no
anchor day.
nextOccurrencemeasures each occurrence from the anchor rather than from the previous result, which fixes the catch-up case but not this one. Documented in the README; fixing it properly means a new field onRecurrence. - The drill's queue is frozen in
useStatewhen the screen mounts. Grading movesrecallDueAt, so a live queue would re-sort underneath the user and make the card they are looking at vanish. Whether a card is graded is frozen with it (DrillCard.graded): in "Quiz me anyway" a grade on one card must not change whether the next one counts. TAB_BAR_HEIGHThas a web branch (58). A browser lays the label out at its line height rather than its font size, and at 49 the labels sat half below the window edge.reorderTasksreassigns the order slots the moved set already held. Numbering the set from zero looked simpler and interleaved every list's 0..n in the All view.
The bug ledger
| Symptom | Root cause | Why it was not caught | The class |
|---|---|---|---|
| A task due 00:30 tomorrow appeared in Today, twice a year | startOfDay(now) + 86_400_000 for "tomorrow" |
No test ran in a zone with daylight saving; the suite did not exist at all | A millisecond span standing in for a calendar day |
| A fortnightly repeat skipped a cycle in March | Math.floor(span / week) where the span crossed a spring-forward |
Same, plus the first test written for it used dates that straddled no transition — it passed against the broken code | A test that names the right bug but does not reach it |
| Ten of 24 hues sat below 3:1 on a white card | luminance used NTSC coefficients on gamma-encoded sRGB, so every correction was solving for the wrong quantity |
Contrast was reasoned about, never measured | Measuring the wrong quantity confidently |
| Four palette tokens failed WCAG AA on chips | Solved against the page and the card, never against surfaceAlt |
The README asserted AA; nothing checked it | A claim in prose that could have been a test |
| Settings taught sixteen movements when five are dealt | The legend read MOTIONS, not ASSIGNABLE_MOTIONS |
Nothing connects a glossary to the thing it describes | A second list that has to be updated with the first |
| Duplicating a task gave the copy the original's totem and voice memo | { ...source } with no exceptions |
duplicateTask had no test and no caller anyone had exercised |
A spread that copies identity along with content |
| A saved recording could not be deleted without deleting its task | MemoRecorder was only ever mounted by the compose screen |
Nothing connects "the app can create this" to "the app can remove it" | A capability with no matching undo |
| Erase everything left recordings behind | resetAll deleted what the task list could name; an undo entry does not survive a relaunch, so a delete the app was closed on stranded the file |
The promise was in prose. Every test asked what the store held, none asked what was on disk | A claim in prose that could have been a test |
Rejected ideas
| Idea | Why not |
|---|---|
| Sixteen assignable motions | Measured at 46pt they sit inside a 3px envelope; wobble and sway differ by 1.5°. A vocabulary of sixteen that reads as five is worse than one of five. |
| A confirmation dialog on delete | Costs a tap every time you meant it to save the rare time you didn't. Undo toast instead. |
| A recall streak counter on Today | It rode on a self-reported grade, so it was a number you could inflate by pressing the easy button — and then the drill asked you to be honest about it. |
| Quizzing the whole deck in a drill | Spaced repetition earns its keep by leaving alone what you already know. The queue is the due set, capped at SESSION_CAP. |
Reading a bare 3/14 as a date |
"1/2" is a fraction at least as often as it is a date. Requiring the year costs almost nothing. |
Counting ! as a priority dial |
Nobody has ever typed one exclamation mark to mean "least urgent". Every run of bangs is High; p1–p4 is the dial. |
| Prerendering the web build | See the trap above. Nothing in this app is server-renderable. |