SLOPSHOPPER

mindful-claude

Guided breathing exercises above the prompt while Claude works: coherent, box, 4-7-8 and the physiological sigh, four animation styles, and the spinner reads…

newbandspinnercommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mindful-claude
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /breathe ⎿ mindful-claude: breathe: on · Coherent Breathing (5.5s in, 5.5s out) · style random · delay 0s · spinner on ▣ client module ./breathe.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./breathe.tsx ⟨Claude Code's own drawing⟩
README

Mindful Claude

Breathe while Claude works.

Every prompt you send gives you 10 to 60+ seconds of waiting. This Claude Mod turns that time into a guided breathing exercise. When Claude starts working, a breathing animation appears above the prompt and the spinner counts the breath with you. When Claude answers, it's gone.

Mindful Claude demo

Why

Slow breathing at about 5.5 breaths per minute raises heart rate variability (HRV), a marker of stress resilience. Even short sessions lower cortisol and sharpen focus. Every Claude turn becomes a micro-session, and you never leave the terminal.

Install

Needs Claude Code 2.1.269 or later. Claude Mods are in early access, so turn them on in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Then:

claude plugin marketplace add halluton/Mindful-Claude
claude plugin install mindful-claude@mindful-claude

Start claude, send a prompt, breathe.

To remove it: claude plugin uninstall mindful-claude.

/breathe

CommandWhat it does
/breatheShow the current settings
/breathe off / onHide or show the band
/breathe hrvCoherent Breathing: 5.5s in, 5.5s out
/breathe sighPhysiological Sigh: double inhale, long exhale
/breathe boxBox Breathing: 4s in, 4s hold, 4s out, 4s hold
/breathe 4784-7-8 Breathing: 4s in, 7s hold, 8s out
/breathe style wavePin a style: pulse, ripples, dots, wave, or random
/breathe delay 5Seconds into a turn before the band appears (default 0)
/breathe spinner offLeave the spinner alone

Settings persist across sessions.

ExerciseBest for
Coherent BreathingSustained HRV improvement
Physiological SighQuick calm-down
Box BreathingFocus and concentration
4-7-8 BreathingDeep relaxation

How it works

A Claude Mod is a plugin whose hooks are TypeScript functions running inside Claude Code. This one costs zero tokens: the model never sees it.

  • hooks/register.tsx hooks ui.render for the band above the prompt and mounts the animation while the engine says a turn is working. It also rewrites the spinner's word with the current phase and registers /breathe.
  • hooks/breathe.tsx is the animation, drawn ten times a second on the surface's own clock.
  • hooks/breath/ holds the exercises, easing and shapes as pure functions, covered by bun test.

Develop

git clone https://github.com/halluton/Mindful-Claude
cd Mindful-Claude
claude --plugin-dir .      # loads from disk, hot-reloads on save
bun test

For type checking, run /plugin-types in that session, then bunx -p typescript tsc -p ..

The original bash and tmux version lives in legacy/.

License

MIT. See LICENSE.

