SLOPSHOPPER

x-mod-breather

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

newbandspinnercommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · x-mod-breather
› 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 ⎿ x-mod-breather: breathe: on · Coherent Breathing (5.5s in, 5.5s out) · 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

🎞️ 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">

🧭 toc

  • fleet — skills, memory, sline, hotkeys
  • chords — the keyboard map, live
  • mirror — home/ is ~
  • machine — brew, defaults, launchd
  • link it — a fresh mac install

🛸 fleet

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

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">

🪞 mirror

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">

💻 machine

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

🔗 link it

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.

Source 6 files
hooks/register.tsx 154 lines
1/* @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};
154
hooks/breath/exercises.ts 141 lines
1// 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`;
141
hooks/meter.ts 72 lines
1import { 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}
72
hooks/breath/config.ts 98 lines
1// 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}
98
hooks/breathe.tsx 78 lines
1/* @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 };
78
hooks/breath/meter-shape.ts 41 lines
1import { 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