a breathing band above the prompt while claude works, terminal + desktop (forked from mindful-claude 2.0.0, MIT)

🎞️ the frame: a place where most of the machine's setup lives as data.
<a href="https://github.com/dvakatsiienko/frame/actions/workflows/ci.yml"><img src="https://raw.githubusercontent.com/dvakatsiienko/frame/badges/ci.svg" alt="ci"></a> <img src="assets/badges/tests.svg" alt="tests"> <img src="assets/badges/renovate.svg" alt="renovate"> <img src="assets/badges/node.svg" alt="node"> <img src="assets/badges/pnpm.svg" alt="pnpm"> <img src="assets/badges/skills.svg" alt="skills"> <img src="assets/badges/mirrored.svg" alt="mirrored">
<img src="assets/banner.svg" align="left" width="100%" alt="frame — the inventory of one mac"> <img src="assets/mac.svg" align="right" width="36%" alt="an 80s mac showing ~ frame">
home/ is ~home/.claude/ is the claude code setup every session reads: memories, rules, skills (plugin-x), hooks, output styles, and sline, the statusline. cclio/ is the coordinator's home, the session that plans and routes work to background coders. x/ is x, the agent-first cli in go + charm: one verb registry, json for agents and boards for humans (x lane commits and pushes from a sandboxed worktree). hotkeys/ maps every keyboard chord on the machine and serves chords, the map's app. x-speak (a schedule/ daemon) reads the selected text aloud on F4 (with nothing selected it pauses / resumes, as ⇧F4 always does) and stops on F5, rewriting ids, versions, paths and code into something a voice can say; speak/ is its voice admin (pnpm speak:admin).
<img src="home/.claude/sline/showcase/sline.svg" width="100%" alt="sline, the statusline, as a session climbs from fresh to heavy">
chords is a keyboard map app: every key binding on every modifier layer, how often each one fires, and which keys are still free. powered by launchd daemon that counts key presses.
<img src="hotkeys/chords/showcase.png" width="100%" alt="chords: the hyper layer on a NuPhy Air75, each bound key with its app and press count">
the mirror links dotfiles and configs. a path under home/ is the same path under ~. the link map is derived by walking the tree, so adding a file to home/ auto-tracks it.
pnpm frame:link # status
pnpm frame:link apply # link everything not linked yet
pnpm frame:link register ~/.foo # move a file into the mirror and link it back
pnpm frame:link untrack ~/.gitconfig # hand a file back to ~
<img src="assets/frame-link.gif" width="720" alt="pnpm frame:link, ending on everything mirrored">
the mac's setup is kept as data, because data does not rot and scripts do: the Brewfile, the macos defaults, the duti file bindings, and the launchd jobs under schedule/.
pnpm macos:setup # brew bundle, macos defaults, duti, vim-plug
on a fresh machine, install the command line tools first — the clone itself needs git, and macos ships only a shim that opens the install dialog. then clone to ~/frame and run the seed, or hand it to an agent («seed this mac from frame»):
xcode-select --install # stop 0: the dialog, ~2 min
git clone https://github.com/dvakatsiienko/frame ~/frame && cd ~/frame
script/seed.sh # command line tools → brew → fnm, pnpm, node → pnpm i → macos:setup → sline → claude cli → frame:link apply
script/seed.sh --claude # the same, with ~/.claude linked
script/seed.sh --without-appstore # skip the app store apps and their sign-in
script/seed.sh --dry-run # what it would do, nothing changed
one status line per step, safe to re-run. it stops with needs your hands: … (exit 2) where only a human can act — the command line tools dialog, the homebrew password, files in the way of a link, the app store and 1password sign-ins — and the next run picks up from there. when it ends, open a new terminal: a shell opened before the seed never sources the zsh stubs it wrote.
hooks/register.tsx 154 lines1/* @jsx h */
2import { exerciseOf } from './breath/exercises.ts';
3import { METER_HEIGHT, meterSvg } from './meter.ts';
4import type { Register } from 'claude-code';
5
6import {
7 type Config,
8 DEFAULTS,
9 applyCommand,
10 readConfig,
11} from './breath/config.ts';
12
13// The hooks module. While a turn runs it draws the meter above the prompt: ./breathe.tsx on the
14// terminal, ./meter.ts on the desktop (the band's
15// `isWorking` prop is the trigger, so the band appears when Claude starts and goes when Claude
16// stops) and reads the breath's phase into the spinner line. /breathe changes the settings,
17// kept in $.store.
18
19const BAND_ROWS = 3;
20
21let config: Config = DEFAULTS;
22// the running turn: when it started
23let turn: { startedAt: number } | undefined;
24// the breath the band last posted, for the spinner
25let phase: { word: string; exercise: string } | undefined;
26
27const log =
28 ($: { ui: { log: (text: string) => void } }, what: string) =>
29 (err: unknown) =>
30 $.ui.log(`x-mod-breather: ${what}: ${err}`);
31
32export const register: Register = (on) => {
33 on('session.start', async ($, e, next) => {
34 const r = await next(e);
35 const saved = await $.store
36 .get('config')
37 .catch(log($, 'store read failed'));
38 config = readConfig(saved);
39 await $.command
40 .register({
41 argumentHint:
42 '[on | off | hrv | sigh | box | 478 | delay <s> | help]',
43 description:
44 'Breathing exercises above the prompt while Claude works: on, off, hrv, sigh, box, 478, delay (mindful-claude)',
45 immediate: true,
46 name: 'breathe',
47 })
48 .catch(log($, '/breathe not registered'));
49 return r;
50 });
51
52 on('command.run', { command: 'breathe' }, async ($, e) => {
53 const applied = applyCommand(config, e.args);
54 if (applied.config !== config) {
55 config = applied.config;
56 await $.store
57 .set('config', config)
58 .catch(log($, 'store write failed'));
59 $.ui.invalidate('ui.render');
60 }
61 return { text: applied.text };
62 });
63
64 on('turn.start', async ($, e, next) => {
65 turn = { startedAt: await $.clock.now() };
66 phase = undefined;
67 // the band is drawn from a delay on: wake the render hook when it has passed
68 if (config.delay > 0)
69 $.clock.after(config.delay * 1000, () =>
70 $.ui.invalidate('ui.render'),
71 );
72 $.ui.invalidate('ui.render');
73 return next(e);
74 });
75
76 on('turn.complete', async ($, e, next) => {
77 const r = await next(e);
78 // a subagent's run ends inside the main turn: the band keeps breathing
79 if (e.agentId) return r;
80 turn = undefined;
81 phase = undefined;
82 $.ui.invalidate('ui.render');
83 return r;
84 });
85
86 // the band posts its phase whenever the line changes (once a second): the spinner reads it
87 on('ui.message', async ($, e, next) => {
88 const data = e.data as { word?: unknown; exercise?: unknown } | null;
89 if (
90 typeof data?.word === 'string' &&
91 typeof data.exercise === 'string'
92 ) {
93 phase = { exercise: data.exercise, word: data.word };
94 if (config.spinner) $.ui.invalidate('ui.render');
95 }
96 return next(e);
97 });
98
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 // terminal and desktop both draw a Client; vscode and mobile do not
101 if (
102 (e.surface !== 'terminal' && e.surface !== 'desktop') ||
103 !config.enabled ||
104 !e.props.isWorking ||
105 e.props.hasSurvey
106 )
107 return next(e);
108 const now = await $.clock.now();
109 // a turn the hook saw start; else one that is working anyway (started before the plugin loaded)
110 const running = turn ?? (turn = { startedAt: now });
111 const elapsedMs = now - running.startedAt;
112 if (elapsedMs < config.delay * 1000) return next(e);
113 if (e.surface === 'desktop') {
114 // the desktop refuses a Client module (10 s, csp); an Svg's own SMIL clock needs no host tick
115 const { Box, Svg } = $.ui.resolve(e);
116 const exercise = exerciseOf(config.exercise);
117 return (
118 <Box flexDirection='column'>
119 <Svg
120 alt={`breathing guide: ${exercise.name}, ${exercise.pattern}`}
121 height={METER_HEIGHT}
122 key={`meter:${running.startedAt}:${config.exercise}`}
123 source={meterSvg(exercise, elapsedMs - config.delay * 1000)}
124 />
125 {await next(e)}
126 </Box>
127 );
128 }
129 const { Box, Client } = $.ui.resolve(e);
130 const rows = Math.min(BAND_ROWS, e.props.maxRows);
131 if (rows < 1) return next(e);
132 // the key names the turn: one instance per turn, remounted when the settings change mid-turn
133 const key = `breathe:${running.startedAt}:${config.exercise}`;
134 return (
135 <Box flexDirection='column'>
136 <Client
137 height={rows}
138 key={key}
139 module='./breathe.tsx'
140 props={{ elapsedMs, exercise: config.exercise }}
141 width={e.props.bodyColumns}
142 />
143 {await next(e)}
144 </Box>
145 );
146 });
147
148 // the spinner's word becomes the breath: `✻ Breathe in 4s…`
149 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
150 if (!config.enabled || !config.spinner || !phase) return next(e);
151 return next({ ...e, props: { ...e.props, message: phase.word } });
152 });
153};
154hooks/breath/exercises.ts 141 lines1// The four breathing exercises and the maths of one breath, ported from legacy/breathe.sh.
2// Pure functions: no engine, no surface.
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 {
21 exhaleMs: 5500,
22 hold1Ms: 0,
23 hold2Ms: 0,
24 inhaleMs: 5500,
25 key: 'hrv',
26 name: 'Coherent Breathing',
27 pattern: '5.5s in, 5.5s out',
28 },
29 {
30 exhaleMs: 10000,
31 hold1Ms: 1000,
32 hold2Ms: 0,
33 inhaleMs: 4000,
34 key: 'sigh',
35 name: 'Physiological Sigh',
36 pattern: 'double inhale, long exhale',
37 sip: true,
38 },
39 {
40 exhaleMs: 4000,
41 hold1Ms: 4000,
42 hold2Ms: 4000,
43 inhaleMs: 4000,
44 key: 'box',
45 name: 'Box Breathing',
46 pattern: '4s in, 4s hold, 4s out, 4s hold',
47 },
48 {
49 exhaleMs: 8000,
50 hold1Ms: 7000,
51 hold2Ms: 0,
52 inhaleMs: 4000,
53 key: '478',
54 name: '4-7-8 Breathing',
55 pattern: '4s in, 7s hold, 8s out',
56 },
57];
58
59const ALIASES: Record<string, ExerciseKey> = {
60 '478': '478',
61 box: 'box',
62 coherence: 'hrv',
63 coherent: 'hrv',
64 hrv: 'hrv',
65 physiological: 'sigh',
66 relax: '478',
67 sigh: 'sigh',
68};
69
70export const isExerciseKey = (value: unknown): value is ExerciseKey =>
71 typeof value === 'string' && EXERCISES.some((e) => e.key === value);
72
73/** The exercise a word names (`hrv`, `coherent`, `sigh`, `box`, `478`, `relax`), or undefined. */
74export const resolveExercise = (word: string): ExerciseKey | undefined =>
75 ALIASES[word.trim().toLowerCase()];
76
77export const exerciseOf = (key: unknown): Exercise =>
78 EXERCISES.find((e) => e.key === key) ?? EXERCISES[0]!;
79
80export const cycleMs = (ex: Exercise): number =>
81 ex.inhaleMs + ex.hold1Ms + ex.exhaleMs + ex.hold2Ms;
82
83export type PhaseLabel = 'Breathe in' | 'Sip in' | 'Hold' | 'Breathe out';
84
85export type Phase = {
86 label: PhaseLabel;
87 /** Milliseconds left in this phase. */
88 remainingMs: number;
89 /** How full the lungs are, 0..1000. */
90 progress: number;
91};
92
93/** Ease-out (quadratic) over 0..1000: fast start, gentle deceleration. */
94export const ease = (x: number): number => Math.floor((x * (2000 - x)) / 1000);
95
96/** Where in the breath `elapsedMs` since the start lands. */
97export function phaseAt(ex: Exercise, elapsedMs: number): Phase {
98 const cycle = cycleMs(ex);
99 const t = ((Math.floor(elapsedMs) % cycle) + cycle) % cycle;
100 const inhaleEnd = ex.inhaleMs;
101 const hold1End = inhaleEnd + ex.hold1Ms;
102 const exhaleEnd = hold1End + ex.exhaleMs;
103
104 if (t < inhaleEnd) {
105 const eased = ease(Math.floor((t * 1000) / ex.inhaleMs));
106 // a sigh's first inhale fills 85%; the sip takes the rest
107 const progress = ex.sip ? Math.floor((eased * 850) / 1000) : eased;
108 return { label: 'Breathe in', progress, remainingMs: inhaleEnd - t };
109 }
110 if (t < hold1End) {
111 if (ex.sip) {
112 const eased = ease(
113 Math.floor(((t - inhaleEnd) * 1000) / ex.hold1Ms),
114 );
115 return {
116 label: 'Sip in',
117 progress: 850 + Math.floor((eased * 150) / 1000),
118 remainingMs: hold1End - t,
119 };
120 }
121 return { label: 'Hold', progress: 1000, remainingMs: hold1End - t };
122 }
123 if (t < exhaleEnd) {
124 const eased = ease(Math.floor(((t - hold1End) * 1000) / ex.exhaleMs));
125 return {
126 label: 'Breathe out',
127 progress: 1000 - eased,
128 remainingMs: exhaleEnd - t,
129 };
130 }
131 return { label: 'Hold', progress: 0, remainingMs: cycle - t };
132}
133
134/** The countdown the phase line shows: whole seconds, rounded up. */
135export const secondsLeft = (phase: Phase): number =>
136 Math.ceil(phase.remainingMs / 1000);
137
138/** The spinner's word, which the engine follows with its own ellipsis: `Breathe in 4s` */
139export const spinnerWord = (phase: Phase): string =>
140 `${phase.label} ${secondsLeft(phase)}s`;
141hooks/meter.ts 72 lines1import { type Exercise, cycleMs } from './breath/exercises.ts';
2import { levelAt } from './breath/meter-shape.ts';
3
4// The desktop band: sline's gruvbox VU grid, one SVG whose SMIL clock runs inside the webview's
5// sandboxed frame, so the host sends it once per turn and never ticks. Each column is one rect
6// whose height glides between keyframes; a mask of the grid cuts it into squares.
7
8const WIDTH = 760;
9const PAD = 4;
10const ROWS = 7;
11const HALF = 3;
12const CELL = 5;
13const GAP = 2;
14const STEP = CELL + GAP;
15const GRID_HEIGHT = ROWS * STEP - GAP;
16const FRAME_MS = 160;
17
18export const METER_HEIGHT = PAD + GRID_HEIGHT + PAD;
19
20// sline's bar ramp, centre out: green, yellow, orange, red (gruvbox-material dark and light)
21const STYLE =
22 ':root{color-scheme:light dark}.bg{fill:none}.off{fill:#cfcfcf;opacity:.5}.r0{stop-color:#7fb83a}.r1{stop-color:#f2b400}.r2{stop-color:#ff7a1a}.r3{stop-color:#f2364d}' +
23 '@media (prefers-color-scheme:dark){.bg{fill:#282828}.off{fill:#504945;opacity:.45}.r0{stop-color:#a9b665}.r1{stop-color:#d8a657}.r2{stop-color:#e78a4e}.r3{stop-color:#ea6962}}';
24
25// the desktop rebuilds an Svg on every band redraw (x-mod-stash polls every 4 s), restarting its SMIL clock; a negative
26// begin at the breath's own phase makes each rebuild resume mid-breath instead of snapping to the floor (FRM-354)
27export function meterSvg(ex: Exercise, breathMs = 0): string {
28 const total = cycleMs(ex);
29 const dur = `${(total / 1000).toFixed(2)}s`;
30 const begin = `-${((breathMs % total) / 1000).toFixed(2)}s`;
31 const cols = Math.floor((WIDTH + GAP) / STEP);
32 const gridWidth = cols * STEP - GAP;
33 const x0 = Math.floor((WIDTH - gridWidth) / 2);
34 // the centre line of the middle row: the top half is drawn, the bottom half is its mirror
35 const centre = PAD + HALF * STEP + CELL / 2;
36 const frames = Math.round(total / FRAME_MS);
37
38 let bars = '';
39 for (let c = 0; c < cols; c++) {
40 const ys: string[] = [];
41 for (let f = 0; f < frames; f++) {
42 const lit = levelAt(ex, c / (cols - 1), f / frames) * HALF;
43 ys.push((centre - CELL / 2 - lit * STEP).toFixed(1));
44 }
45 ys.push(ys[0]!);
46 // evenly spaced values need no keyTimes; linear glides between them
47 bars += `<rect x="${x0 + c * STEP}" y="${ys[0]}" width="${CELL}" height="${GRID_HEIGHT}"><animate attributeName="y" values="${ys.join(';')}" calcMode="linear" dur="${dur}" begin="${begin}" repeatCount="indefinite"/></rect>`;
48 }
49
50 const ramp = [3, 2, 1, 0, 1, 2, 3]
51 .map((k, i) => `<stop offset="${(i / 6).toFixed(3)}" class="r${k}"/>`)
52 .join('');
53 const area = `x="${x0}" y="${PAD}" width="${gridWidth}" height="${GRID_HEIGHT}"`;
54
55 return (
56 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${METER_HEIGHT}">` +
57 `<style>${STYLE}</style>` +
58 '<defs>' +
59 `<linearGradient id="ramp" x1="0" y1="${PAD}" x2="0" y2="${PAD + GRID_HEIGHT}" gradientUnits="userSpaceOnUse">${ramp}</linearGradient>` +
60 `<pattern id="cell" x="${x0}" y="${PAD}" width="${STEP}" height="${STEP}" patternUnits="userSpaceOnUse"><rect width="${CELL}" height="${CELL}" rx="1" fill="#fff"/></pattern>` +
61 `<mask id="cells"><rect ${area} fill="url(#cell)"/></mask>` +
62 `<clipPath id="top"><rect x="${x0}" y="${PAD}" width="${gridWidth}" height="${centre - PAD}"/></clipPath>` +
63 `<g id="bars" clip-path="url(#top)" fill="#fff">${bars}</g>` +
64 `<mask id="lit"><use href="#bars"/><use href="#bars" transform="translate(0 ${2 * centre}) scale(1 -1)"/></mask>` +
65 '</defs>' +
66 `<rect class="bg" width="${WIDTH}" height="${METER_HEIGHT}" rx="8"/>` +
67 `<rect class="off" ${area} mask="url(#cells)"/>` +
68 `<g mask="url(#cells)"><rect ${area} fill="url(#ramp)" mask="url(#lit)"/></g>` +
69 `</svg>`
70 );
71}
72hooks/breath/config.ts 98 lines1// The plugin's settings and the /breathe command that changes them. Pure: no engine, no surface.
2
3import {
4 EXERCISES,
5 type ExerciseKey,
6 exerciseOf,
7 isExerciseKey,
8 resolveExercise,
9} from './exercises.ts';
10
11export type Config = {
12 enabled: boolean;
13 exercise: ExerciseKey;
14 /** Seconds Claude works before the band appears; 0 shows it at once. */
15 delay: number;
16 /** Whether the spinner line reads the breath's phase too. */
17 spinner: boolean;
18};
19
20export const DEFAULTS: Config = {
21 delay: 0,
22 enabled: true,
23 exercise: 'hrv',
24 spinner: true,
25};
26
27/** A config from what the store held, field by field, defaults for the rest; unknown keys (a retired `style`) drop. */
28export function readConfig(saved: unknown): Config {
29 const s = (
30 typeof saved === 'object' && saved !== null ? saved : {}
31 ) as Record<string, unknown>;
32 return {
33 delay:
34 typeof s.delay === 'number' &&
35 s.delay >= 0 &&
36 Number.isFinite(s.delay)
37 ? s.delay
38 : DEFAULTS.delay,
39 enabled: typeof s.enabled === 'boolean' ? s.enabled : DEFAULTS.enabled,
40 exercise: isExerciseKey(s.exercise) ? s.exercise : DEFAULTS.exercise,
41 spinner: typeof s.spinner === 'boolean' ? s.spinner : DEFAULTS.spinner,
42 };
43}
44
45export function statusLine(c: Config): string {
46 const ex = exerciseOf(c.exercise);
47 return `breathe: ${c.enabled ? 'on' : 'off'} · ${ex.name} (${ex.pattern}) · delay ${c.delay}s · spinner ${c.spinner ? 'on' : 'off'}`;
48}
49
50export const HELP = [
51 '/breathe status',
52 '/breathe on | off show or hide the breathing band while Claude works',
53 ...EXERCISES.map(
54 (e) => `/breathe ${e.key.padEnd(14)}${e.name}: ${e.pattern}`,
55 ),
56 '/breathe delay <s> seconds Claude works before the band appears (0 = at once)',
57 '/breathe spinner on|off read the phase in the spinner line too',
58].join('\n');
59
60/** Applies one `/breathe` invocation; returns the new config and the transcript line. */
61export function applyCommand(
62 config: Config,
63 args: string,
64): { config: Config; text: string } {
65 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
66 const [head, arg] = words;
67 if (!head || head === 'status') return { config, text: statusLine(config) };
68 if (head === 'help') return { config, text: HELP };
69 if (head === 'on' || head === 'off') {
70 const next = { ...config, enabled: head === 'on' };
71 return { config: next, text: statusLine(next) };
72 }
73 const exercise = resolveExercise(head);
74 if (exercise) {
75 const next = { ...config, exercise };
76 return { config: next, text: statusLine(next) };
77 }
78 if (head === 'delay') {
79 const delay = Number(arg);
80 if (arg !== undefined && Number.isFinite(delay) && delay >= 0) {
81 const next = { ...config, delay };
82 return { config: next, text: statusLine(next) };
83 }
84 return {
85 config,
86 text: 'breathe: delay takes a number of seconds, 0 or more',
87 };
88 }
89 if (head === 'spinner') {
90 if (arg === 'on' || arg === 'off') {
91 const next = { ...config, spinner: arg === 'on' };
92 return { config: next, text: statusLine(next) };
93 }
94 return { config, text: 'breathe: spinner on or off' };
95 }
96 return { config, text: `breathe: no setting called "${head}"\n${HELP}` };
97}
98hooks/breathe.tsx 78 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code';
3
4import { exerciseOf, phaseAt, spinnerWord } from './breath/exercises.ts';
5import { levelsAt } from './breath/meter-shape.ts';
6
7// The terminal band: the desktop's meter in block glyphs, a surface module the hooks module
8// mounts above the prompt while Claude works. Its own frame clock ticks ten times a second, and
9// it posts the phase to the hooks module whenever it changes so the spinner can read it.
10//
11// Never name a local `h` here: every JSX tag compiles to a call of `h`.
12
13type Props = { exercise?: string; elapsedMs?: number } | undefined;
14type State = { tick: number; offsetMs: number; word: string };
15
16const TICK_MS = 100;
17const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
18// sline's bar ramp, bottom row up: green, yellow, orange
19const ROW_COLORS = ['#a9b665', '#d8a657', '#e78a4e'];
20const UNLIT = 'subtle';
21
22export default function Breathe(props: Props, surface: ClientSurface<State>) {
23 const { Box, Text } = surface.elements;
24 const exercise = exerciseOf(props?.exercise);
25
26 if (surface.state === undefined) {
27 // elapsedMs says how far into the turn the band mounted (a delay); the clock runs from there
28 surface.setState({ offsetMs: props?.elapsedMs ?? 0, tick: 0, word: '' });
29 surface.every(TICK_MS, () => {
30 const s = surface.state;
31 if (!s) return;
32 const tick = s.tick + 1;
33 const word = spinnerWord(phaseAt(exercise, s.offsetMs + tick * TICK_MS));
34 if (word !== s.word) surface.post({ exercise: exercise.name, word });
35 surface.setState({ offsetMs: s.offsetMs, tick, word });
36 });
37 }
38
39 const s = surface.state;
40 const elapsed = (s?.offsetMs ?? 0) + (s?.tick ?? 0) * TICK_MS;
41 const width = surface.columns || 80;
42 const rows = Math.min(ROW_COLORS.length, surface.rows || ROW_COLORS.length);
43 // a bar is one cell and a gap, like the desktop's squares
44 const levels = levelsAt(exercise, elapsed, Math.floor(width / 2), rows * BLOCKS.length);
45
46 const rowJSX = Array.from({ length: rows }, (_, i) => {
47 const row = rows - 1 - i;
48 return <Text>{meterRow(levels, row).map((run) => {
49 return <Text color={run.isLit ? ROW_COLORS[row] : UNLIT}>{run.text}</Text>;
50 })}</Text>;
51 });
52
53 return (
54 <Box flexDirection='column' width={width}>
55 {rowJSX}
56 </Box>
57 );
58}
59
60/* Helpers */
61
62/** One row of the meter, bottom row 0, as runs of lit and unlit text; the floor shows unlit bars as `▁`. */
63function meterRow(levels: number[], row: number): Run[] {
64 const runs: Run[] = [];
65 for (const level of levels) {
66 const fill = Math.max(0, Math.min(BLOCKS.length, level - row * BLOCKS.length));
67 const isLit = fill > 0;
68 const cell = isLit ? BLOCKS[fill - 1] : row === 0 ? '▁' : ' ';
69 const last = runs.at(-1);
70 if (last?.isLit === isLit) last.text += `${cell} `;
71 else runs.push({ isLit, text: `${cell} ` });
72 }
73 return runs;
74}
75
76/* Types */
77type Run = { isLit: boolean; text: string };
78hooks/breath/meter-shape.ts 41 lines1import { type Exercise, cycleMs, phaseAt } from './exercises.ts';
2
3// The meter's motion, shared by both renderers: the desktop bakes it into SMIL, the terminal
4// samples it every frame into block glyphs. A tweak here lands on both surfaces.
5
6const TAU = 2 * Math.PI;
7
8// soft peaks wander at whole-cycle rates, so the loop closes with no seam
9const PEAKS = [
10 { amp: 1.0, n: 1, phase: 0.0, pulse: 2, pulsePhase: 0.1, width: 0.09 },
11 { amp: 0.85, n: -1, phase: 0.37, pulse: 3, pulsePhase: 0.6, width: 0.06 },
12 { amp: 0.7, n: 2, phase: 0.71, pulse: 1, pulsePhase: 0.3, width: 0.05 },
13 { amp: 0.6, n: -2, phase: 0.15, pulse: 2, pulsePhase: 0.85, width: 0.04 },
14] as const;
15
16/** How lit column `u` (0..1 across) is at point `p` (0..1) of the cycle, 0..1. */
17export function levelAt(ex: Exercise, u: number, p: number): number {
18 const d = Math.abs(u * 2 - 1);
19 // the inhale blooms from the middle out, the exhale folds back in: the edges lag the breath
20 const lag = (((p - d * 0.15) % 1) + 1) % 1;
21 const breath = phaseAt(ex, lag * cycleMs(ex)).progress / 1000;
22 let shape = 0.3;
23 for (const k of PEAKS) {
24 const cx = 0.5 + 0.4 * Math.sin(TAU * (p * k.n + k.phase));
25 const h = k.amp * (0.6 + 0.4 * Math.sin(TAU * (p * k.pulse + k.pulsePhase)));
26 shape += h * Math.exp(-(((u - cx) / k.width) ** 2));
27 }
28 // a spectrum texture: neighbouring bars differ, and the pattern slides slowly
29 const tex = 0.5 + 0.5 * Math.sin(TAU * (u * 19 + p)) * Math.sin(TAU * (u * 8.5 - p * 2));
30 return Math.min(1, breath * Math.min(1, 1.35 * shape * (0.4 + 0.8 * tex)) * 1.1);
31}
32
33/** Every column's level at `elapsedMs`, in whole steps of `steps`. */
34export function levelsAt(ex: Exercise, elapsedMs: number, columns: number, steps: number): number[] {
35 const total = cycleMs(ex);
36 const p = (((elapsedMs % total) + total) % total) / total;
37 return Array.from({ length: columns }, (_, c) => {
38 return Math.round(levelAt(ex, columns > 1 ? c / (columns - 1) : 0.5, p) * steps);
39 });
40}
41