A quiet place for a noisy world.
Sanctuary is an immersive, fully client-side space for breathing, listening, focusing and sleeping. It is not a dashboard or a productivity tool. It is a small digital place you can step into for a few minutes, after a long day, with headphones on and the lights off.
Live: https://wembie.github.io/sanctuary/
The one metric that matters: does someone arrive stressed and, five minutes later, feel different? Every decision in this repository (design, motion, sound, code) is measured against that.
| Space | What it does |
|---|---|
| Threshold | A few quiet lines, then a doorway. Enter with sound or in silence. |
| Home | A greeting for the time of day, four needs, "Or simply stay", and a daily pause. |
| Breathe | An orb that fills with your breath. Calm (4·4·6·2), Box (4·4·4·4), Deep (4·7·8), Custom. |
| Sounds | Eight synthesized soundscapes you can layer, plus seven pieces of music composed live. |
| Focus | 25 / 45 / 60 / custom minutes. A hairline ring, a soft tone, and "You did enough." |
| Sleep | The screen dims gradually; the sound follows only at the end. 15 / 30 / 60 / 90 / ∞. |
| Disconnect | "Put your phone down." Then nothing but the place, until "Welcome back." |
| Explore | Six places: Night, Ocean, Forest, Rain, Fireplace, Clouds. Sky, particles and sound all follow. |
| Stillness | "You don't have to do anything." Then every piece of interface leaves. |
Touch the empty sky anywhere and it answers with a slow ring. Now and then, at night, a shooting star.
- Less UI, more experience. Controls appear when you need them and fade when you're still.
- Nothing here is urgent. Every motion is slow; every sound fades in and out; no alarms.
- Never blank, never broken. No audio? It stays silent. No canvas? The CSS sky remains. No storage? Preferences just don't persist. No technical error ever reaches the screen.
- No accounts, no tracking, no backend. Preferences live in
localStorageon the device.
| Choice | Why |
|---|---|
| React 19 + TypeScript | Strict types, composition, lazy-loaded pages. Nothing heavier needed. |
| Vite | Fast builds, CSS modules, code splitting, relative base for GitHub Pages. |
| Canvas 2D | One canvas, a few hundred particles, pre-rendered glow sprites. No WebGL dependency. |
| Web Audio API | Every sound is synthesized live from noise and oscillators: no audio files, no licenses. |
| CSS custom properties | Themes as tokens; @property-registered colors crossfade between environments. |
| Vitest + jsdom | Tests for the logic that matters. |
| Manrope (self-hosted) | Via @fontsource-variable. No third-party font requests. |
There is no router library, no state library, no animation library and no UI kit. Runtime
dependencies: react, react-dom, and a font.
src/
├── app/ App shell, routes, navigation, page registry, cross-cutting actions
├── components/
│ ├── ambient/ AmbientBackground: CSS sky + particle canvas
│ ├── audio/ SoundMixer
│ ├── breathing/ BreathingOrb, PatternEditor
│ ├── cursor/ CalmCursor
│ ├── experiences/ Stillness ("do nothing" mode)
│ ├── intro/ Loader, Threshold
│ ├── navigation/ FloatingNavigation, Link
│ ├── session/ DurationPicker, ProgressRing, SessionControls
│ ├── settings/ SettingsPanel
│ └── ui/ Icon, Slider, Switch, Segmented, Modal, FadeText
├── hooks/ useStore, useHashRoute, useIdle, useSession, useAnimationFrame, …
├── i18n/ Typed dictionaries (en, es, pt, fr), detection, useT()
├── lib/ Pure logic: breathing, timer, sleep curve, random, storage, store
│ └── ambient/ AmbientEngine (canvas particle system)
├── pages/ One lazy-loaded chunk per route
├── services/audio/ AudioManager, synthesis primitives, generators, catalog, mix planning
├── store/ settings, mix, experience (persisted); scene (in-memory)
├── styles/ tokens.css (design system), global.css
└── themes/ Environments: palettes, particle presets, soundscapes
Languages. English, Español, Português, Français. No i18n library: each locale is a plain
object typed as Messages (the shape of src/i18n/en.ts), so a missing or extra key fails
tsc. Strings whose grammar depends on values are small functions. useT() returns the current
dictionary; the language comes from Settings or, on "Auto", from navigator.languages.
To add a language: copy src/i18n/en.ts to xx.ts, type it export const xx: Messages = {…},
translate, then add xx to LOCALES/LOCALE_NAMES in src/i18n/index.ts and to
LANGUAGE_OPTIONS in src/store/settings.ts (a test checks they match). Prefer gender-neutral
phrasing: the visitor could be anyone.
State. Four tiny stores built on useSyncExternalStore: settings, mix and experience
persist to localStorage (namespaced, sanitized on load: stored data is never trusted);
scene is in-memory (intensity, immersive mode, stillness). Per-frame values like breath
fullness live in a plain mutable object (sceneSignals) so they never trigger renders.
Audio. AudioManager owns a single AudioContext, created inside the first user gesture.
sync(mix) diffs what's playing against what's wanted (planMix, pure and tested) and fades
every change. Each voice is a VoiceGraph that tracks its nodes, sources and timers so it can be
torn down completely. One-shot events (raindrops, crackles, birds, thunder) are scheduled ahead on
the audio clock so background-tab timer throttling never causes gaps. The context suspends itself
after 20 s of silence to save battery.
Visuals. AmbientEngine runs one requestAnimationFrame loop, sizes the particle count to the
screen, caps DPR, crossfades between environments, reacts to pointer and taps, and breathes with
sceneSignals.breath. It measures its own frame times and quietly drops to a lighter mode if the
device struggles. With reduced motion it draws a single still frame.
Routing. Hash routes (#/breathe) so every URL works on GitHub Pages with no server rules.
public/404.html redirects path-style URLs (/sanctuary/breathe) to their hash equivalent.
Requires Node 20+ (see .nvmrc).
npm install
npm run dev # http://localhost:5173| Script | Does |
|---|---|
npm run dev |
Dev server with HMR |
npm run build |
Typecheck, then production build → dist/ |
npm run preview |
Serve the production build locally |
npm run lint |
ESLint (typescript-eslint strict, react-hooks) |
npm run typecheck |
tsc --noEmit |
npm test |
Vitest, once |
npm run format |
Prettier |
Tests cover logic where bugs would actually hurt: breathing phase math and easing, the pausable stopwatch and clock formatting, the sleep dimming curve, storage failure modes, store persistence and sanitizers, mix diffing, perceptual volume, theme tokens and route parsing. Purely visual components are verified by hand, in a browser, on real devices.
npm testOne pipeline, .github/workflows/ci.yml:
| Trigger | Jobs |
|---|---|
| Pull request | verify: version check → install → lint → format → typecheck → test → build |
Push to main |
verify → deploy (GitHub Pages) → release (tag + GitHub Release) |
VERSION (one line, SemVer) is the single source of truth. The app reads it at build time
(shown in Settings), CI validates it, and the release job uses it. package.json deliberately
has no version field, so there is nothing to keep in sync.
To release:
- Change
VERSION, e.g.0.3.0. - Add a
## [0.3.0] — YYYY-MM-DDsection toCHANGELOG.md(CI fails if it's missing). - Merge to
main.
The pipeline then creates the v0.3.0 tag, publishes the GitHub Release with that changelog
section as notes, and attaches sanctuary-v0.3.0.zip (the built site). Nothing is tagged or
released by hand. Check locally with npm run version:check.
One-time setup: Settings → Pages → Build and deployment → Source: GitHub Actions.
The build uses a relative base (./), so it works under /<repo>/, at a custom domain, or from
any static file server without changes.
The repository contains no audio files. Everything you hear is made in the browser:
- Ambient sounds: eight noise-and-oscillator generators (
src/services/audio/generators.ts). - Music: seven original pieces, one per category, composed live by a small generative
engine (
src/services/audio/music/). ARecipedescribes a piece (root, chord progression, tempo, instruments: pads, bells, arpeggio, singing bowl, lo-fi keys and drums, reverb); the composer schedules it ahead on the audio clock forever, never quite the same way twice. Only one piece plays at a time; changing tracks crossfades.
Your own tracks: drop a file into src/assets/music/<category>/ (e.g. sleep/night-swim.mp3)
and rebuild. It's discovered automatically with import.meta.glob; an optional night-swim.json
beside it sets the title and credit. Only add audio you may redistribute. See
src/assets/music/README.md.
- Semantic landmarks, one
h1per view, labelled controls, native<dialog>for settings. - Full keyboard support: arrow keys in radio groups,
Spacepauses breathing,Ffullscreen,Msound on/off,Escleaves stillness or closes settings. Visible focus everywhere. - Live regions announce breathing phases and changing lines, politely.
prefers-reduced-motion(or the in-app Motion setting) stops particles and drifting layers, shortens transitions and reduces the orb's movement; everything keeps working.- Text contrast is kept high on every environment; state is never conveyed by color alone.
- One canvas, one loop, sprite-based glows, batched rain strokes, DPR capped at 2 (1 in light mode).
- Particle count scales with screen area; light mode halves it and renders at ~30 fps.
- Runtime self-check drops to light mode after sustained slow frames.
- Animation pauses with the tab; the cursor loop stops when the mouse rests.
- Pages are separate chunks; the first one preloads during the loader.
- Aurora and mist are GPU-composited transforms of gradients: no
filter: bluron large layers.
Current Chrome, Edge, Firefox, Safari, iOS Safari and Chrome Android. Missing features degrade:
no backdrop-filter → opaque glass; no Screen Wake Lock → the screen may sleep during a session; no Media Session or
MediaStream output → no lock-screen controls, audio plays directly; no Web Audio → silent; no Fullscreen API → option hidden;
no @property → colors switch instead of crossfading.
- Fork, then create a branch:
feat/…,fix/…,perf/…,a11y/…,docs/…. - Keep
npm run lint && npm run typecheck && npm test && npm run buildgreen. - Use Conventional Commits:
feat: add zen garden,perf: batch ember rendering,a11y: label duration picker. - Before opening a PR, ask of your change: is anything here faster, louder or busier than it needs to be? If so, slow it down.
- Update
CHANGELOG.mdunder Unreleased. Maintainers cut a release by bumpingVERSION(see CI/CD, deployment and releases).
Versioning follows SemVer. 0.x while the experience takes shape; 1.0.0
when it is stable, polished and complete.
Created and developed by Juan (@Wembie).