<!-- Cloud Hands — https://tai-chi.app/method -->

# 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 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.

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. **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.
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

| 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 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):

| 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 `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       | 中文 | 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.
- `seated` or `supported` → `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`, 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 family | Languages served | Indexable |
  | --- | --- | --- |
  | Home, `/library`, `/library/teachers` | all five | yes, with reciprocal `hreflang` |
  | Class pages, `/for/*`, `/method`, `/install` | English | 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 `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

| 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](https://www.cochranelibrary.com/cdsr/doi/10.1002/14651858.CD012424.pub2/full)
- [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](https://pmc.ncbi.nlm.nih.gov/articles/PMC6233748/)
- [Wang C. et al. *Comparative Effectiveness of Tai Chi Versus Physical Therapy for Knee Osteoarthritis: A Randomized Trial.* Annals of Internal Medicine, 2016](https://www.acpjournals.org/doi/10.7326/M15-2143)
- [WHO Guidelines on Physical Activity and Sedentary Behaviour, 2020 — Recommendations](https://www.ncbi.nlm.nih.gov/books/NBK566046/)
- [Tai Ji Quan: Moving for Better Balance](https://www.betterbalance.net/)
