Skip to content
Cloud Hands home

Cloud Hands — Methodology

The single source of truth for this project. Brand, design system, evidence base, prescription logic, content policy and safety scope all live here. If code and this document disagree, one of them is a bug — say which in the PR.

Status: living document · Last substantive revision: 2026-08-01


1. What this is, and what it refuses to be

Cloud Hands is a free, public, no-login tai chi companion. You open it, it knows what day it is, and it gives you a session shaped by how steady you are on your feet, how long you have, and what you are training for.

It is:

  • A scheduler and timer wrapped around other people's teaching.
  • A rotation engine whose main job is making sure tomorrow doesn't feel like today.
  • An honest broker: every video is attributed, linked back, and listed in full on the Library page.

It is deliberately not:

  • A teacher. It does not correct your posture, it cannot see you, and it will never claim a movement is "correct".
  • A medical device or a clinical intervention. It summarises published evidence to explain why the sessions are shaped this way; it does not diagnose, treat, or personalise around a condition.
  • An account-based product. No login, no email, no server-side profile, no analytics. Everything lives in localStorage on one device.

1.1 Why no login

The target practitioner is disproportionately over 65. Every account wall is a drop-off, and a wellbeing tool that asks a 78-year-old to verify an email is a tool they open once. The costs of this decision are real and we accept them openly in the UI: no cross-device sync, and clearing site data clears the streak.


2. Brand

2.1 The name

Cloud Hands (雲手, yún shǒu) is an actual movement — the sideways, waist-driven weight transfer that appears in every major tai chi family. It was chosen over the alternatives because:

  • It describes the feeling of the practice (continuous, soft, circular) without being a wellness cliché.
  • It is a real term of art, so it earns credibility with people who already practise.
  • It is concrete. "Flow", "Balance", "Serenity" are adjectives pretending to be products.

2.2 Voice

Plain, calm, and slightly dry. Short sentences. Never breathless, never mystical, never a coach shouting encouragement.

We writeWe don't write
"Nothing to achieve here. Just stand.""Embrace your inner stillness!"
"Ache is fine; sharp is not.""Listen to your body 🙏"
"Rest is training, not a gap in it.""Don't break your streak!"
"This one won't play here.""Oops! Something went wrong."

Guilt is never used as a motivator. There is no "you missed 3 days" state, and a broken streak is reported without comment.

2.3 The mark

Two interleaved arcs — one rising, one sinking, sharing a centre — tracing the path the hands take in cloud hands. It carries the yin/yang exchange the practice is built on without using the taijitu, which is visually exhausted and reads as a logo from 1997. Single stroke weight, 24-unit grid, legible at favicon size.

2.4 Colour — deep night and moonlight

The app is dark by default, and dark is the designed state rather than an accommodation. Tai chi is practised at dawn, in a dim room, often before the lights go on. A bright white page at 6am is hostile. Light mode exists and is complete, but it is the same brand at noon, not a different one.

The screen is a night sky with one light source in it. Everything follows from that: the moon is fixed top-right, the glass panes' specular edges assume light from up and to the right, and no surface emits its own colour — they catch the moonlight instead.

Everything is oklch, so lightness ramps are perceptually even and the translucent layers mix predictably.

RampRoleWhy this hue
NightBackground and structureBlue-black, never neutral grey. The slight blue is what makes the accent read as light rather than paint
MoonlightPrimary. The single light source, and the accentPale desaturated blue. Used for the play control, the timer ring and the active state — and almost nothing else
CeladonThe practice colour: qigong, warm-up, gentleA jade held so far back it reads as glazed porcelain under moonlight, not as "wellness green"
LamplightToday, streaks, moderate effortThe only warm hue in normal use. Rationed hard, so it means something
EmberVigorous intensity, destructive actionsThe only hot hue. Appears on perhaps two surfaces in the whole app

Colour is never the only signal. Every phase chip pairs colour with a label and an icon; the intensity dots carry an sr-only text equivalent.

2.5 Type

Two families, chosen for the subject rather than for fashion.

  • Cormorant (display) — a calligraphic old-style with brush-like stroke modulation. It carries the ink-painting quality of the practice without resorting to a pastiche "oriental" face, which would be both ugly and faintly insulting. Display sizes only: it is far too fine to set body copy in, and we never do.
  • Inter (everything functional) — UI, controls, and crucially the timer. Large x-height, neutral, and real tabular figures.

