Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
112 changes: 0 additions & 112 deletions docs/wp-storage-plan.md

This file was deleted.

9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
<meta name="theme-color" content="#faf8f4" />
<link rel="icon" type="image/svg+xml" href="/troche-icon.svg" />
<title>troche</title>
<style>
/* Safari 26 ignores theme-color and instead tints the status bar and
toolbar from the page's own edge colors, falling back to the root
background when nothing else qualifies. Left at the UA default that
fallback is white, which shows up as hard white bars above and below
the app; the 8px UA body margin put a white gutter at those same edges.
Paper-colored and flush, so the chrome blends into the app. */
html, body { margin: 0; background: #faf8f4; }
</style>
</head>
<body>
<div id="root"></div>
Expand Down
12 changes: 8 additions & 4 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export default function App() {
const {
playing, togglePlay, stop,
metronome, setMetronome,
flash, setFlash, flashElRef,
flash, setFlash, flashElRef, flashLayerRef,
segments, totalBeats, secPerBeat,
countInActive, masterTop,
inCountIn, activePartId, partProgress,
Expand Down Expand Up @@ -579,9 +579,13 @@ export default function App() {
{/* Print-only chart — hidden on screen, revealed inside @media print. */}
<PrintChart song={activeSong} />

{/* Beat flash. Always mounted (transparent at rest) so the playback
engine can animate it the instant a beat lands. */}
<div ref={flashElRef} className="sa-flash" aria-hidden="true" />
{/* Beat flash. Always mounted so the playback engine can animate it the
instant a beat lands; the engine displays the wrapper only for the
length of each beat and drives the inner fill — see .sa-flash in
styles.js for why it's split that way. */}
<div ref={flashLayerRef} className="sa-flash" aria-hidden="true">
<div ref={flashElRef} className="sa-flash-fill" />
</div>

<div ref={stickyRef} style={styles.stickyTop}>
<Header
Expand Down
2 changes: 2 additions & 0 deletions src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function Header({
<input
className="sa-input name"
value={activeSong.name}
disabled={playing}
onChange={(e) => setField("name", e.target.value)}
/>
</MetaField>
Expand Down Expand Up @@ -163,6 +164,7 @@ export function Header({
<select
className="sa-input keysel"
value={activeSong.musicalKey || ""}
disabled={playing}
onChange={(e) => setField("musicalKey", e.target.value)}
>
<option value="">—</option>
Expand Down
13 changes: 12 additions & 1 deletion src/components/PartBlock.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export const PartBlock = React.forwardRef(function PartBlock(
else if (ref) ref.current = el;
};

// Playback collapses the editors: the settings in there can't be changed
// mid-song anyway, and the expanded panel just pushes the parts you're
// actually reading off the screen.
useEffect(() => {
if (playing) setEditing(false);
}, [playing]);

useEffect(() => {
if (autoFocusName && nameRef.current) {
nameRef.current.focus();
Expand Down Expand Up @@ -114,6 +121,7 @@ export const PartBlock = React.forwardRef(function PartBlock(
ref={nameRef}
className="sa-partname"
value={part.name}
disabled={playing}
onChange={(e) => onUpdate({ name: e.target.value })}
onFocus={(e) => {
// Defer past the mousedown→mouseup that would otherwise place a
Expand All @@ -132,6 +140,7 @@ export const PartBlock = React.forwardRef(function PartBlock(
className="sa-cue"
placeholder="cue / note…"
value={part.cue || ""}
disabled={playing}
onChange={(e) => onUpdate({ cue: e.target.value })}
/>
{part.sample && (
Expand All @@ -146,7 +155,8 @@ export const PartBlock = React.forwardRef(function PartBlock(
<button
className={`sa-config ${editing ? "open" : ""}`}
onClick={() => setEditing((v) => !v)}
title="Part settings"
disabled={playing}
title={playing ? "Stop playback to edit part settings" : "Part settings"}
>
<span className="sa-swatch" style={{ background: part.color }} />
<Settings2 size={15} />
Expand Down Expand Up @@ -208,6 +218,7 @@ export const PartBlock = React.forwardRef(function PartBlock(
className="sa-input sample"
placeholder="https://… link to mp3 / wav"
value={part.sample}
disabled={playing}
onChange={(e) => onUpdate({ sample: e.target.value })}
/>
</div>
Expand Down
19 changes: 15 additions & 4 deletions src/hooks/usePlaybackEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export function usePlaybackEngine(activeSong) {
// through React state: the beat times come off the same lookahead queue the
// clicks do, and the fade runs on the Web Animations API so the compositor
// owns it — neither the flash's start nor its decay waits on a render.
const flashElRef = useRef(null);
const flashElRef = useRef(null); // the colored fill
const flashLayerRef = useRef(null); // the fixed wrapper, shown only mid-beat
const flashQueueRef = useRef([]); // [{ t: audioCtx time, accent }] pending beats

const secPerBeat = activeSong ? 60 / activeSong.bpm : 0.5;
Expand Down Expand Up @@ -118,27 +119,36 @@ export function usePlaybackEngine(activeSong) {
src.start(when);
};

// Downbeat: a hard, flat wash of the accent colour, gone in ~70ms — short
// Downbeat: a hard, flat wash of the accent color, gone in ~70ms — short
// enough to read as a strobe hit rather than a tint sitting over the chart.
// Off-beats: a dim edge-weighted wash that never obscures anything.
const fireFlash = (accent) => {
const el = flashElRef.current;
if (!el) return;
const layer = flashLayerRef.current;
if (!el || !layer) return;
el.classList.toggle("downbeat", accent);
// The wrapper only exists in the render tree for the length of the beat —
// see .sa-flash in styles.js. Display has to be restored before animate(),
// which won't run against a display:none element.
layer.classList.add("live");
// Keep the flash inside its own beat so fast tempos don't smear into one
// another, and cancel any in-flight fade so a new beat starts clean.
const dur = Math.min(accent ? 70 : 110, secPerBeat * 1000 * 0.55);
for (const a of el.getAnimations()) a.cancel();
el.animate(
const anim = el.animate(
[{ opacity: accent ? 0.92 : 0.16 }, { opacity: 0 }],
{ duration: dur, easing: accent ? "cubic-bezier(.2,0,.6,1)" : "ease-out" }
);
// cancel() doesn't fire onfinish, so a beat that supersedes this one hands
// the hide-again duty to its own animation rather than dropping it.
anim.onfinish = () => layer.classList.remove("live");
};

const clearFlash = () => {
flashQueueRef.current = [];
const el = flashElRef.current;
if (el) for (const a of el.getAnimations()) a.cancel();
flashLayerRef.current?.classList.remove("live");
};

const stopScheduler = () => {
Expand Down Expand Up @@ -312,6 +322,7 @@ export function usePlaybackEngine(activeSong) {
flash,
setFlash,
flashElRef,
flashLayerRef,
togglePlay,
stop,
// derived song shape
Expand Down
57 changes: 51 additions & 6 deletions src/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,22 +254,40 @@ select.sa-input { padding-right: 28px; appearance: none; -webkit-appearance: non
.sa-metro:hover { border-color: var(--ink-dim); color: var(--ink); }
.sa-metro.on { color: var(--accent); border-color: var(--accent); background: rgba(216,72,59,.06); }

/* Beat flash overlay. Transparent at rest; the playback engine animates its
opacity per beat (Web Animations API, so the fade is composited off the
main thread) and swaps .downbeat on for accented beats.
/* Beat flash overlay. Transparent at rest; the playback engine animates the
inner fill's opacity per beat (Web Animations API, so the fade is composited
off the main thread) and swaps .downbeat on for accented beats.
Off-beat: dim, edge-weighted, the sheet stays readable underneath.
Downbeat: flat fill at near-full opacity — it can hide the sheet because
the engine keeps it to ~70ms. */
the engine keeps it to ~70ms.

Two things here are about Safari 26, which tints the status bar and toolbar
by sampling fixed/sticky elements near the viewport edges rather than from
theme-color. A full-viewport fixed overlay is the candidate at both edges,
so an accent background on it turns the browser chrome into orange bars for
the whole of playback, and a transparent one turns them white. Hence:
.live — the wrapper is display:none between beats, so most of the time it
isn't in the render tree to be sampled at all (opacity 0 is not enough,
Safari still reads the background off a hidden fixed element); and the
color sits on an absolutely positioned child, so even mid-beat the fixed
wrapper itself has nothing to sample and the chrome falls back to the paper
background set on html/body in index.html. */
.sa-flash {
display: none;
position: fixed; inset: 0; z-index: 100;
pointer-events: none; opacity: 0;
pointer-events: none; background: transparent;
}
.sa-flash.live { display: block; }
.sa-flash-fill {
position: absolute; inset: 0;
opacity: 0;
background: radial-gradient(
ellipse at center,
color-mix(in srgb, var(--accent) 55%, transparent) 0%,
var(--accent) 100%
);
}
.sa-flash.downbeat { background: var(--accent); }
.sa-flash-fill.downbeat { background: var(--accent); }

.sa-input.sig { width: 84px; font-family: 'Spline Sans Mono', monospace; }
.sa-input.editor-sig { width: auto; min-width: 170px; }
Expand Down Expand Up @@ -409,6 +427,13 @@ select.sa-input { padding-right: 28px; appearance: none; -webkit-appearance: non
width: 100%; padding: 2px 0; letter-spacing: -.01em;
}
.sa-partname:focus { outline: none; }
/* Locked during playback, but this is exactly when the chart has to be most
readable — so no dimming. WebKit greys disabled input text via text-fill,
which opacity/color alone don't override. */
.sa-partname:disabled, .sa-cue:disabled {
opacity: 1; color: var(--ink); -webkit-text-fill-color: var(--ink);
}
.sa-cue:disabled:hover { border-bottom-color: transparent; }
.sa-block-sub {
display: flex; align-items: center; gap: 10px;
font-size: 12px; color: var(--ink-dim);
Expand Down Expand Up @@ -439,6 +464,9 @@ select.sa-input { padding-right: 28px; appearance: none; -webkit-appearance: non
cursor: pointer; color: var(--ink-dim); transition: all .15s;
}
.sa-config:hover { border-color: var(--ink-dim); color: var(--ink); }
/* The swatch keeps its color so the part is still identifiable while locked. */
.sa-config:disabled { opacity: .5; cursor: default; }
.sa-config:disabled:hover { border-color: var(--line); color: var(--ink-dim); }
.sa-config.open { border-color: var(--clr); color: var(--ink); background: var(--bg); }
.sa-swatch { width: 16px; height: 16px; border-radius: 5px; display: block; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }

Expand Down Expand Up @@ -598,4 +626,21 @@ input[type=number]::-webkit-inner-spin-button { opacity: .4; }
}
.sa-print-sig { font-weight: 700; color: #000; margin-right: 4px; }
}

/* iOS Safari zooms the page in whenever a focused text field is smaller than
16px, and it does not zoom back out when the field blurs — you're left
panning around a magnified chart mid-song. 16px is a hard threshold, not a
design choice, so every text field gets it on touch; the desktop sizes are
untouched. Selects are exempt (they open a picker, not a keyboard) and
.sa-partname is already 16px+ at every width.

This block sits last on purpose. Each selector is element-qualified so it
outranks the class rules that set the smaller sizes — a plain .sa-cue would
only tie with the 12px rule further up, and .sa-input.sample's two classes
outrank a bare input.sa-input wherever it sits. */
@media (pointer: coarse) {
input.sa-input,
input.sa-input.sample,
input.sa-cue { font-size: 16px; }
}
`;