Source 5 files
hooks/register.tsx 103 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3import { DEFAULTS, applyCommand, readConfig, type Config } from './breath/config.ts'
4import { pickStyle, type Style } from './breath/shapes.ts'
5
6// The hooks module. While a turn runs it mounts ./breathe.tsx above the prompt (the band's
7// `isWorking` prop is the trigger, so the band appears when Claude starts and goes when Claude
8// stops) and reads the breath's phase into the spinner line. /breathe changes the settings,
9// kept in $.store.
10
11const BAND_ROWS = 9 // seven rows of picture, the phase line, the exercise name
12
13let config: Config = DEFAULTS
14// the running turn: when it started and which style it drew
15let turn: { startedAt: number; style: Style } | undefined
16let lastStyle: Style | undefined
17// the breath the band last posted, for the spinner
18let phase: { word: string; exercise: string } | undefined
19
20const log = ($: { ui: { log: (text: string) => void } }, what: string) => (err: unknown) => $.ui.log(`mindful-claude: ${what}: ${err}`)
21
22export const register: Register = on => {
23  on('session.start', async ($, e, next) => {
24    const r = await next(e)
25    const saved = await $.store.get('config').catch(log($, 'store read failed'))
26    config = readConfig(saved)
27    await $.command.register({
28      name: 'breathe',
29      description: 'Breathing exercises above the prompt while Claude works: on, off, hrv, sigh, box, 478, style, delay (mindful-claude)',
30      argumentHint: '[on | off | hrv | sigh | box | 478 | style <name> | delay <s> | help]',
31      immediate: true,
32    }).catch(log($, '/breathe not registered'))
33    return r
34  })
35
36  on('command.run', { command: 'breathe' }, async ($, e) => {
37    const applied = applyCommand(config, e.args)
38    if (applied.config !== config) {
39      config = applied.config
40      await $.store.set('config', config).catch(log($, 'store write failed'))
41      $.ui.invalidate('ui.render')
42    }
43    return { text: applied.text }
44  })
45
46  on('turn.start', async ($, e, next) => {
47    const style = pickStyle(config.style, lastStyle)
48    lastStyle = style
49    turn = { startedAt: await $.clock.now(), style }
50    phase = undefined
51    // the band is drawn from a delay on: wake the render hook when it has passed
52    if (config.delay > 0) $.clock.after(config.delay * 1000, () => $.ui.invalidate('ui.render'))
53    $.ui.invalidate('ui.render')
54    return next(e)
55  })
56
57  on('turn.complete', async ($, e, next) => {
58    const r = await next(e)
59    turn = undefined
60    phase = undefined
61    $.ui.invalidate('ui.render')
62    return r
63  })
64
65  // the band posts its phase whenever the line changes (once a second): the spinner reads it
66  on('ui.message', async ($, e, next) => {
67    const data = e.data as { word?: unknown; exercise?: unknown } | null
68    if (typeof data?.word === 'string' && typeof data.exercise === 'string') {
69      phase = { word: data.word, exercise: data.exercise }
70      if (config.spinner) $.ui.invalidate('ui.render')
71    }
72    return next(e)
73  })
74
75  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76    // the band needs a Client, which only the terminal draws; a survey keeps the band
77    if (e.surface !== 'terminal' || !config.enabled || !e.props.isWorking || e.props.hasSurvey) return next(e)
78    const now = await $.clock.now()
79    // a turn the hook saw start; else one that is working anyway (started before the plugin loaded)
80    const running = turn ?? (turn = { startedAt: now, style: pickStyle(config.style, lastStyle) })
81    const elapsedMs = now - running.startedAt
82    if (elapsedMs < config.delay * 1000) return next(e)
83    const { Box, Client } = $.ui.resolve(e)
84    const rows = Math.min(BAND_ROWS, e.props.maxRows)
85    if (rows < 1) return next(e)
86    // the key names the turn: one instance per turn, remounted when the settings change mid-turn
87    const key = `breathe:${running.startedAt}:${config.exercise}:${running.style}`
88    return (
89      <Box flexDirection="column">
90        <Client key={key} module="./breathe.tsx" width={e.viewport?.columns ?? 80} height={rows}
91          props={{ exercise: config.exercise, style: running.style, elapsedMs }} />
92        {await next(e)}
93      </Box>
94    )
95  })
96
97  // the spinner's word becomes the breath: `✻ Breathe in 4s…`
98  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
99    if (!config.enabled || !config.spinner || !phase) return next(e)
100    return next({ ...e, props: { ...e.props, message: phase.word } })
101  })
102}
103
hooks/breath/config.ts 88 lines
1// The plugin's settings and the /breathe command that changes them. Pure: `bun test` covers it.
2
3import { EXERCISES, exerciseOf, isExerciseKey, resolveExercise, type ExerciseKey } from './exercises.ts'
4import { STYLES, isStyle, type Style } from './shapes.ts'
5
6export type Config = {
7  enabled: boolean
8  exercise: ExerciseKey
9  style: Style | 'random'
10  /** Seconds Claude works before the band appears; 0 shows it at once. */
11  delay: number
12  /** Whether the spinner line reads the breath's phase too. */
13  spinner: boolean
14}
15
16export const DEFAULTS: Config = { enabled: true, exercise: 'hrv', style: 'random', delay: 0, spinner: true }
17
18/** A config from what the store held, field by field, defaults for the rest. */
19export function readConfig(saved: unknown): Config {
20  const s = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>
21  return {
22    enabled: typeof s.enabled === 'boolean' ? s.enabled : DEFAULTS.enabled,
23    exercise: isExerciseKey(s.exercise) ? s.exercise : DEFAULTS.exercise,
24    style: s.style === 'random' || isStyle(s.style) ? s.style : DEFAULTS.style,
25    delay: typeof s.delay === 'number' && s.delay >= 0 && Number.isFinite(s.delay) ? s.delay : DEFAULTS.delay,
26    spinner: typeof s.spinner === 'boolean' ? s.spinner : DEFAULTS.spinner,
27  }
28}
29
30export function statusLine(c: Config): string {
31  const ex = exerciseOf(c.exercise)
32  return `breathe: ${c.enabled ? 'on' : 'off'} · ${ex.name} (${ex.pattern}) · style ${c.style} · delay ${c.delay}s · spinner ${c.spinner ? 'on' : 'off'}`
33}
34
35export const HELP = [
36  '/breathe               status',
37  '/breathe on | off      show or hide the breathing band while Claude works',
38  ...EXERCISES.map(e => `/breathe ${e.key.padEnd(14)}${e.name}: ${e.pattern}`),
39  `/breathe style <name>  ${STYLES.join(', ')} or random`,
40  '/breathe delay <s>     seconds Claude works before the band appears (0 = at once)',
41  '/breathe spinner on|off  read the phase in the spinner line too',
42].join('\n')
43
44/** Applies one `/breathe` invocation; returns the new config and the transcript line. */
45export function applyCommand(config: Config, args: string): { config: Config; text: string } {
46  const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
47  const [head, arg] = words
48  if (!head || head === 'status') return { config, text: statusLine(config) }
49  if (head === 'help') return { config, text: HELP }
50  if (head === 'on' || head === 'off') {
51    const next = { ...config, enabled: head === 'on' }
52    return { config: next, text: statusLine(next) }
53  }
54  const exercise = resolveExercise(head)
55  if (exercise) {
56    const next = { ...config, exercise }
57    return { config: next, text: statusLine(next) }
58  }
59  if (head === 'style') {
60    const style: Config['style'] | undefined = arg === 'random' ? 'random' : isStyle(arg) ? arg : undefined
61    if (style) {
62      const next: Config = { ...config, style }
63      return { config: next, text: statusLine(next) }
64    }
65    return { config, text: `breathe: style is one of ${STYLES.join(', ')}, random` }
66  }
67  if (isStyle(head)) {
68    const next = { ...config, style: head }
69    return { config: next, text: statusLine(next) }
70  }
71  if (head === 'delay') {
72    const delay = Number(arg)
73    if (arg !== undefined && Number.isFinite(delay) && delay >= 0) {
74      const next = { ...config, delay }
75      return { config: next, text: statusLine(next) }
76    }
77    return { config, text: 'breathe: delay takes a number of seconds, 0 or more' }
78  }
79  if (head === 'spinner') {
80    if (arg === 'on' || arg === 'off') {
81      const next = { ...config, spinner: arg === 'on' }
82      return { config: next, text: statusLine(next) }
83    }
84    return { config, text: 'breathe: spinner on or off' }
85  }
86  return { config, text: `breathe: no setting called "${head}"\n${HELP}` }
87}
88
hooks/breath/shapes.ts 148 lines
1// The four animation styles, ported from legacy/breathe.sh: each turns a breath's progress
2// (0..1000) into rows of text, the shape centred in `width` cells. Pure: `bun test` covers it.
3
4export type Style = 'pulse' | 'ripples' | 'dots' | 'wave'
5
6export const STYLES: readonly Style[] = ['pulse', 'ripples', 'dots', 'wave']
7
8export const isStyle = (value: unknown): value is Style =>
9  typeof value === 'string' && (STYLES as readonly string[]).includes(value)
10
11/** The style for a turn: the setting, or at random, never the one just shown. */
12export function pickStyle(setting: Style | 'random', last: Style | undefined, random: () => number = Math.random): Style {
13  if (setting !== 'random') return setting
14  const pool = STYLES.filter(s => s !== last)
15  return pool[Math.floor(random() * pool.length)] ?? 'pulse'
16}
17
18/** Rows the shape is drawn on, as breathe.sh drew it (its rows 2..8); the middle row is 3. */
19export const ART_ROWS = 7
20const MID = 3
21
22const HBLK = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
23const RIPPLE = ['━', '─', '╌', '┈'] as const
24const PULSE_RATIO = [200, 600, 1000, 600, 200] as const
25const RIPPLE_RATIO = [1000, 750, 500, 250] as const
26
27// [row, column factor]: the column is -1000..1000 from the centre, scaled to the box
28const DOTS: readonly (readonly [number, number])[] = [
29  [3, 0], [3, 70], [3, -80],
30  [2, 150], [4, -160], [2, -220], [4, 230], [3, 300], [3, -310],
31  [1, 250], [5, -260], [1, -400], [5, 410], [2, 450], [4, -460],
32  [2, -530], [4, 540], [3, 600], [3, -620],
33  [0, 400], [6, -420], [0, -600], [6, 620], [1, 700], [5, -710],
34  [1, -800], [5, 810], [2, 850], [4, -860],
35  [0, 900], [6, -910], [0, -950], [6, 960],
36]
37
38/** The shape's widest extent: the band less a margin, at most 60 cells. */
39export const boxWidth = (width: number): number => Math.min(60, Math.max(4, width - 4))
40
41/** Half the shape's current width in cells; never 0 while any breath is in. */
42export function scaledHalf(progress: number, width: number): number {
43  const maxHalf = Math.floor(boxWidth(width) / 2)
44  const half = Math.floor((maxHalf * progress) / 1000)
45  return half < 1 && progress > 0 ? 1 : half
46}
47
48/** A pulse bar's half: `░▒▓███` on the left, mirrored on the right, `h` cells each. */
49function pulseBar(h: number): string {
50  const fill = '█'.repeat(Math.max(0, h - 3))
51  const left = (h >= 3 ? '░' : '') + (h >= 2 ? '▒' : '') + '▓' + fill
52  const right = fill + '▓' + (h >= 2 ? '▒' : '') + (h >= 3 ? '░' : '')
53  return left + right
54}
55
56type Grid = string[][]
57
58const blank = (width: number): Grid => Array.from({ length: ART_ROWS }, () => Array<string>(width).fill(' '))
59
60/** Writes `text` into `grid[row]` centred, clipped to the grid. */
61function centred(grid: Grid, row: number, text: string): void {
62  const line = grid[row]
63  if (!line) return
64  const width = line.length
65  const chars = [...text]
66  const start = Math.floor((width - chars.length) / 2)
67  chars.forEach((ch, i) => {
68    const col = start + i
69    if (col >= 0 && col < width) line[col] = ch
70  })
71}
72
73function pulse(grid: Grid, half: number): void {
74  PULSE_RATIO.forEach((ratio, i) => {
75    const h = Math.min(32, Math.floor((half * ratio) / 1000))
76    if (h > 0) centred(grid, MID - 2 + i, pulseBar(h))
77  })
78}
79
80function ripples(grid: Grid, half: number): void {
81  RIPPLE_RATIO.forEach((ratio, dist) => {
82    const rw = Math.min(64, Math.floor((half * ratio) / 1000))
83    if (rw <= 0) return
84    const line = RIPPLE[dist]!.repeat(rw * 2)
85    centred(grid, MID - dist, line)
86    if (dist > 0) centred(grid, MID + dist, line)
87  })
88}
89
90function dots(grid: Grid, progress: number, width: number): void {
91  if (progress <= 0) return
92  const centre = Math.floor(width / 2)
93  const maxHalf = Math.floor(boxWidth(width) / 2)
94  for (const [row, dcol] of DOTS) {
95    const abs = Math.abs(dcol)
96    if (abs > progress) continue
97    const col = centre + Math.trunc((dcol * maxHalf) / 1000)
98    const ch = abs < 150 ? '✦' : abs < 500 ? '•' : '·'
99    const line = grid[row]
100    if (line && col >= 0 && col < width) line[col] = ch
101  }
102}
103
104function wave(grid: Grid, half: number): void {
105  if (half <= 0) return
106  const sh2 = half * half
107  for (let rowIdx = 0; rowIdx < 3; rowIdx++) {
108    const rowBase = rowIdx * 8
109    let text = ''
110    let any = false
111    for (let x = -half; x <= half; x++) {
112      const total = Math.floor((24 * (sh2 - x * x)) / sh2)
113      const rowH = Math.max(0, Math.min(8, total - rowBase))
114      text += HBLK[rowH]
115      if (rowH > 0) any = true
116    }
117    if (any) centred(grid, MID + 2 - rowIdx, text)
118  }
119}
120
121/**
122 * One frame: `rows` strings, each at most `width` cells with the shape centred.
123 * Fewer than 7 rows shows the middle of the picture; more pads it top and bottom.
124 */
125export function frame(style: Style, progress: number, width: number, rows: number = ART_ROWS): string[] {
126  const grid = blank(Math.max(1, width))
127  const half = scaledHalf(progress, width)
128  switch (style) {
129    case 'pulse': pulse(grid, half); break
130    case 'ripples': ripples(grid, half); break
131    case 'dots': dots(grid, progress, width); break
132    case 'wave': wave(grid, half); break
133  }
134  const lines = grid.map(line => line.join('').trimEnd() || ' ')
135  if (rows >= ART_ROWS) {
136    const above = Math.floor((rows - ART_ROWS) / 2)
137    return [...Array<string>(above).fill(' '), ...lines, ...Array<string>(rows - ART_ROWS - above).fill(' ')]
138  }
139  const skip = Math.floor((ART_ROWS - rows) / 2)
140  return lines.slice(skip, skip + rows)
141}
142
143/** `text` centred in `width` cells (left padding only). */
144export const centre = (text: string, width: number): string => {
145  const pad = Math.floor((width - [...text].length) / 2)
146  return ' '.repeat(Math.max(0, pad)) + text
147}
148
hooks/breathe.tsx 55 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { exerciseOf, phaseAt, phaseLine, spinnerWord } from './breath/exercises.ts'
4import { centre, frame, isStyle, type Style } from './breath/shapes.ts'
5
6// The breathing band: a surface module the hooks module mounts above the prompt while Claude
7// works. It runs on the drawing thread with its own frame clock, ten frames a second like
8// legacy/breathe.sh, and posts the phase line to the hooks module whenever it changes so the
9// spinner can read it too.
10//
11// Never name a local `h` here: every JSX tag compiles to a call of `h`.
12
13type Props = { exercise?: string; style?: string; elapsedMs?: number } | undefined
14type State = { tick: number; offsetMs: number; line: string }
15
16const COLOR = '#f7835d' // Claude orange
17const TICK_MS = 100
18
19export default function Breathe(props: Props, surface: ClientSurface<State>) {
20  const { Box, Text } = surface.elements
21  const exercise = exerciseOf(props?.exercise)
22  const style: Style = isStyle(props?.style) ? props.style : 'pulse'
23
24  if (surface.state === undefined) {
25    // elapsedMs says how far into the turn the band mounted (a delay); the clock runs from there
26    surface.setState({ tick: 0, offsetMs: props?.elapsedMs ?? 0, line: '' })
27    surface.every(TICK_MS, () => {
28      const s = surface.state
29      if (!s) return
30      const tick = s.tick + 1
31      const next = phaseAt(exercise, s.offsetMs + tick * TICK_MS)
32      const line = phaseLine(next)
33      if (line !== s.line) surface.post({ word: spinnerWord(next), exercise: exercise.name })
34      surface.setState({ tick, offsetMs: s.offsetMs, line })
35    })
36  }
37
38  const s = surface.state
39  const elapsed = (s?.offsetMs ?? 0) + (s?.tick ?? 0) * TICK_MS
40  const phase = phaseAt(exercise, elapsed)
41  const width = surface.columns || 80
42  const rows = surface.rows || 9
43  // two rows of text under the picture; a short band keeps the phase line and drops the rest
44  const artRows = Math.max(0, rows - 2)
45  const art = frame(style, phase.progress, width, artRows)
46
47  return (
48    <Box flexDirection="column" width={width}>
49      {art.map(line => <Text color={COLOR}>{line}</Text>)}
50      {rows >= 1 ? <Text color={COLOR}>{centre(phaseLine(phase), width)}</Text> : null}
51      {rows >= 2 ? <Text color={COLOR} dimColor>{centre(exercise.name, width)}</Text> : null}
52    </Box>
53  )
54}
55
hooks/breath/exercises.ts 92 lines
1// The four breathing exercises and the maths of one breath, ported from legacy/breathe.sh.
2// Pure functions: no engine, no surface, so `bun test` covers them.
3
4export type ExerciseKey = 'hrv' | 'sigh' | 'box' | '478'
5
6export type Exercise = {
7  key: ExerciseKey
8  name: string
9  /** The pattern, as the list prints it. */
10  pattern: string
11  inhaleMs: number
12  hold1Ms: number
13  exhaleMs: number
14  hold2Ms: number
15  /** Physiological sigh: the first hold is a second, smaller inhale. */
16  sip?: true
17}
18
19export const EXERCISES: readonly Exercise[] = [
20  { key: 'hrv', name: 'Coherent Breathing', pattern: '5.5s in, 5.5s out', inhaleMs: 5500, hold1Ms: 0, exhaleMs: 5500, hold2Ms: 0 },
21  { key: 'sigh', name: 'Physiological Sigh', pattern: 'double inhale, long exhale', inhaleMs: 4000, hold1Ms: 1000, exhaleMs: 10000, hold2Ms: 0, sip: true },
22  { key: 'box', name: 'Box Breathing', pattern: '4s in, 4s hold, 4s out, 4s hold', inhaleMs: 4000, hold1Ms: 4000, exhaleMs: 4000, hold2Ms: 4000 },
23  { key: '478', name: '4-7-8 Breathing', pattern: '4s in, 7s hold, 8s out', inhaleMs: 4000, hold1Ms: 7000, exhaleMs: 8000, hold2Ms: 0 },
24]
25
26const ALIASES: Record<string, ExerciseKey> = {
27  hrv: 'hrv', coherent: 'hrv', coherence: 'hrv',
28  sigh: 'sigh', physiological: 'sigh',
29  box: 'box',
30  '478': '478', relax: '478',
31}
32
33export const isExerciseKey = (value: unknown): value is ExerciseKey =>
34  typeof value === 'string' && EXERCISES.some(e => e.key === value)
35
36/** The exercise a word names (`hrv`, `coherent`, `sigh`, `box`, `478`, `relax`), or undefined. */
37export const resolveExercise = (word: string): ExerciseKey | undefined => ALIASES[word.trim().toLowerCase()]
38
39export const exerciseOf = (key: unknown): Exercise => EXERCISES.find(e => e.key === key) ?? EXERCISES[0]!
40
41export const cycleMs = (ex: Exercise): number => ex.inhaleMs + ex.hold1Ms + ex.exhaleMs + ex.hold2Ms
42
43export type PhaseLabel = 'Breathe in' | 'Sip in' | 'Hold' | 'Breathe out'
44
45export type Phase = {
46  label: PhaseLabel
47  /** Milliseconds left in this phase. */
48  remainingMs: number
49  /** How full the lungs are, 0..1000. */
50  progress: number
51}
52
53/** Ease-out (quadratic) over 0..1000: fast start, gentle deceleration. */
54export const ease = (x: number): number => Math.floor((x * (2000 - x)) / 1000)
55
56/** Where in the breath `elapsedMs` since the start lands. */
57export function phaseAt(ex: Exercise, elapsedMs: number): Phase {
58  const cycle = cycleMs(ex)
59  const t = ((Math.floor(elapsedMs) % cycle) + cycle) % cycle
60  const inhaleEnd = ex.inhaleMs
61  const hold1End = inhaleEnd + ex.hold1Ms
62  const exhaleEnd = hold1End + ex.exhaleMs
63
64  if (t < inhaleEnd) {
65    const eased = ease(Math.floor((t * 1000) / ex.inhaleMs))
66    // a sigh's first inhale fills 85%; the sip takes the rest
67    const progress = ex.sip ? Math.floor((eased * 850) / 1000) : eased
68    return { label: 'Breathe in', remainingMs: inhaleEnd - t, progress }
69  }
70  if (t < hold1End) {
71    if (ex.sip) {
72      const eased = ease(Math.floor(((t - inhaleEnd) * 1000) / ex.hold1Ms))
73      return { label: 'Sip in', remainingMs: hold1End - t, progress: 850 + Math.floor((eased * 150) / 1000) }
74    }
75    return { label: 'Hold', remainingMs: hold1End - t, progress: 1000 }
76  }
77  if (t < exhaleEnd) {
78    const eased = ease(Math.floor(((t - hold1End) * 1000) / ex.exhaleMs))
79    return { label: 'Breathe out', remainingMs: exhaleEnd - t, progress: 1000 - eased }
80  }
81  return { label: 'Hold', remainingMs: cycle - t, progress: 0 }
82}
83
84/** The countdown the phase line shows: whole seconds, rounded up. */
85export const secondsLeft = (phase: Phase): number => Math.ceil(phase.remainingMs / 1000)
86
87/** The band's line: `Breathe in... 4s` */
88export const phaseLine = (phase: Phase): string => `${phase.label}... ${secondsLeft(phase)}s`
89
90/** The spinner's word, which the engine follows with its own ellipsis: `Breathe in 4s` */
91export const spinnerWord = (phase: Phase): string => `${phase.label} ${secondsLeft(phase)}s`
92