Tabular numerals are forced on the timer and every value that changes in place, so digits don't jitter as they swap.


3. Design system

3.1 Token tiers

Three tiers, one direction of dependency. Components consume tier 3 only.

  1. Primitive--ch-moon-400, --ch-night-950. Raw brand values. Never referenced by a component.
  2. Semantic--primary, --phase-form, --glass-blur. Meaning-bearing, theme-switchable. This is the layer light/dark swaps.
  3. Component — Tailwind utilities generated in @theme inline (bg-phase-form, text-timer, rounded-3xl).

A new colour that goes straight into a component is a review comment, not a patch.

3.2 Liquid glass, specified

A pane is four stacked signals. Anything missing one of them looks like a translucent rectangle, not glass.

  1. Refractionbackdrop-filter: blur() saturate() brightness(). The saturate is what sells it; blur alone reads as frosted plastic. In the dark build the panes are smoked rather than frosted: fill drops to ~6% and the brightness lift does the work.
  2. Body — a translucent tint fill (--glass-tint / --glass-fill).
  3. Edges — a bright hairline on top (inset 0 1px 0) and a dark contact line on the bottom. This is what implies thickness.
  4. Specular — a diagonal highlight sweep via ::before, plus a 3.5% noise grain via ::after. The grain exists to defeat the plastic look of large blurred fills.

Exposed as @utility glass, glass-faint, glass-strong, glass-specular, glass-grain, glass-press, and consumed through one <Glass> primitive with three axes: depth, tone, radius.

Rule: never hand-roll backdrop-blur on a component. Use <Glass>, or add a variant.

Two things make this survivable rather than decorative:

  • The night sky. Glass only reads as glass if there is something behind it worth refracting. A fixed moon plus two slow, cold ground fields sit behind everything at -z-10. Pure CSS, no canvas, no rAF. A scrim over the top guarantees body-text contrast wherever a field happens to land — the sky is decoration, and text must never depend on it.
  • The @supports fallback. Where backdrop-filter is unsupported or disabled, fills jump to 88–98% opacity. Text contrast never depends on the blur landing.

3.3 Motion

RuleReason
Two easings only, both custom--ease-glass for material, --ease-settle for arrivals. More than two reads as inconsistency.
Nothing animates in from the sidePeripheral motion pulls the eye and breaks a standing posture. Cues cross-fade in place.
Ambient loops are 28–44 sSlow enough to be unnoticeable; anything faster becomes a metronome you fight.
prefers-reduced-motion kills drift…but keeps opacity and colour transitions, because those carry session state.

3.4 Accessibility commitments

  • Touch targets: 44px floor, 48px for transport, 64px for play.
  • Every control has a real aria-label; the timer ring is aria-hidden with a coarse sr-only live region so it isn't announced every second.
  • role="radiogroup" for the setup options, so arrow keys and position announcements work.
  • Skip link, visible focus ring, aria-current on nav and the active block.
  • No maximumScale. This app is aimed at people over 65; zoom is not optional.
  • Captions on by default in the player — much of the library is quiet, accented, or in Mandarin, and the viewer is across the room.

3.5 Responsive architecture

The first build broke below ~400px, which is unacceptable for an app whose users are most likely to open it on a phone propped against a lamp. The rules now:

  • Navigation moves, it does not shrink. Below md the header carries the brand mark and two controls only; the four nav destinations become a fixed glass bottom tab bar, safe-area aware, with icons and labels. Icon-only bars are unreadable to the age group this is built for.
  • overflow-x: hidden on html and body, and min-width: 0 on every element. Nothing is ever wider than the viewport; if something is, that is a bug — but a bug that must not become a sideways-scrolling page.
  • The week strip must fit seven cells at 330px. The text label drops below sm; the weekday initial, the date and the colour dot survive. Cells stay ≥44px tall regardless — it is the most-tapped control in the app.
  • Safe areas everywhere: env(safe-area-inset-*) on the header, the tab bar and the setup sheet.
  • The timer is sized against its ring, not the viewport. A vw-driven clamp overflowed the ring on wide screens.
  • font-size: max(16px, 1em) on controls, so iOS never zooms the page.

