Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9121bb3
fix: retry a failed settings read and log write failures
mmazzarolo Aug 2, 2026
2adb1f1
build: turn on TypeScript strict mode
mmazzarolo Aug 2, 2026
9c127c8
build: turn on noUncheckedIndexedAccess and close the gaps it found
mmazzarolo Aug 2, 2026
994c8a5
fix: let callers choose an animation easing
mmazzarolo Aug 2, 2026
d981e30
feat: give the design system a semantic colour layer
mmazzarolo Aug 2, 2026
70b3406
fix: give secondary text and the close button a legible colour in bot…
mmazzarolo Aug 2, 2026
d85fa61
test: silence the expected storage warnings in the settings suite
mmazzarolo Aug 2, 2026
89ee2f9
feat: add coherent and extended-exhale patterns
mmazzarolo Aug 2, 2026
4fd7e5e
fix: give the bell mode a different cue for each direction
mmazzarolo Aug 2, 2026
8e9653b
chore: delete unreferenced assets
mmazzarolo Aug 2, 2026
eb8294b
chore: re-encode the audio to a consistent format
mmazzarolo Aug 2, 2026
50d6a7f
test: make the settings UI implementations answer to one contract
mmazzarolo Aug 2, 2026
f37734b
Merge branch 'worktree-agent-a982d45aa7fe1c5e3' into claude/overnight…
mmazzarolo Aug 2, 2026
cb2224a
fix(exercise): pause the session only on a real background
mmazzarolo Aug 2, 2026
010e4f9
fix: keep the current appearance when the system theme switch goes off
mmazzarolo Aug 2, 2026
a6e7bd0
fix(exercise): tell the user that the last breath continues
mmazzarolo Aug 2, 2026
da3aadb
fix(exercise): keep the breathing circle alive on short steps
mmazzarolo Aug 2, 2026
492bbc1
fix(exercise): limit a long timer gap instead of discarding it
mmazzarolo Aug 2, 2026
f1b39bc
fix(exercise): keep the screen awake for the full session
mmazzarolo Aug 2, 2026
cc73275
feat(exercise): announce the steps to a screen reader
mmazzarolo Aug 2, 2026
58555d3
feat(exercise): honour the reduce motion setting of the system
mmazzarolo Aug 2, 2026
5a73579
feat(exercise): start the voice for a user of a screen reader
mmazzarolo Aug 2, 2026
12c38aa
refactor(exercise): guard the step index of the loop
mmazzarolo Aug 2, 2026
a51ba1c
fix(exercise): apply the accessibility answers on the first frame
mmazzarolo Aug 2, 2026
dd5af51
Merge branch 'worktree-agent-a03324f965a8d18b8' into claude/overnight…
mmazzarolo Aug 2, 2026
07cd503
fix: hold the star field still under Reduce Motion
mmazzarolo Aug 2, 2026
8444367
fix: stop iOS drawing a second, tiny splash screen
mmazzarolo Aug 3, 2026
70edf7b
fix: make the native and JS splash screens draw the same thing
mmazzarolo Aug 3, 2026
dfa82ca
Merge remote-tracking branch 'origin/master' into claude/overnight-im…
mmazzarolo Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
"slug": "breathly",
"version": "2.2.0",
"orientation": "portrait",
"icon": "./assets/ios-icon.png",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"icon": "./assets/ios-icon.png",
"icon": "./assets/icon.png",
"buildNumber": "18",
"bundleIdentifier": "com.mmazzarolo.breathly",
"infoPlist": {
Expand All @@ -32,7 +32,7 @@
"foregroundImage": "./assets/android-adaptive-icon-foreground.png",
"backgroundColor": "#ffffff"
},
"icon": "./assets/android-icon.png",
"icon": "./assets/icon.png",
"package": "com.mmazzarolo.breathly",
"versionCode": 3145750
},
Expand Down Expand Up @@ -75,6 +75,11 @@
"image": "./assets/splash.png",
"resizeMode": "cover",
"backgroundColor": "#F2F2F1",
"enableFullScreenImage_legacy": true,
"dark": {
"image": "./assets/splash-dark.png",
"backgroundColor": "#0f172a"
},
"android": {
"drawable": {
"icon": "./assets/android-splash-screen-icon.xml"
Expand Down
Binary file modified assets/audio/cuebell1.mp3
Binary file not shown.
Binary file modified assets/audio/cuebell2.mp3
Binary file not shown.
Binary file modified assets/audio/endingbell1.mp3
Binary file not shown.
Binary file modified assets/audio/lauraexhale.mp3
Binary file not shown.
Binary file modified assets/audio/laurahold.mp3
Binary file not shown.
Binary file modified assets/audio/laurainhale.mp3
Binary file not shown.
Binary file removed assets/audio/paulbreatheout.mp3
Binary file not shown.
Binary file modified assets/audio/paulexhale.mp3
Binary file not shown.
Binary file modified assets/audio/paulhold.mp3
Binary file not shown.
Binary file modified assets/audio/paulinhale.mp3
Binary file not shown.
Binary file removed assets/audio/paulthein.mp3
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-Bold.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-BoldItalic.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-Extralight.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-ExtralightItalic.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-Italic.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-LightItalic.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-MediumItalic.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-Semibold.otf
Binary file not shown.
Binary file removed assets/fonts/GeneralSans-SemiboldItalic.otf
Binary file not shown.
Binary file removed assets/fonts/Lora-Bold.otf
Binary file not shown.
Binary file removed assets/fonts/Lora-BoldItalic.otf
Binary file not shown.
Binary file removed assets/fonts/Lora-Italic.otf
Binary file not shown.
Binary file removed assets/fonts/Lora-MediumItalic.otf
Binary file not shown.
Binary file removed assets/fonts/Lora-Regular.otf
Binary file not shown.
Binary file removed assets/fonts/Lora-SemiBoldItalic.otf
Binary file not shown.
File renamed without changes
Binary file removed assets/images/background-stars-horizontal-old.png
Binary file not shown.
Binary file removed assets/ios-icon.png
Binary file not shown.
Binary file added assets/splash-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 124 additions & 5 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = {
preset: "jest-expo",
setupFiles: ["<rootDir>/jest.setup.js"],
moduleNameMapper: {
"^@breathly/(.*)$": "<rootDir>/src/$1",
},
Expand Down
5 changes: 5 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// AsyncStorage is a native module, so anything that imports the settings store — which is
// most of the app — needs its Jest mock. The package ships one.
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock"),
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
},
"devDependencies": {
"@babel/core": "^7.19.3",
"@testing-library/react-native": "^14.0.1",
"@types/color": "^3.0.3",
"@types/jest": "29.5.14",
"@types/ms": "^2.1.0",
Expand Down
32 changes: 26 additions & 6 deletions src/assets/pattern-presets.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import ms from "ms";
import { PatternPreset } from "../types/pattern-preset";
import { PatternPreset } from "@breathly/types/pattern-preset";

export const patternPresets: PatternPreset[] = [
// Sorted by display name. The `id` of a preset is persisted in the settings store and
// validated by `normalizePersistedSettingsState`, so ids must never change: an id that
// no longer matches silently resets that user to the Square pattern.
//
// Typed as a non-empty tuple, so the first entry can serve as a total fallback without an
// assertion.
export const patternPresets: [PatternPreset, ...PatternPreset[]] = [
{
id: "deep-calm",
name: "4-7-8 Deep Calm",
steps: [ms("4s"), ms("7s"), ms("8s"), 0],
description: "A natural tranquilizer for the nervous system. Do it at least twice a day.",
},
{
id: "awake",
name: "Awake",
Expand All @@ -10,10 +22,18 @@ export const patternPresets: PatternPreset[] = [
"Use this technique first thing in the morning for quick burst of energy and alertness.",
},
{
id: "deep-calm",
name: "Deep Calm",
steps: [ms("4s"), ms("7s"), ms("8s"), 0],
description: "A natural tranquilizer for the nervous system. Do it at least twice a day.",
id: "coherent",
name: "Coherent",
steps: [ms("5.5s"), 0, ms("5.5s"), 0],
description:
"Equal inhales and exhales at about five and a half breaths per minute. This pace brings the heart rate and the breath into step.",
},
{
id: "extended-exhale",
name: "Extended Exhale",
steps: [ms("4s"), 0, ms("6s"), 0],
description:
"The exhale lasts longer than the inhale, which is the simplest way to settle. Use it when you have only a minute.",
},
{
id: "pranayama",
Expand Down
27 changes: 21 additions & 6 deletions src/core/splash-screen-manager.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as SplashScreen from "expo-splash-screen";
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Animated, Easing, Platform, StyleSheet, View } from "react-native";
import { Animated, Easing, Platform, StyleSheet, useColorScheme, View } from "react-native";
import {
getRemainingSplashDurationMs,
maximumSplashWaitMs,
Expand All @@ -12,16 +12,29 @@ if (Platform.OS !== "web") {
void SplashScreen.preventAutoHideAsync().catch(() => undefined);
}

const splashImageAsset = require("../../assets/splash.png");
// The same two assets the native splash uses, so the hand-off from the launch screen to
// this overlay draws exactly the same thing.
const splashImageAssets = {
light: require("../../assets/splash.png"),
dark: require("../../assets/splash-dark.png"),
};

const splashBackgroundColor = "#F2F2F1";
// Keep these in step with the `expo-splash-screen` plugin config in `app.json`.
const splashBackgroundColors = {
light: "#F2F2F1",
dark: "#0f172a",
};

export const SplashScreenManager: React.FC<PropsWithChildren> = ({ children }) => {
if (Platform.OS === "web") return <>{children}</>;
return <NativeSplashScreenManager>{children}</NativeSplashScreenManager>;
};

const NativeSplashScreenManager: React.FC<PropsWithChildren> = ({ children }) => {
// The *system* appearance, not the app's resolved theme. The native launch screen can only
// follow the system, and the settings store has not hydrated yet at this point, so reading
// anything else here would make the two layers disagree.
const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light";
const mountedAtMs = useRef(Date.now()).current;
const opacity = useMemo(() => new Animated.Value(1), []);
const isHomeScreenReady = useHomeScreenStatusStore((state) => state.isHomeScreenReady);
Expand Down Expand Up @@ -79,13 +92,16 @@ const NativeSplashScreenManager: React.FC<PropsWithChildren> = ({ children }) =>
{!isSplashComplete && (
<Animated.View
pointerEvents="none"
style={[styles.overlay, { opacity }]}
style={[
styles.overlay,
{ opacity, backgroundColor: splashBackgroundColors[systemColorScheme] },
]}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.Image
style={styles.image}
source={splashImageAsset}
source={splashImageAssets[systemColorScheme]}
resizeMode="cover"
onLoadEnd={handleOverlaySettled}
fadeDuration={0}
Expand All @@ -106,7 +122,6 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
left: 0,
backgroundColor: splashBackgroundColor,
},
image: {
width: "100%",
Expand Down
65 changes: 65 additions & 0 deletions src/design/__tests__/colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { themeColors } from "../colors";

// WCAG 2.x relative luminance and contrast ratio.
const channelLuminance = (channel: number) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};

const relativeLuminance = (hex: string) => {
const value = hex.replace("#", "");
const red = Number.parseInt(value.slice(0, 2), 16);
const green = Number.parseInt(value.slice(2, 4), 16);
const blue = Number.parseInt(value.slice(4, 6), 16);
return (
0.2126 * channelLuminance(red) +
0.7152 * channelLuminance(green) +
0.0722 * channelLuminance(blue)
);
};

const contrastRatio = (foreground: string, background: string) => {
const foregroundLuminance = relativeLuminance(foreground);
const backgroundLuminance = relativeLuminance(background);
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
};

const bodyTextMinimum = 4.5; // WCAG 1.4.3
const controlMinimum = 3; // WCAG 1.4.11

describe.each(["light", "dark"] as const)("the %s scheme", (scheme) => {
const theme = themeColors[scheme];

it("keeps primary text legible on the background", () => {
expect(contrastRatio(theme.text, theme.background)).toBeGreaterThanOrEqual(bodyTextMinimum);
});

it("keeps secondary text legible on the background", () => {
// The app used one secondary colour with no dark counterpart, so this failed in both
// schemes and worse in dark. The roles carry a value per scheme now.
expect(contrastRatio(theme.textSecondary, theme.background)).toBeGreaterThanOrEqual(
bodyTextMinimum,
);
});

it("keeps secondary text legible on a surface", () => {
expect(contrastRatio(theme.textSecondary, theme.surface)).toBeGreaterThanOrEqual(
bodyTextMinimum,
);
});

it("keeps controls visible against the background", () => {
// The exercise screen's close button is the only way out of a session, and it measured
// 1.37:1 in light mode.
expect(contrastRatio(theme.control, theme.background)).toBeGreaterThanOrEqual(controlMinimum);
});
});

describe("the contrast helper", () => {
it("agrees with the known extremes", () => {
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
expect(contrastRatio("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
});
});
32 changes: 31 additions & 1 deletion src/design/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const colors = {
"stone-200": "#e7e5e4", // Borders in settings
"stone-300": "#d6d3d1", // Disabled controls in settings
"slate-300": "#cbd5e1", // Web settings section bottom border
"slate-500": "#64748b", // Secondary text in light mode, settings borders
"slate-400": "#94a3b8", // Secondary text in dark mode
"slate-500": "#64748b", // Settings borders, controls on the light background
"slate-600": "#475569", // Settings stepper border in dark mode
"slate-700": "#334155", // Settings bg border in dark mode
"slate-800": "#1e293b", // Primary text in light mode
Expand All @@ -24,3 +25,32 @@ export const colors = {
"gray-light": "#E7E9E6", // Home screen button
},
};

// What each colour is *for*, per scheme. The palette above says what a colour is; this says
// where it belongs. Components read these instead of pairing a light value with a hand-written
// `*Dark` override, which is how several elements ended up with no dark variant at all.
//
// Contrast against the scheme background, by the WCAG 2.x formula:
// light text 10.02:1 · textSecondary 6.95:1 · control 4.36:1
// dark text 17.85:1 · textSecondary 6.97:1 · control 12.03:1
// Body text needs 4.5:1 and a control outline needs 3:1, so every pair above has room.
export const themeColors = {
light: {
background: colors["stone-100"],
surface: colors.white,
text: colors["slate-800"],
textSecondary: colors["slate-600"],
border: colors["stone-200"],
control: colors["slate-500"],
},
dark: {
background: colors["slate-900"],
surface: colors["slate-800"],
text: colors.white,
textSecondary: colors["slate-400"],
border: colors["slate-700"],
control: colors["slate-300"],
},
} as const;

export type ThemeColors = (typeof themeColors)["light"];
8 changes: 7 additions & 1 deletion src/design/theme.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useColorScheme as useSystemColorScheme } from "react-native";
import { resolveColorScheme, type ColorScheme } from "@breathly/design/color-scheme";
import { themeColors, type ThemeColors } from "@breathly/design/colors";
import { useSettingsStore } from "@breathly/stores/settings";

export { type ColorScheme };
export { type ColorScheme, type ThemeColors };

// The color scheme that the app renders with.
//
Expand All @@ -15,3 +16,8 @@ export const useColorScheme = (): ColorScheme => {
const theme = useSettingsStore((state) => state.theme);
return resolveColorScheme(systemColorScheme, shouldFollowSystemDarkMode, theme);
};

// The colours for the scheme the app is rendering with. Prefer this over reaching into the
// palette and writing a `*Dark` style beside every rule: a role that exists here cannot be
// left without a dark value by accident.
export const useThemeColors = (): ThemeColors => themeColors[useColorScheme()];
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
getInterludeAccessibilityLabel,
getStepAccessibilityLabel,
} from "../accessibility-announcements";

describe("exercise accessibility labels", () => {
it("reads the step and its duration", () => {
expect(getStepAccessibilityLabel("Inhale", 4_000)).toBe("Inhale, 4 seconds");
expect(getStepAccessibilityLabel("Exhale", 8_000)).toBe("Exhale, 8 seconds");
});

it("reads a duration of one second in the singular", () => {
expect(getStepAccessibilityLabel("Hold", 1_000)).toBe("Hold, 1 second");
});

// The custom pattern of the settings screen moves in steps of half a second.
it("reads the half seconds of a custom pattern", () => {
expect(getStepAccessibilityLabel("Hold", 500)).toBe("Hold, 0.5 seconds");
expect(getStepAccessibilityLabel("Inhale", 4_500)).toBe("Inhale, 4.5 seconds");
expect(getStepAccessibilityLabel("Exhale", 99_000)).toBe("Exhale, 99 seconds");
});

it("reads the countdown of the interlude", () => {
expect(getInterludeAccessibilityLabel(3)).toBe("Starting session in 3");
});
});
32 changes: 30 additions & 2 deletions src/screens/exercise-screen/__tests__/exercise-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,41 @@ describe("exercise session lifecycle", () => {
expect(resumed).toMatchObject({ status: "running", activeElapsedMs: 5_000 });
});

it("counts normal active heartbeats but discards suspension-sized gaps", () => {
it("counts normal heartbeats of the foreground", () => {
expect(getActiveTickDeltaMs(1_000, 1_250, true, 1_000)).toBe(250);
expect(getActiveTickDeltaMs(1_000, 11_000, true, 1_000)).toBe(0);
expect(getActiveTickDeltaMs(1_000, 2_000, true, 1_000)).toBe(1_000);
});

it("counts no time in the background and no time that goes backwards", () => {
expect(getActiveTickDeltaMs(1_000, 1_250, false, 1_000)).toBe(0);
expect(getActiveTickDeltaMs(2_000, 1_000, true, 1_000)).toBe(0);
});

// A gap longer than the maximum comes from a stall of the JavaScript thread.
// The time of the stall must not disappear from the session: a slow device
// stalls many times, and the session then runs past the selected time limit.
it("limits a long gap but keeps the time that it can confirm", () => {
expect(getActiveTickDeltaMs(1_000, 11_000, true, 1_000)).toBe(1_000);
expect(getActiveTickDeltaMs(1_000, 1_001_000, true, 1_000)).toBe(1_000);
});

// A discarded gap counts 750 ms of these 8_750 ms, thus the session of the
// user runs 8 seconds longer than the selected time limit.
it("counts the limited part of every stall instead of nothing", () => {
const maximumGapMs = 1_000;
const stallsMs = [250, 3_000, 250, 5_000, 250];
let clockMs = 0;
let elapsedMs = 0;

stallsMs.forEach((stallMs) => {
elapsedMs += getActiveTickDeltaMs(clockMs, clockMs + stallMs, true, maximumGapMs);
clockMs += stallMs;
});

expect(clockMs).toBe(8_750);
expect(elapsedMs).toBe(2_750);
});

it("restarts the interlude when it was interrupted before the exercise began", () => {
const paused = exerciseSessionReducer(createExerciseSession(), {
type: "pause",
Expand Down
Loading
Loading