diff --git a/docs/wp-storage-plan.md b/docs/wp-storage-plan.md
deleted file mode 100644
index c42697b..0000000
--- a/docs/wp-storage-plan.md
+++ /dev/null
@@ -1,112 +0,0 @@
-# Plan: WordPress storage for Troche song forms
-
-**Status:** planned, not started (July 2026). Intentionally uncommitted.
-
-## Goal
-
-Let bandmates save/load song forms through a WP site the band already runs
-instead of passing `*.troche.json` files around. Songs stay off the public web (band IP).
-The existing Troche UI is the product and is kept as-is — WP provides storage
-and auth, nothing more. Scope is exactly what's in this doc.
-
-## Architecture
-
-One codebase, one build, two homes:
-
-- **Standalone (GitHub Pages)** — unchanged. localStorage-only, never talks
- to WP, no band data on the public instance.
-- **WP plugin** — serves the same built app from the WP site, adding
- server-side persistence and login gating.
-
-Persistence sits behind a small adapter. The localStorage adapter is always
-active (working copy / offline buffer). The WP adapter activates when the
-plugin's HTML shell prints a `window.trocheWP` config global (REST URL, nonce,
-user caps) before the bundle loads; no global → app behaves exactly as today.
-
-Vite builds with `base: './'` so the same `dist/` serves from the GH Pages
-subpath and the plugin directory.
-
-**Repo:** monorepo — plugin PHP in a `wp-plugin/` dir here, consuming the same
-`dist/`.
-
-## Stage 1: plugin core — `song` CPT + REST
-
-- Non-public CPT: `public => false`, `exclude_from_search => true`,
- `publicly_queryable => false`.
-- One post per song; the JSON envelope (`{ format: "troche", version: 1, ... }`)
- stored in `post_content`, so WP revisions give save history for free,
- restorable from wp-admin.
-- Cap revisions per song (`wp_revisions_to_keep`, ~50).
-- REST: read endpoint returns the whole library in the envelope format; write
- endpoint saves per-song. Post IDs are opaque save handles; songs get no
- slugs or URLs.
-- Read requires `is_user_logged_in()`; write requires
- `current_user_can('troche_edit')`.
-- Song deletion goes to WP trash (30-day undo), never hard delete.
-
-## Stage 2: the app route
-
-- Plugin registers a rewrite rule for a slug configured on Settings → Troche
- (text field, default `troche`); flush on activation and slug change.
-- On that route: logged-out visitors get `auth_redirect()` (bounce to
- wp-login, return after sign-in); logged-in users get a minimal
- self-contained HTML shell — `window.trocheWP` config plus the `dist/`
- script/style tags. The theme is never involved: no wrapper, no theme
- CSS/JS bleed, renders identically to GH Pages.
-- Cookie auth + nonces; no CORS.
-
-## Permissions
-
-- Band members are plain Subscribers granted a custom `troche_edit`
- capability.
-- **Settings → Troche** holds the two admin controls: the route slug and a
- user checklist toggling `troche_edit` per user.
-- Activation adds `troche_edit` to the Administrator role so the installer
- always has access. No user-specific grants ship in code (plugin is open
- source).
-- Session expiry: on 401/403 during save, keep changes in localStorage and
- show "session expired — log in again." Never fail silently.
-
-## App changes (WP mode)
-
-- **Autosave replaces the Save button:** ~10s debounce, fires only when dirty,
- flush on `visibilitychange`/`pagehide`. Status indicator: "Saved ✓ /
- Saving… / Offline — changes kept locally."
-- **Conflicts: last write wins**; revisions are the safety net. Dirty-only
- saving means idle open tabs never clobber.
-- **Share/Export/Import stay functionally untouched** in both modes (one
- codebase; they're client-side and cost nothing). Export/Import double as the
- seeding path: each bandmate exports their existing localStorage library and
- imports into WP on first login. No migration code needed.
-- **WP mode on mobile viewports: don't render the Share/Export/Import button
- row** — it costs a full row of scarce mobile space, and with autosave those
- buttons aren't needed on a phone. Conditional render (not CSS hide); they
- remain available on desktop and everywhere in standalone mode.
-
-## Release & install
-
-GH Actions on tag: `vite build`, assemble `troche-wp.zip` (plugin PHP +
-`dist/`), attach to the GitHub release. Install/update = upload the zip in
-wp-admin.
-
-**Open-source ready is an acceptance criterion:** nothing site-specific or
-personal in code, defaults, or docs — any WP site can install the zip as-is.
-
-## README deliverables
-
-- **Standalone dev/test:** existing `npm install` / `npm run dev` section
- stands; note that without `window.trocheWP` the app runs localStorage-only,
- so today's workflow is unchanged.
-- **Plugin local testing:** `npx @wp-now/wp-now start` from `wp-plugin/`
- (auto-detects plugin mode, WP + SQLite, no Docker, auto-admin login). Test
- loop: `npm run build`, start wp-now, activate the plugin, visit `/troche`,
- create a Subscriber test user, grant `troche_edit` on Settings → Troche,
- verify both gates (login-to-view, cap-to-edit). `wp-env` is the Docker-based
- alternative for closer-to-prod testing.
-- **Release/install:** how the release zip is produced and installed.
-
-## Watch-item
-
-If demo audio ever moves into the WP media library, files sit at guessable
-public URLs regardless of post status — fine while per-part sample links stay
-on Dropbox/Drive; needs a protected-uploads plan first.
diff --git a/index.html b/index.html
index 716e418..980a2b2 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,15 @@
troche
+
diff --git a/src/App.jsx b/src/App.jsx
index 89230f0..c11df27 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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,
@@ -579,9 +579,13 @@ export default function App() {
{/* Print-only chart — hidden on screen, revealed inside @media print. */}
- {/* Beat flash. Always mounted (transparent at rest) so the playback
- engine can animate it the instant a beat lands. */}
-
+ {/* 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. */}
+
+
+
setField("name", e.target.value)}
/>
@@ -163,6 +164,7 @@ export function Header({
diff --git a/src/hooks/usePlaybackEngine.js b/src/hooks/usePlaybackEngine.js
index b5ddefd..6e2b364 100644
--- a/src/hooks/usePlaybackEngine.js
+++ b/src/hooks/usePlaybackEngine.js
@@ -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;
@@ -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 = () => {
@@ -312,6 +322,7 @@ export function usePlaybackEngine(activeSong) {
flash,
setFlash,
flashElRef,
+ flashLayerRef,
togglePlay,
stop,
// derived song shape
diff --git a/src/styles.js b/src/styles.js
index 7bf10b6..b8145d5 100644
--- a/src/styles.js
+++ b/src/styles.js
@@ -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; }
@@ -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);
@@ -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); }
@@ -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; }
+}
`;