3.6 Internationalisation

The language is detected from the browser automatically on the first visit and then remembered.

  1. Read navigator.languages — the user's own ordered preference list — and take the first supported entry. Someone whose list is [ca, es, en] gets Spanish, not English.
  2. Match on the primary subtag only: es-419, es-MX and es all resolve to Spanish. Every Chinese variant resolves to Simplified — shipping zh-Hant without a real Traditional translation would be worse than showing Simplified.
  3. An explicit choice in the setup sheet overrides detection and is written to localStorage. "Match my browser" clears the override.

Shipping locales: English, Spanish, French, German, Simplified Chinese.

Two architectural decisions make this work rather than half-work:

  • The engine emits keys, never prose. prescribe and select are pure and locale-blind, so they return Note = { key, values } and the UI resolves it. Rationale, cautions and block notes are therefore translated, not just the chrome. Where a value is itself a translatable term ("you asked for gentle"), the engine emits it behind a NUL sentinel and translate resolves one level deep.
  • English is the source of truth and the type. Every other locale is typed Record<keyof typeof en, string>, so a missing key is a compile error. A missing key at runtime falls back to English rather than rendering the raw key.

Deliberately untranslated: rank names (proper nouns, like kyū and dan grades), video titles and channel names (attribution must match the source), and this document.


3A. Progression

Progress is measured in days you showed up — never minutes, never intensity. A five-minute seated session on a bad day counts exactly as much as a forty-five-minute form on a good one.

This is not a softening. Rewarding volume would push precisely the people this app is built for — older, stiffer, less certain — toward doing too much, which is the main way an exercise intervention causes harm.

Ranks (derived from completed days, never stored):

RankDaysRankDays
Beginner's Mind初心0White Crane白鶴60
First Light初光1Long River長江100
Still Water靜水5Bedrock磐石180
Green Bamboo青竹15Old Pine古松365
Moving Cloud行雲30

Milestones are earned, never lost. One of them — Came back — is explicitly for returning after a fortnight away, because that is harder than a streak and no habit tracker ever rewards it.

Anti-patterns this screen refuses:

  • No "you broke your streak" state, and no red anywhere.
  • Today not yet practised does not break the streak. The day isn't over.
  • No comparison to other people. There is nobody else here.
  • Everything is derived from the record log on every render. Storing a streak next to the history that produces it is how every habit tracker ends up displaying a number its own data contradicts.

4. The evidence base

Each claim below carries its source and its verification status: ✅ verified means the figures were checked against the source during authoring; ⚠️ unverified means it is stated from general knowledge and should be confirmed before it is used in any public-facing marketing claim.

4.1 Falls

  • Tai chi reduces the rate of falls by about 19% in community-dwelling older adults (RaR 0.81, 95% CI 0.67–0.99; 2,655 participants across 7 studies; low-certainty evidence). Sherrington et al., Cochrane Database of Systematic Reviews, 2019. ✅ verified
    • The same review rates balance and functional exercise higher: 24% reduction, high-certainty evidence (RaR 0.76, 95% CI 0.70–0.81; 39 studies). Tai chi is good, not best, and the certainty is lower.
  • In a high-risk population, a therapeutic tai ji quan protocol cut falls by 58% vs stretching and 31% vs multimodal exercise (IRR 0.42, 95% CI 0.31–0.56 vs stretching). Li et al., JAMA Internal Medicine, 2018, 670 adults aged 70+ with a fall history or impaired mobility. ✅ verified

What we take from this: balance work is the highest-value block for older practitioners, so the balance focus roughly doubles that phase, and the falls-prevention framing is honest rather than overstated.

4.2 Joints

  • For knee osteoarthritis, tai chi produced WOMAC improvements comparable to standard physical therapy at 12 weeks (tai chi 167 points, 95% CI 145–190; PT 143 points, 95% CI 119–167; between-group difference not significant). Wang et al., Annals of Internal Medicine, 2016, n=204. ✅ verified
  • Dr Paul Lam's Tai Chi for Arthritis (Sun style, high stance, no deep knee flexion) is the most widely disseminated protocol in this space and is the default form content for the joints focus and the 75+ band. ⚠️ unverified (programme popularity, not an efficacy claim)

