From 025b83d5de5ae3b692b40c6cbd7452ce476a2ce1 Mon Sep 17 00:00:00 2001
From: Brian Alexander <824344+ironprogrammer@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:27:44 -0700
Subject: [PATCH 1/2] Remove the outdated WP storage planning doc
It described the plugin's local test loop as wp-now, which was superseded by
the WordPress Playground CLI before the feature shipped. The README's "Test
locally" section is the current instructions, so the plan file was only left
to contradict them.
---
docs/wp-storage-plan.md | 112 ----------------------------------------
1 file changed, 112 deletions(-)
delete mode 100644 docs/wp-storage-plan.md
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.
From 96aa3eeee7690d0ed11771a7f3a4c9b332b10d86 Mon Sep 17 00:00:00 2001
From: Brian Alexander <824344+ironprogrammer@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:27:55 -0700
Subject: [PATCH 2/2] Lock the chart during playback and fix two iOS Safari
issues
Three fixes that all showed up while running a song on a phone.
Nothing is editable during playback any more. The song name and key were
still live, as were a part's name, cue and sample URL; the rest of the meta
was already gated on `playing`. Open part editors also collapse when playback
starts and the settings button is disabled, so the expanded panel can't push
the parts you're reading off the screen mid-song. Disabled inputs keep their
full ink color -- playback is exactly when the chart has to be readable, and
WebKit greys disabled text through -webkit-text-fill-color, which color and
opacity alone don't override.
The beat flash no longer paints the browser chrome. Safari 26 ignores
theme-color and instead tints the status bar and toolbar by sampling
fixed/sticky elements near the viewport edges, so the full-viewport flash
overlay became the candidate at both edges: orange bars top and bottom for
the whole of playback, or white ones when it sampled the gradient's
transparent background-color. The overlay is now display:none between beats
rather than opacity 0 -- Safari reads the background off a hidden fixed
element -- and the color moved to an absolutely positioned child so the fixed
wrapper has nothing to sample even mid-beat. html/body get the app's paper
background so the fallback tint isn't white, which also drops the 8px UA body
margin that was showing as a white gutter at those same edges.
Text fields are 16px on touch. Below that iOS zooms the page in on focus and
doesn't zoom back out on blur, leaving you panning around a magnified chart.
---
index.html | 9 ++++++
src/App.jsx | 12 ++++---
src/components/Header.jsx | 2 ++
src/components/PartBlock.jsx | 13 +++++++-
src/hooks/usePlaybackEngine.js | 19 +++++++++---
src/styles.js | 57 ++++++++++++++++++++++++++++++----
6 files changed, 97 insertions(+), 15 deletions(-)
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; }
+}
`;