This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
.claude/rules/preact.md— enforceable Preact + signals rules; always follow when editingsrc/**/*.{js,jsx}.claude/rules/debugging.md— reproduce reported bugs through the real UI control path before diagnosing.claude/skills/tymer-preact/SKILL.md— full rationale and Tymer-specific signals examples; load via the Skill tool when depth is needed
This is a countdown timer web application built with Preact and Vite called "Tymer". It's a Pomodoro-style timer that supports multiple periods with customizable durations and automatic time tracking persistence via localStorage.
pnpm run dev- Start development server (Vite) on port 5050pnpm run build- Build for production todist/folderpnpm run preview- Preview production buildpnpm test- Run tests with Vitestpnpm run test:coverage- Run tests with coverage reportpnpm run format/format:check- Prettier over the whole repopnpm run lint/lint:fix- Biome (JS/TS/JSON)pnpm run lint:css/lint:css:fix- Stylelint (SCSS)pnpm run lint:sh- shellcheckpnpm run lint:py/format:py/format:py:check- ruffpnpm run typecheck-tsc --noEmitpnpm run knip- orphan discovery
The split is the whole design, and it is what flake.nix is careful not to blur:
- The project installs libraries and pins them. Biome, Prettier
(+
prettier-plugin-sh), Stylelint, TypeScript, lint-staged and simple-git-hooks come frompackage.json; ruff frombuild-tools/tts/pyproject.toml, pinned exactly because ruff's DEFAULT rule set moves between releases (0.16 reportsI001where 0.15 does not — hence the explicitselectthere too). - The environment supplies runtimes and standalone binaries:
node,pnpm,uv,shellcheck. The project does not install these and must not try — no postinstall binary downloaders.nix developis one way to get them; nvm/corepack, apt and brew are equally fine, and CI uses none of them. Versions live in neutral files both the flake and CI read:.nvmrcfor node,build-tools/tts/.python-versionfor the interpreter uv provisions.
shfmt is deliberately absent: prettier-plugin-sh wraps mvdan/sh as WASM, so
shell formatting needs no binary and rides the Prettier config already there.
Prettier, in every language it can parse — including shell. biome.json sets
formatter.enabled: false for exactly this reason, so Biome contributes lint
fixes and import sorting only. Stylelint 17 ships no stylistic rules, with one
exception that had to be switched off: scss/operator-no-newline-after cannot
tell the / in grid-column: <start> / <end> from SCSS division, and errors
when Prettier wraps such a declaration at the slash (src/app/_stats.scss).
Scope is .prettierignore, not a glob. A glob in package.json was the previous
design and it silently left every .scss file unformatted for as long as the
stylesheets existed, because css in a brace list does not match scss. An
ignore file fails the other way: a new file type is formatted until someone
decides otherwise. Note .prettierrc sets tabWidth: 4 explicitly, which
overrides .editorconfig — hence the *.yml/*.yaml override restoring 2,
and the *.sh one turning off spaceRedirects (on by default, it would rewrite
every 2>/dev/null in the audio scripts).
.simple-git-hooks.js installs a one-line pre-commit that runs
pnpm exec lint-staged; lint-staged.config.js holds the actual chains. The
hook is installed by the prepare script, so pnpm install is the only
bootstrap. simple-git-hooks' own postinstall is denied in pnpm-workspace.yaml
so hook installation stays something this repo asks for explicitly.
Two rules govern lint-staged.config.js:
- Within a chain, Biome runs first and Prettier last (see above).
- No two write-capable globs may match the same file. lint-staged runs
different globs' chains concurrently, so an overlap means two processes
rewriting one file at once. This is why
.scssand.share single stacked entries, and whytscsits at the end of the JS/TS chain rather than in a glob of its own — as a function task, so no file list is appended to it.
Escape hatches: git commit --no-verify, or SKIP_SIMPLE_GIT_HOOKS=1.
Every gate is CI-enforced in .github/workflows/deploy.yml, running the same
package.json scripts over the whole tree. The hook is the fast loop; CI decides.
- Preact: React-like UI library (smaller than React)
- @preact/signals: State management with reactive signals
- Vite: Build tool and development server
- Vitest: Testing framework
- Sass: CSS preprocessing
- Howler: Audio library for sound effects
- date-fns: Date/time utilities
State Management: Uses Preact signals for reactive state management. The main timer state is in src/lib/timer.js with signals like timerState, currentPeriod, timerHasFinished.
Timer Logic: Core timer functionality in src/lib/timer.js includes:
- Multi-period timer configuration with work/break periods
- Auto-extension when periods complete
- Persistence to localStorage
- Sound effects on period transitions
Component Architecture:
src/app/main.jsx- Entry point, renders Timer componentsrc/components/timer/timer.jsx- Main timer component that initializes timer and renders all sub-componentssrc/components/timer/controls/- Timer controls (start/pause/reset) and period controlssrc/components/timer/durations-config/- Durations-config editor (pick/edit named period configs)src/components/timer/timeline/- Visual timeline representationsrc/components/timer/stats/- Statistics displaysrc/components/timer/debug/- Debug information components
Styling: SCSS files in src/app/ with component-specific styling using BEM-like naming conventions.
src/lib/timer.js- Core timer logic and state management (580+ lines)src/lib/period-configs.js- Named period configurations: parsing, persistence, CRUDsrc/lib/storage.js- localStorage persistence helperssrc/lib/app-update.js- new-deploy signal (updateReady) and the safe-reload policysrc/lib/timer-worker.js- 1 Hz tick worker, bundled by Vite (hashed, not inpublic/)src/app/register-sw.js- service-worker registration + periodic update checkssrc/lib/sounds.js- Audio playback using Howlersrc/lib/format.js- Time formatting utilitiesvite.config.js- Vite configuration with PWA plugin
- Test files use the
.test.tsextension, beside the module they cover - Every suite is a unit test over
src/lib/**(plustimeline-logicandpreview-model); there are no component tests, so@testing-library/preactis deliberately NOT a dependency — adding one means adding it back.@testing-library/jest-domstays becausesetup.tsimports it. - Test setup in
src/test/setup.ts, wired viatest.setupFilesinvite.config.js - Testing library: Vitest with jsdom environment
The app is configured as a Progressive Web App with:
- Service worker for offline functionality
- Web app manifest for installability
- Icons and assets in
public/directory
GitHub Pages serves index.html with a 10-minute max-age and no way to set headers, so freshness is the service worker's job. Every layer must therefore be either content-hashed or revisioned:
- Assets — JS, CSS and the tick worker are bundled by Vite and content-hashed. Nothing that changes between builds may live in
public/under a fixed name:timer-worker.jsused to, and went stale in the HTTP cache. It is nowsrc/lib/timer-worker.js, loaded vianew Worker(new URL('./timer-worker.js', import.meta.url), { type: 'module' }). index.html— precached by Workbox with a content revision, so a new build always produces a differentsw.js.- Registration —
injectRegister: nullinvite.config.js;src/app/register-sw.jsregisters throughvirtual:pwa-registerinstead. The plugin's injectedregisterSW.jsonly callsnavigator.serviceWorker.register()— the new worker activated but the open page kept running the old bundle, which is why deployed changes used to need a cache-disabled refresh. (virtual:pwa-registerneeds theworkbox-windowdependency.) - Update checks — the browser only re-checks
sw.json navigation, soregister-sw.jsalso callsregistration.update()every 15 min, onvisibilitychangeand ononline. Those requests bypass the HTTP cache. - Applying the update —
src/lib/app-update.jsowns the policy.registerType: 'autoUpdate'would reload unconditionally; insteadonNeedReloadsets theupdateReadysignal and aneffectreloads as soon as it is safe: idle or completed, and the durations panel closed. Reloading is lossless (the session is persisted and elapsed is clock-derived) but it would drop half-typed editor text. While a session is running theBuildInfoavatar becomes a pulsing button that reloads on click; otherwise the reload happens when the session ends. - Build identity —
__BUILD_COMMIT__/__BUILD_TIME__(UTC) are injected invite.config.jsand shown in theBuildInfotooltip, so the running build can be identified without devtools. - Runtime caching — images use
StaleWhileRevalidate;CacheFirstpinned unhashed icons for up to 30 days.
The hardcoded default periods live in the PERIOD_CONFIG constant in src/lib/config.js. It is exposed as the readonly built-in "Default" config.
Users can also create unlimited named period configurations via the durations-config editor (src/lib/period-configs.js):
- Each config is a text definition, one period per line:
<Type> <Duration> <Note>—TypeisW/B/F(work/break/fun, case-insensitive);Durationis minutes (plain number) orh:mm(when it contains:);Noteis optional. Empty and unparseable lines are ignored. SeeparseConfigText. - Configs and the last-selected config are persisted to localStorage (
periodConfigs/activeConfigId). The Reset button restores the active config (activeConfigPeriodsintimer.js). - Editing is only allowed while no meaningful time has elapsed (
canConfigureDurations— i.e. when Finish is disabled). Edits save and re-apply to the timeline immediately (no save button).
Once the timer is running with ≥ 1 min elapsed (config editing disabled), the same button becomes Edit current durations — a live text editor for the running timeline (src/lib/durations-format.js, current-durations-editor.jsx):
- Format per line:
<Type> <elapsed>/<total> <Note>. Each time value is integer minutes (no:),h:m/h:mm(one:), orh:mm:ss(two:).elapsedis omitted when 0; on renderelapsedshowsh:mm:ssandtotalshows minutes/h:mm. SeeparseCurrentDurationsText/serializeCurrentDurations. - Opening pauses the timer; closing resumes it only if it had been running (
applyCurrentDurationsreconciles the current period's start timestamp so no time is lost). Edits apply live; external period-control/keyboard changes mirror back into the textarea via thecurrentDurationsTextsignal. - Keyboard:
Eopens the durations panel,Esccloses it (works while the textarea is focused). The Start/Pause button is disabled while the panel is open.
A session can be pinned to a wall-clock time (Schedule.pin/unpin/isAnchored/timestampAnchor in src/lib/schedule.js; pinTimer/unpinTimer/togglePinTimer/canTogglePin in timer.js):
- Configs and the live "current durations" editor both accept an optional
@h:mmfirst line — parsed byparseConfigAnchor/parseDurationsAnchor, serialized back byformatAnchorToken. Applying a config with an@header arms the anchor; a future time auto-starts at that moment (armed), a past time just sits until Start is pressed (the first period then absorbs the whole gap since the anchor, auto-extending). In the live editor the anchor takes an optional day qualifier —@h:mm(today only; a time later than now is invalid, never silently yesterday),@yesterday h:mm,@30 Dec h:mm(most recent occurrence of that date) — and the mirror serializes anchors from before today WITH their qualifier, so re-parsing the text always resolves to the same day. Invalid typed anchors leave the anchor state unchanged: a future-resolving time, an anchor newer than the past periods' typed elapsed allows (derived current elapsed would go negative), and a half-edited@line (hasAnchorLine) all keep the previous anchor — only fully deleting the anchor line unpins. A typed anchor resolving into the same minute as the current anchor keeps its exact timestamp (seconds preserved). The current period's typed elapsed is ignored while anchored — it's derived from the anchor instead (reconcileToAnchor). - Affordances: the timeline's start-time label doubles as the pin toggle (click, or
Pkey) — shows a thumbtack when pinned; a thumbtack button in the top-left controls (theme-switcher.jsx) toggles the same, disabled whencanTogglePinis false. Start times before today are qualified with "yesterday" or a short date (formatDayMarkerinformat.js), in both the timeline start label and the armed indicator.TimerControlsshows anArmedIndicatorwhen idle + anchored ("Starts at H:MM · in Xm" for a future anchor, "Start from H:MM" for a past one); the Start button reads "Start now" when armed-future. - Any user-facing pause (
pauseTimer) unpins;pauseForEditing/resumeAfterEditing(used by the live editor and the timeline period-edit form) do not. - While anchored, elapsed is clock-owned — the current period's elapsed is derived from the anchor (
reconcileToAnchor) — so manual elapsed adjustment (adjustElapsed,moveElapsedTimeToPreviousPeriod) instead transfers recorded time with the previous period (Period.amendRecordedDuration), keeping the anchor and total elapsed fixed: forward shrinks the previous period's record and grows the current period's derived elapsed; backward does the reverse, floored at 0. The transfer slides the boundary between the two periods, not the current period's end: its duration follows its elapsed by the same amount (Period.shiftDuration, applied before the elapsed refresh so a forward transfer never reads as an overrun), sostate.remaining— and every projected clock time from there to the end of the session — is unchanged.shiftDurationmovesstate.durationandconfig.userIntendedDurationby the same delta rather than collapsing them (unlikeextendDuration), so an auto-extension gap survives and the move is exactly reversible; both floor atMIN_PERIOD_MS.moveElapsedTimeToPreviousPeriod("move time to previous" /Backspace) is the exception — it passesadjustElapsed(delta, { keepDuration: true }), keeping the current period's LENGTH instead of its end, so it starts and therefore ends later, exactly as that button behaves unanchored. The previous period's record can't shrink belowMIN_PERIOD_MS(canAdjustElapsed*guards this), and there's nothing to transfer with on the first period (currentPeriodIndex === 0), so adjustment is a no-op there. Any caller computing anadjustElapseddelta must measure it againstadjustableElapsed, nevertimerDurationElapsed— while anchored the session total is nailed to the wall clock and cannot move, so a delta derived from it never converges (the reference is unaffected by the adjustment) and bleeds sub-minute time out of the previous period's record on every keypress.adjustableElapsedis the current period's elapsed while anchored, the session total otherwise — floored to a whole minute in both modes: a running reference never sits on a whole minute, andgetNextMultipleOf3Deltasnaps such a value to the boundary just below it, so without the floor the plain ←/→ keys only shave off seconds the next tick re-adds and the elapsed can never actually step (it also made anchored deltas fractional, corrupting the previous period's record). This is what the plain ←/→ snap-to-3 keys use;Enduses the current period's elapsed directly. Period boundaries behave exactly like normal mode — an overrun period auto-extends and later periods just shift; the anchor only fixes the session start and the completed periods' record. Moving elapsed backward while NOT anchored hands the auto-extension back (Period.relaxAutoExtension, viaupdateCurrentPeriod({ relax: true })):state.durationreturns toconfig.userIntendedDuration, floored at the remaining elapsed, so a forward/back round trip is lossless. Without it the extension outlived the elapsed that earned it and every projected clock time drifted later on each bounce. The anchored branch does not need it — it manages duration explicitly in both directions viashiftDuration. Any clock gap (late Start on a past anchor, device sleep/reload, time spent in the live editor) lands entirely on the current period, so an anchored session never self-finishes and no wall-clock time since the anchor is lost (the user ends it via Finish). Consequently, any past@time is valid in the live editor no matter how old, andresumeTimerreconciles when a paused session is anchored (reachable by typing@h:mmwhile paused).
Wall-clock targets independent of the session's periods (src/lib/deadline.ts) — there can be
several. Each is a 2px dashed white marker over the timeline (timeline-deadline.tsx) with a
countdown left of the line, shown at all times: 0:01 one minute before, -0:01 one minute past.
Once the clock passes a deadline its marker turns --color-error and pulses — the red light is
tied to being OVERDUE, not to the alarm, so it keeps pulsing after silencing — and a notification
chime loops until silenced via the little bell-slash button on the alarming marker's label or the
S key. The chime is randomly picked ONCE per deadline (keyed by kind/time/label in an in-memory
map — deliberately not persisted, a reload re-picks) and replays back-to-back with no gap; only a
failed play waits before retrying, so muted/locked audio doesn't busy-spin.
- Set/edited/cleared only through the durations textareas, via
+lines — every valid one counts (parseDeadlineLinesindurations-format.ts):+h:mm Labelwith NO date is a daily deadline — it recurs every day at that time;+today h:mm,+tomorrow h:mm,+yesterday h:mmand+30 Dec h:mmare absolute. The mirror serializes an absolute deadline WITH its day qualifier even for today (serializeDeadlineLines) — a bare+h:mmwould re-parse as daily, silently changing kind. Markers show the time, the day when not today, and the optional label. - Ownership differs by editor (same contract shape as the anchor): the live editor owns the
list — valid lines set exactly those, no
+line at all clears,+lines present but none valid keeps (hasDeadlineLine). A config apply only SETS when+lines are present; absence leaves the list alone, so a daily deadline survives config switches. Reset is the exception —resetTimerpassesclearDeadlines: truedown tosetPeriodsFromConfig, which forwards it asclearOnAbsence, so after a Reset the list is exactly the active config's+lines and empty when it has none. Deadlines do not survive a Reset. - Only one alarm at a time — the latest expired owns it (
deadlineAlarmTimestamp= max overdue occurrence). When a newer deadline expires while an older one still rings, a supersede effect silences the older one FOR GOOD (it must not resume even if the newer one is later deleted). Silencing is per occurrence timestamp (silencedDeadlines, persisted, pruned against current occurrences): an absolute deadline stays quiet forever, a daily one resolves to a new timestamp after midnight and alarms again. A deadline already overdue when it first appears starts silenced — typing+yesterday 17:00must not blast chimes per keystroke; the alarm is for the live crossing (and for reopening the app while un-silenced-overdue).setDeadlinesbatches the list write with that silencing so the alarm effect never sees the intermediate state. The loop (playNotificationof the owner's fixed chime) re-checksdeadlineAlarmActiveeach round, so silence/supersede/clear/mute stops it at the next clip boundary. - Spoken pre-warnings (
deadlineWarningKey+ an effect ondeadlineNow): a clip (deadline_60/deadline_12/deadline_6, chime-then-speech viaplayPeriodSound) plays when the clock LIVE-crosses 60/12/6 minutes before the nearest upcoming occurrence — fires while idle too, since it rides the module's own clock. Live crossing only: nothing back-fires on reload, and typing a deadline whose warning moments are already past stays quiet (same philosophy as the starts-silenced rule). A gap spanning several warning moments (device sleep) fires only the smallest offset. Silencing does not apply — it is an overdue-alarm concept. - Deadline state persists under its own localStorage keys (
deadlines,deadlineSilenced), NOT in thetimerStateblob — it outlives sessions. The module runs its own 1 Hz clock (deadlineNow, a plainsetInterval): the timer's worker tick only runs during a session, and a deadline must fire, and its daily resolution must roll over at midnight, while idle too. - Markers map each deadline's clock time into the session's start..end span (start =
sessionStartTimestampintimer.js: the anchor when pinned;timestampStartedminus the completed periods' recorded elapsed while running — plain stored numbers, becausenow − totalElapsedwobbles ±1s with the phase between the worker tick anddeadlineNow, which made every minute-rounded position jitter and the deadline tail blink per tick; a minute-quantizednow − totalElapsedwhen paused/idle, so the sliding projection moves in full-minute steps). A set deadline is always visible; unanchored-idle, markers slide as the derived start moves with "now". A deadline beyond the session end does NOT clamp: the timeline grid extends past the end with empty track up to the latest deadline (calculateTimelineMinutesintimeline-logic.ts, used by both the container's--total-minutesand the marker math), so the marker sits at its true clock position. Purely visual — session end, remaining and projected times stay defined by the periods alone. Only a deadline before the session start still clamps (to the left edge). - Adding a period at the session's tail fills up to the nearest deadline beyond the end instead
of the 24 min default, when that gap is larger (
fillToDeadlineDurationintimer.js): the session then ends exactly on the deadline. Applies to "add period"/A/Insertwhen on the last period (measured from "now" minus the completed period's sub-minute remainder — in the insert-before branch, i.e. < 1 min elapsed, the displaced period's fresh duration counts as already covered) and to the timeline's hover "+" after the last period (measured from the session end, where the appended period starts). Deadlines the session already covers, and gaps the default already reaches past, keep the 24 min default.
Sources are WAV files in src/assets/sounds/; ./normalize_audio.sh converts them to
public/sounds/**/*.webm (Opus, −18 LUFS) — the app only ever loads the .webm copies, via
absolute /tymer/sounds/... URLs. It takes optional file/directory arguments inside
src/assets/sounds/ and converts the whole bank when given none; sounds.py promote passes it just
the clips it copied, since re-encoding 230-odd files for a handful of new takes is minutes of ffmpeg.
Per-set filter presets. A voice can carry an effect — radio distortion, intercom band-limiting —
that the TTS cannot produce. build-tools/audio-presets.sh defines named ffmpeg filter_complex
chains (apollo, headset) and build-tools/audio-presets.conf maps <set> <preset>, one per
line (nasa apollo). normalize_audio.sh sources both and, when a file's set is mapped, runs the
chain before its loudnorm — so the effect is measured and levelled like any other take.
The set is derived from the WAV stem minus the -N take suffix, the same rule
generate-sound-manifest.js uses, so a mapping applies to every take of the set with no bookkeeping.
The effect lives only in the encoded output. The WAVs under src/assets/sounds/ are never
touched, so tweaking a preset and re-running the conversion regenerates everything from the clean
originals — the alternative, baking the effect into the sources at promote time, makes every
adjustment a full regeneration through the TTS quota. Audition a chain over staged or promoted WAVs
with build-tools/preview-audio-preset.sh <preset> <wav-or-dir> (renders through the same
preset + loudnorm into a temp dir, plays via mpv). Chains that synthesize audio (noise, tones) build
it at 24 kHz mono and condition the input to match: a sample-rate mismatch inside concat silently
mangles the synthesized segments. lint:sh runs shellcheck with -x so it follows the source.
Events, takes, and the manifest. A sound event is a directory of interchangeable takes, and one
is chosen at random per play. A browser cannot list a directory, so build-tools/generate-sound-manifest.js
scans public/sounds/ and writes src/lib/sound-manifest.js — SOUND_VARIANTS, keyed
elapsed_6, overtime_break_12, timesup_work, notification_1, button, timerFinished, whose
values are arrays of { src, set }; plus SOUND_SETS, the distinct set names found on disk.
normalize_audio.sh regenerates it at the end, so it cannot go stale; pnpm run sounds:manifest
rebuilds it alone. Both layouts resolve to the same key — flat elapsed/006.webm and
elapsed/006/<take>.webm merge into elapsed_6 — so takes can be added without moving what is
already there.
buildSoundConfig in src/lib/sounds.js holds an ARRAY of { set, howl } per key and playByKey
picks via pickVariant (src/lib/pick-variant.js), which never returns the previous index for that
key — so a repeated event does not replay the same take twice in a row. Last-index bookkeeping is a
module-level Map, deliberately not a signal (it is never rendered).
Voice sets. Each speech take's filename stem is its set — elapsed/006/brisk-1.webm belongs to
brisk, brisk-2.webm to the same set (a trailing -<N> take suffix is stripped). So the sets a
prompt file promotes are already distinguishable on disk and nothing has to move; the generator just
records the set alongside the path. This makes @name in a prompt set load-bearing — a set
without one gets text-derived filenames and each clip becomes its own pseudo-set, which the coverage
test below fails on. Files that carry no set — the flat layouts, notifications/*.ogg,
button.webm, timer-end.webm — get set: null and belong to every set.
src/lib/sound-set.js owns the selection: activeSoundSet (persisted under soundSet, default
ALL_SETS === 'all'), soundSetOptions, cycleSoundSet, and a computed soundSetLabel — no
hand-maintained name list anywhere, so a fifth voice appears in the UI with no code edit. The
switcher is the masks button in the top-left controls (sound-set-switcher.jsx, V key); it renders
every option's label stacked in one CSS grid cell with the inactive ones visibility: hidden, so the
button is permanently as wide as the longest name and does not jump as the set cycles.
pickCandidates({ variants, set }) filters at play time (Howls stay eagerly built, so switching is
instant and reloads nothing). An empty filter falls back to the full pool rather than returning
nothing: set-less keys match no set and must keep playing under every selection, and a
half-promoted set degrades to the other voices instead of going silent — silence is exactly the
failure mode described below. Because the index now points into a filtered list, lastVariantIndex
is keyed `${soundKey}|${set}`; a stale index from another set would be meaningless.
The manifest is the only source of sound paths. getVariants(key) returns
SOUND_VARIANTS[key] ?? [] (and getVariantPaths maps that to bare src strings for soundConfig,
the build-time preload export — preloading covers every set regardless of what is selected). There is
no hardcoded fallback. There used to be one, and it was a
trap rather than a safety net: the flat paths it fell back to (elapsed/006.webm,
timesup/work.webm, overtime/break/006.webm, …) all stopped existing when the bank was
restructured into take directories, so a missing key produced a Howl on a 404 that sat in
state === 'loading' forever, in silence. An empty list instead reaches playByKey's existing
not-found branch, which logs and records a failed soundPlaybackLog entry. REQUIRED_SOUND_KEYS
(derived from AVAILABLE_SOUNDS + DEADLINE_WARNING_MINUTES + the 78 notifications +
button/timerFinished/timesup_*) is
what buildSoundConfig iterates, and src/lib/sounds.test.js guards it three ways: every required
key has a manifest entry, every manifest path exists on disk, and every set in SOUND_SETS covers
every speech key. The second assertion is the one that catches a bank restructure; the third catches
a half-promoted set and a prompt file that forgot its @name.
AVAILABLE_SOUNDS in src/lib/sound-discovery.ts defines which minute marks exist per bank
(elapsed, remaining, overtime, overtimeBreak, and the break-specific elapsedBreak /
remainingBreak, both [6, 12]) and is what SoundScheduler schedules from. Break periods use
the break banks — check-in wording at 6/12 elapsed, wind-down at 12/6 remaining — with the
elapsed↔remaining threshold computed from the break bank's max (12 min, vs 24 for work), and
remaining_break_12 hard-gated to breaks of 48 min and up; work and fun periods keep the work
banks throughout. Scheduler windows carry canonical manifest keys (timesup_work,
overtime_break_12, …) that timer.ts plays directly — there is no path→key derivation anymore.
The overtime ladder ends at 48 — the old 60-minute buzz was retired.
Spoken text is generated, not recorded. The words live in sound-prompts/*.txt, one file per
set (one voice, one character, one block per event), rendered by the vendored Gemini TTS tool in
build-tools/tts/. See sound-prompts/README.md for the set format and build-tools/tts/README.md
for the workflow.
build-tools/tts/sounds.py is a subcommand CLI (pnpm run sounds <subcommand>, plus
sounds:generate / sounds:promote shortcuts). Inside the repo it is just sounds <subcommand>:
flake.nix builds a tymer-sounds derivation that direnv puts on PATH, carrying bin/sounds and
share/bash-completion/completions/sounds. That second path is load-bearing — direnv replays
environment variables only and cannot export a shell function or a complete registration, but
bash-completion's complete -D loader derives <prefix>/share/bash-completion/completions/<cmd>
from every PATH entry ending in /bin, so shipping both halves gets tab completion with no shell
config. Both halves are stubs resolving through TYMER_ROOT (exported by the shell hook) into
build-tools/tts/completions/sounds.bash, so edits need no rebuild. That file also defines a
sounds function for shells with no direnv — guarded by command -v sounds, since a function
would otherwise shadow the dev shell's command with the wrong tool directory. Clips stage under
.staging/<set>/ and reach the app only via promote:
generatefills in clips with no file yet — the resumable everyday run, since free-tier quota makes a 40-clip set a multi-day job.regenerateredoes every clip over the set's take-1;regenerate --freshdeletes the staged set first, so takes from an earlier, longer batch do not survive.--freshconfirms before deleting and only ever clears a directory under.staging/.audition(and--audition each|endduring a run) plays clips through mpv. Listening time is subtracted from the inter-request spacing, so it costs no extra wall clock.promotemerges the staged set in as extra takes by default — a second set, or a second batch of the same one, lands beside what is there rather than overwriting.promote --replacedeletes every promoted take of that set first, so the staged batch becomes the whole of it; it therefore requires a complete staging and asks before deleting. Both then convert only the copied clips, refresh the manifest, and clear staging (--skip-normalize/--keep-stagingopt out).
A deleted take must lose its .webm too. --replace removes the counterpart under
public/sounds/ for every source it deletes, because generate-sound-manifest.js scans that
directory: an orphan there is not stale, it stays in SOUND_VARIANTS and keeps playing.
Not speech: notifications/*.ogg (78 chimes, played before period announcements), button.webm,
timer-end.webm.
The notification chimes bypass normalize_audio.sh entirely. That script only walks
src/assets/sounds/**/*.wav, so the oggs are copied to public/sounds/notifications/ by hand —
adding one means writing it to BOTH trees, then pnpm run sounds:manifest. They are stock Android
ringtones (64–78 came from a per-vendor ringtone bank), which pad a short chime out to 1.5–3 s
of silence. Trim that silence with a stream copy, never a re-encode:
ffmpeg -ss <start> -i in.ogg -t <len> -c copy out.ogg cuts at Vorbis packet boundaries and the
decoded PCM stays bit-identical, so the clip survives with no generation loss; it just lands within
~10 ms of the requested point. Re-encoding a lossy source to trim leading silence is the tempting
wrong move. Bump the 78 in REQUIRED_SOUND_KEYS and pickRandomNotificationKey (src/lib/sounds.ts)
in lockstep — the count is not derived from the manifest, and the sounds test fails if it drifts.
PWA precaching uses recursive globs (sounds/**/*.webm, sounds/**/*.ogg) in vite.config.js —
single-* globs silently missed overtime/break/ and every notification.
Auditions the whole bank in one place — every event × every voice × every take — so a set can be compared against the others and a half-promoted one is visible at a glance.
- A second Vite page, not a route.
sounds/index.htmllives at the repo ROOT and is the secondbuild.rollupOptions.inputentry, so it builds todist/sounds/index.htmland GitHub Pages serves the directory URL with no SPA fallback. It lands inside the directorypublic/sounds/is copied to; the names don't collide, and the SW precaches the page like any other HTML.src/sound-preview/is its own little app:initTheme()and nothing else from the timer — no timer state, no service-worker registration, and plainnew Audiorather than Howler, since eagerly building 560 Howls to preview them is the opposite of what the page is for. soundPreloadPluginis gated to the app entry (config.root/index.html, compared as an absolute path —ctx.pathis a base-prefixed request URL in dev and a root-relative path in build, so it is not comparable across both). Ungated,transformIndexHtmlran for every entry and put all 560 preload links on the preview page, downloading the bank on open.preview-model.tsturns the flat manifest into the matrix: 8 banks × the voice columns, plus the set-less events (the 78 chimes collapse into ONE row of 78 takes, since they are one sound with many variants). Rows are guaranteed rectangular — one cell per set, empty or not — because the sticky voice header is one shared CSS grid with the bank rows and would otherwise drift out of alignment.totals.eventscounts real manifest keys (120), not display rows.- Playback state lives in
playback.tssignals. A take chip's playing state keys offplayingSrc(the take that is sounding), NOT the group that started the queue — otherwise a chip only lights up when clicked directly and stays dark for the whole of a row/voice/all run, which is exactly when the highlight is needed. - knip cannot trace the second HTML entry, so
src/sound-preview/main.tsxis listed inknip.json'sentryarray directly, the same workaroundsrc/lib/timer-worker.tsalready uses.