4.3 Dose

  • WHO 2020 guidelines, adults 65+: 150–300 min moderate-intensity aerobic activity per week, muscle strengthening ≥2 days/week, and varied multicomponent activity emphasising functional balance and strength at moderate or greater intensity on ≥3 days/week. ✅ verified

What we take from this: the default plan is 5 days/week, and the weekly periodiser guarantees balance-emphasis appears on multiple non-consecutive days. A practitioner selecting fewer than 3 days is not warned — nagging is against the brand — but the plan still distributes emphasis rather than repeating one quality.

4.4 Intensity

  • General tai chi is conventionally classified as light-to-moderate intensity, around 3.0 METs (Compendium of Physical Activities). ⚠️ unverified — treated as a design heuristic only, never displayed to users as a number.

Intensity in tai chi is not speed. It is modulated by:

  1. Stance height — the dominant variable by a wide margin.
  2. Range of motion.
  3. Single-leg time — the main driver of both training effect and fall risk.
  4. Continuity — how much of the session is unbroken movement.

This is why the UI says "In tai chi this is stance height and single-leg time, not speed" directly under the intensity control.

4.5 Breath

  • The guided blocks pace at roughly 5.5 breaths per minute (an 11-second cycle), within the resonance-frequency range associated with the clearest short-term effects on heart-rate variability and baroreflex sensitivity. ⚠️ unverified — it is presented as a suggestion; nothing in the app requires matching it.

4.6 Habit

  • The single strongest lever on adherence we can actually pull is making the next session visible and already decided. Hence the week strip is pinned above everything, and rest days are rendered rather than omitted. ⚠️ unverified as a specific effect size; treated as a product principle.

4.7 What the evidence does not support

Stated plainly so nobody builds on sand:

  • Tai chi has not been shown to be superior to well-designed conventional balance training for falls.
  • Evidence certainty for tai chi specifically is generally low to moderate; trials are small, blinding is impossible, and attention-control comparators are inconsistent.
  • Nothing here supports claims about immunity, "energy", organ function, or disease reversal. The app never makes them, including in the qigong copy, where video titles sometimes do. We reproduce titles verbatim as attribution — that is not endorsement.

5. The prescription model

src/lib/engine/prescribe.ts — profile in, blueprint out. No content is selected here, which makes session shape testable without touching the video library.

5.1 The five-phase arc

Every session, at every duration, follows the same arc. Familiarity is the point: the ritual should not change just because the content does.

Phase中文PurposeBase share
Center無極Arrive. Settle the breath, find vertical alignment.8%
Warm up熱身Mobilise ankles, hips, spine, shoulders through pain-free range.17%
Qigong氣功Repeatable breath-linked movement. Builds coordination.27%
Form套路Continuous choreography. The cognitive and postural core.30%
Balance平衡Deliberate weight transfer, controlled stepping, narrow stance.13%
Close收功Down-regulate. Slow the breath, return to the room.5%

Center and close are always present and are always narrated by the app, not by video. Three reasons: nobody publishes a good two-minute cool-down; a fixed opening and closing is the strongest habit cue available; and it means a session still works when a video is pulled or the connection drops.

5.2 Focus reweighting

Focus multiplies the base shares, then everything renormalises — so a focus can emphasise a phase but can never delete one by arithmetic.

FocusEmphasis
balancebalance ×2.0, warm-up ×1.1, form/qigong ×0.85
jointswarm-up ×1.55, qigong ×1.2, form ×0.8
stresscenter ×2.2, close ×2.0, qigong ×1.2, form ×0.7
strengthbalance ×1.45, form ×1.15, center ×0.7
cardioform ×1.3, qigong ×1.2, center ×0.5
cognitionform ×1.55, balance ×1.1, qigong ×0.8

5.3 Phase floors and dropping

Each phase has a minimum below which it isn't worth doing — you'd spend the whole block finding the video's starting position.

center 90s · warmup 120s · qigong 180s · form 240s · balance 120s · close 90s

Allocation iterates: compute shares → find underfunded phases → drop the lowest-weighted droppable one → recompute. Iterating matters, because dropping one phase can lift another above its floor. center and close are protected and never dropped; at least one of qigong / form / balance must survive. Final durations round to 15 s, with the residue pushed onto the largest block.

