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
localStorageon 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 write | We 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.
| Ramp | Role | Why this hue |
|---|---|---|
| Night | Background and structure | Blue-black, never neutral grey. The slight blue is what makes the accent read as light rather than paint |
| Moonlight | Primary. The single light source, and the accent | Pale desaturated blue. Used for the play control, the timer ring and the active state — and almost nothing else |
| Celadon | The practice colour: qigong, warm-up, gentle | A jade held so far back it reads as glazed porcelain under moonlight, not as "wellness green" |
| Lamplight | Today, streaks, moderate effort | The only warm hue in normal use. Rationed hard, so it means something |
| Ember | Vigorous intensity, destructive actions | The 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.
- Primitive —
--ch-moon-400,--ch-night-950. Raw brand values. Never referenced by a component. - Semantic —
--primary,--phase-form,--glass-blur. Meaning-bearing, theme-switchable. This is the layer light/dark swaps. - 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.
- Refraction —
backdrop-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. - Body — a translucent tint fill (
--glass-tint/--glass-fill). - Edges — a bright hairline on top (
inset 0 1px 0) and a dark contact line on the bottom. This is what implies thickness. - 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
@supportsfallback. Wherebackdrop-filteris unsupported or disabled, fills jump to 88–98% opacity. Text contrast never depends on the blur landing.
3.3 Motion
| Rule | Reason |
|---|---|
| Two easings only, both custom | --ease-glass for material, --ease-settle for arrivals. More than two reads as inconsistency. |
| Nothing animates in from the side | Peripheral motion pulls the eye and breaks a standing posture. Cues cross-fade in place. |
| Ambient loops are 28–44 s | Slow 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 isaria-hiddenwith a coarsesr-onlylive 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-currenton 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
mdthe 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: hiddenonhtmlandbody, andmin-width: 0on 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.
- 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. - Match on the primary subtag only:
es-419,es-MXandesall resolve to Spanish. Every Chinese variant resolves to Simplified — shippingzh-Hantwithout a real Traditional translation would be worse than showing Simplified. - 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.
prescribeandselectare pure and locale-blind, so they returnNote = { 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 andtranslateresolves 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):
| Rank | Days | Rank | Days | |||
|---|---|---|---|---|---|---|
| Beginner's Mind | 初心 | 0 | White Crane | 白鶴 | 60 | |
| First Light | 初光 | 1 | Long River | 長江 | 100 | |
| Still Water | 靜水 | 5 | Bedrock | 磐石 | 180 | |
| Green Bamboo | 青竹 | 15 | Old 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
formcontent for thejointsfocus 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:
- Stance height — the dominant variable by a wide margin.
- Range of motion.
- Single-leg time — the main driver of both training effect and fall risk.
- 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 | 中文 | Purpose | Base 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.
| Focus | Emphasis |
|---|---|
| balance | balance ×2.0, warm-up ×1.1, form/qigong ×0.85 |
| joints | warm-up ×1.55, qigong ×1.2, form ×0.8 |
| stress | center ×2.2, close ×2.0, qigong ×1.2, form ×0.7 |
| strength | balance ×1.45, form ×1.15, center ×0.7 |
| cardio | form ×1.3, qigong ×1.2, center ×0.5 |
| cognition | form ×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.seatedorsupported→requireChairFriendly, 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 | |
|---|---|
| HARD | No video twice in one session |
| HARD | No channel twice in one session — the single biggest driver of "this feels samey" |
| HARD | No video seen within cooldownDays |
| SOFT | Channel penalty across the last two sessions |
| SOFT | Style 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, andplayableInEmbed: 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
- Real runtime in
seconds. Never the title's number. chairFriendlyis 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.- Never tag one video into more than three phases. Over-tagging is how a library of 50 starts feeling like a library of 6.
- 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 forseated; "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/*andlib/progress/*are pure: no React, no DOM, no implicitDate.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.tsonly 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.
PracticeScreenholds exactly one piece of view state — the selected date — and everything else is derived.
10.1 The timer, specifically
Three decisions worth defending:
- 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.
- Wall-clock, not tick-counted. Elapsed is
committed + (now − startedAt), recomputed each tick. Accumulating+250 msper interval loses minutes over a 45-minute session, because background tabs throttle timers hard. - 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,weekandprogress/statsare pure and were written to be testable; nothing has been written. This is the largest gap by a distance. -
localStorageonly. 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/libraryand so on, and<html lang>is correct on the first byte. There is no mount-time correction and no flash of English. -
PageFixed for the routes listed as covered below; they come from the dictionary now, like everything else.<title>and meta descriptions are English only. -
Only three route families are actually translated, and this is the real remaining gap.
src/lib/i18n/coverage.tsis the source of truth and is deliberately conservative:Route family Languages served Indexable Home, /library,/library/teachersall five yes, with reciprocal hreflangClass pages, /for/*,/method,/installEnglish English only; the other four render but are noindex/progress— never, 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
hreflangannotation 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 toCOVERAGE.topicis a claim that a human did the work. -
docs/METHODOLOGY.mdis English only, and is rendered as-is at/methodin every locale. Localising it means localising the repository's own source of truth, which is a larger decision than localising the site. -
/installis 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
hreflangalone. Baidu ignores it and weights domain and hosting signals differently, so/zhranking 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
| Date | Change |
|---|---|
| 2026-08-01 | Initial build. Brand, three-tier tokens, liquid-glass system, 51-video verified library, prescription engine, anti-repetition selector, weekly periodiser. |
| 2026-08-01 | Visual 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-01 | Automatic browser-language detection with en · es · fr · de · zh. Engine refactored to emit translatable keys instead of prose. |
| 2026-08-01 | Progression added: nine ranks, nine milestones, streaks — all derived from the record log, all measured in days rather than volume. |
Sources
- Sherrington C. et al. Exercise for preventing falls in older people living in the community. Cochrane Database of Systematic Reviews, 2019
- Li F. et al. Effectiveness of a Therapeutic Tai Ji Quan Intervention vs a Multimodal Exercise Intervention to Prevent Falls Among Older Adults at High Risk of Falling. JAMA Internal Medicine, 2018
- Wang C. et al. Comparative Effectiveness of Tai Chi Versus Physical Therapy for Knee Osteoarthritis: A Randomized Trial. Annals of Internal Medicine, 2016
- WHO Guidelines on Physical Activity and Sedentary Behaviour, 2020 — Recommendations
- Tai Ji Quan: Moving for Better Balance