This is why a 10-minute session has four blocks and a 45-minute session has six, without any hard-coded per-duration templates.

5.4 Safety ceilings

Mobility is the hard gate. Age is a soft one. A steady 80-year-old outranks an unsteady 55-year-old, and the UI asks about mobility first to signal that it is a safety question rather than a demographic one.

mobility ceiling:   seated → gentle   supported → gentle   steady → vigorous
age ceiling:        <50 → vigorous    50–64 → vigorous
                    65–74 → moderate  75+ → gentle
effective = min(requested, mobility ceiling, age ceiling)

One exception: steady on the feet and an established practice lifts the age ceiling by one step. Demonstrated capability beats a birth year. The mobility ceiling is never lifted.

When the requested intensity is capped, the app says so, and says which constraint bound — "Standing support is the limiting factor, not age."

Additional structural consequences:

  • seated → the balance phase is removed entirely (there is no honest seated equivalent of single-leg standing) and its time goes to qigong, where trunk control and weight shift can be trained sitting.
  • seated or supportedrequireChairFriendly, which is a hard filter on content, not a preference.
  • new → warm-up ×1.25, form ×0.85, and step-by-step teachers are preferred.
  • established → form ×1.2, warm-up ×0.85.

6. The anti-repetition engine

src/lib/engine/select.ts. This is the part the whole product lives or dies on: it must not feel like the same thing every day.

A naive best-match scorer fails immediately — the best match is deterministic, so day 2 is identical to day 1. So novelty is not a tie-breaker here. It is the largest single term in the score.

6.1 Score

score = 0.42 · novelty        # days since last served / 21, capped at 1
      + 0.30 · durationFit    # how well the runtime covers the block
      + 0.20 · fit            # focus, intensity, level, teaching style
      + 0.08 · jitter         # seeded random — what "Shuffle" perturbs

× 0.55 if the channel appeared in either of the last two sessions
× 0.85 if the style appeared in the last session

6.2 Hard rules, and the order they relax

Rule
HARDNo video twice in one session
HARDNo channel twice in one session — the single biggest driver of "this feels samey"
HARDNo video seen within cooldownDays
SOFTChannel penalty across the last two sessions
SOFTStyle penalty across the last session

cooldownDays = clamp(floor(eligiblePoolSize × 0.6), 1, 14). It scales with what the phase can actually reach: a phase with 15 eligible videos gets a 9-day cooldown, one with 3 gets 1 day. A fixed constant would either starve narrow phases or barely rotate wide ones.

If a phase would come up empty, constraints relax in a defined order — (1) experience-level matching, (2) the cooldown, (3) intensity-band and channel variety — and the safety ceiling never relaxes at any level. Which rules were relaxed is reported back in session.rationale and shown to the user, because silently ignoring your own constraints is how a recommender rots.

6.3 Duration fit is asymmetric, on purpose

A video longer than its block is fine — you practise the first N minutes and the timer moves you on, exactly like a class. A video shorter than its block leaves you standing in silence, which is a much worse failure. So:

r = budget / runtime
r > 1  →  max(0, 1 − (r−1) × 1.25)   # too short: punished hard
r ≤ 1  →  r ^ 0.45                    # long is acceptable, not preferred

6.4 Determinism

The session you were given must not silently reshuffle when you reload mid-practice. Every random draw comes from a mulberry32 PRNG seeded by hash(date, profileKey, variant). variant is the only thing that changes a day's session, and it is incremented by exactly one control: "Different session".

6.5 Why the library is built the way it is

The engine can only rotate through what exists. So the library deliberately carries the same canonical sets taught by different teachers — seven recordings of Ba Duan Jin, six of Shibashi, eight of the Yang 24 form. That is the trick: the content stays familiar (you're learning one form, not fifty), while the voice, tempo and camera angle rotate. Familiar practice, fresh session.

Editorial target: ≥5 distinct channels per (phase × intensity) cell.


7. Weekly periodisation

src/lib/engine/week.ts. A week of identical sessions is the fastest way to quit, and poor training besides.

  • One long day — the practitioner's own focus, duration ×1.5, placed on their last selected day of the week (usually the weekend, usually the only day with time). This is the session where a form actually gets learned. Requires ≥3 practice days.
  • One easy day — one intensity step down, focus switched to stress, placed mid-week. Recovery, not a gap. Requires ≥4 practice days.
  • Everything else alternates between the chosen focus and its complement, so no single quality is trained on consecutive days.
balance ↔ strength    strength → joints    joints → stress
stress  → balance     cardio   → balance   cognition → joints
  • Rest days are rendered, not hidden. An empty Thursday is a decision the plan made. Selecting one offers "Practise anyway", which builds at reduced intensity rather than refusing.

Weeks start Monday — that is how people talk about "this week", and it puts the natural long day at the end rather than the middle.


8. Content policy

8.1 Verification

Every entry in src/lib/content/videos.ts was machine-checked before shipping:

  • https://www.youtube.com/oembed?url=… → exists, is public, real title, real channel.
  • the watch page → lengthSeconds, and playableInEmbed: true.

Run it again with bun run verify:library.

Titles lie constantly. "10 min" routinely sits on a 14-minute video. The seconds field is always YouTube's own number, because the timer budgets against it.

Of 52 candidates, 51 shipped; one (ILFcKMnMkPQ) reported playableInEmbed: false and was dropped rather than shipped as a dead frame.

8.2 Editorial rules for adding an entry

  1. Real runtime in seconds. Never the title's number.
  2. chairFriendly is a safety claim, not a convenience tag. Only true if the video is seated, or slow and narrow-stanced enough to do holding a chair back.
  3. Never tag one video into more than three phases. Over-tagging is how a library of 50 starts feeling like a library of 6.
  4. Aim for ≥5 distinct channels per (phase × intensity) cell.

8.3 Attribution and fair use

Nothing is hosted, re-uploaded, downloaded or stripped. Videos are embedded via YouTube's own player (youtube-nocookie host) with native controls left enabled, channel names displayed on every surface, and a "Watch on YouTube" link on every library card. The full library is public at /library.

If a creator asks to be removed, remove the entry. No negotiation, no "but the engine needs it".

8.4 Graceful failure

Videos get deleted and embed permissions change. When the player errors, the stage degrades to a card with the poster, an explanation, and an external link — and the timer keeps running. A blank iframe is never acceptable.


9. Safety scope

What the app does:

  • Asks about standing stability before anything else.
  • Enforces a hard intensity ceiling that no relaxation path can override.
  • Surfaces cautions above the player, every session, not once at onboarding.
  • Universal cautions: stop for sharp pain, chest pain, dizziness or breathlessness that doesn't settle; never force a knee past the toes; knee tracks over the foot.
  • Conditional cautions: chair within reach for supported; firm chair, no wheels, feet flat for seated; "raise your stance before you shorten your session" for moderate and above.

What it explicitly does not do, and must not start doing without a clinician in the loop:

  • Screen for contraindications (recent surgery, acute cardiac events, vestibular disorders, uncontrolled hypertension).
  • Personalise around a named diagnosis.
  • Claim any therapeutic outcome for an individual.

10. Architecture

src/
  app/                 routes: / · /progress · /library · /method
  components/
    brand/             mark, wordmark, lockup
    glass/             NightSky, Glass — the material layer
    ui/                shadcn primitives (extended, not forked)
    layout/            header, bottom tab bar, skip link, nav definition
    practice/          stage, timer, transport, rail, screen
    progress/          rank, milestones, stats
    calendar/          week strip
    setup/             option groups, setup sheet
    library/           library browser
  hooks/               timer, chime, wake lock, hydration gate
  lib/
    domain/types.ts    the vocabulary. Nothing decorative lives here
    content/           verified video registry + app-narrated guided blocks
    engine/            prescribe → select → week. Pure, locale-blind
    progress/          ranks + derived stats. Pure
    i18n/              detection, dictionaries (en·es·fr·de·zh), provider
    store/             the only persisted state
docs/METHODOLOGY.md    this file
scripts/verify-library.ts

Conventions

  • lib/engine/* and lib/progress/* are pure: no React, no DOM, no implicit Date.now(), and no language. Every function takes the date as an argument, which is what makes sessions reproducible and testable, and returns dictionary keys rather than sentences.
  • A field exists in domain/types.ts only if an engine rule reads it or a label renders it.
  • kebab-case files, named exports, direct imports, no barrel files.
  • Client components are leaves. PracticeScreen holds exactly one piece of view state — the selected date — and everything else is derived.

10.1 The timer, specifically

Three decisions worth defending:

  1. The clock is a single scalar. One number — milliseconds elapsed in the whole session — and the current block is derived from it against cumulative boundaries. Skip, back, and scrub are all the same operation. A separate per-block countdown and session total is how those two drift apart.
  2. Wall-clock, not tick-counted. Elapsed is committed + (now − startedAt), recomputed each tick. Accumulating +250 ms per interval loses minutes over a 45-minute session, because background tabs throttle timers hard.
  3. Therefore it survives backgrounding. A phone that locks mid-session resumes at the correct time, not the time it locked.

The timer is also the source of truth for the video: pausing the session pauses YouTube, and a video reaching its own end does not end the block.


11. Known gaps

Honest list. These are not hidden behind "future work".

  • No tests yet. prescribe, select, week and progress/stats are pure and were written to be testable; nothing has been written. This is the largest gap by a distance.

  • localStorage only. No sync, no export, no import. An export/import JSON button is the cheapest meaningful fix, and would also give people a way to move a year of practice to a new phone.

  • Locale is applied after mount, so the first painted frame is English. Fixed. The locale is a route segment: English is served unprefixed at /library, the other four at /es/library and so on, and <html lang> is correct on the first byte. There is no mount-time correction and no flash of English.

  • Page <title> and meta descriptions are English only. Fixed for the routes listed as covered below; they come from the dictionary now, like everything else.

  • Only three route families are actually translated, and this is the real remaining gap. src/lib/i18n/coverage.ts is the source of truth and is deliberately conservative:

    Route familyLanguages servedIndexable
    Home, /library, /library/teachersall fiveyes, with reciprocal hreflang
    Class pages, /for/*, /method, /installEnglishEnglish only; the other four render but are noindex
    /progressnever, in any language

    A non-English variant of an untranslated page is not hidden — a Spanish visitor who follows a link to a class page keeps Spanish navigation, Spanish controls and Spanish dates around English prose, and links into English-only pages are labelled "(in English)". It is simply not offered to a search engine as a Spanish page, because it is not one. No hreflang annotation is emitted for a URL that does not serve that language; a fabricated one is worse than none.

  • The seven /for/* pages are English and will stay that way until a person translates them. They are roughly 1,250 lines of health-adjacent guidance written for an over-65 readership, and §9 is the reason: this is the only content on the site where being wrong could hurt someone, and a machine translation of movement guidance is exactly the kind of wrong that reads fluently. Adding a locale to COVERAGE.topic is a claim that a human did the work.

  • docs/METHODOLOGY.md is English only, and is rendered as-is at /method in every locale. Localising it means localising the repository's own source of truth, which is a larger decision than localising the site.

  • /install is English only because its steps quote the exact wording of iOS, Chrome and Samsung Internet menu items. Those menus are localised by the operating system, so a half-translated page would send a reader hunting for a string that is not on their screen.

  • Chinese is not solved by hreflang alone. Baidu ignores it and weights domain and hosting signals differently, so /zh ranking is a separate piece of work rather than the same work again.

  • Seated library is narrower than standing, and skews to two channels. The channel-diversity rule therefore relaxes sooner for seated practitioners.

  • No offline mode. The guided blocks work offline by design; the video blocks do not.

  • Two claims remain ⚠️ unverified (§4.4 METs, §4.5 breathing rate) and must not be used in public-facing copy until checked.

12. Changelog

DateChange
2026-08-01Initial build. Brand, three-tier tokens, liquid-glass system, 51-video verified library, prescription engine, anti-repetition selector, weekly periodiser.
2026-08-01Visual direction changed to deep night & moonlight, dark-first. Cormorant + Inter replace Newsreader + Geist. Full mobile pass: bottom tab bar, safe areas, overflow guards, ring-relative timer sizing.
2026-08-01Automatic browser-language detection with en · es · fr · de · zh. Engine refactored to emit translatable keys instead of prose.
2026-08-01Progression added: nine ranks, nine milestones, streaks — all derived from the record log, all measured in days rather than volume.

Sources