Compare commits

..
Author SHA1 Message Date
topkoaandClaude Opus 4.8 38297937b7 fix: "practise" -> "practice" (American spelling, matches the rest of the UI)
Three spots used British "practise" while the rest of the app uses
American "practice" (Keep practicing, Virtuoso - Practice, etc.):
- static/v3/profile.js — first-run onboarding "Feats of Power" step
- plugins/achievements/feats.json — The Witching Hour feat description
- plugins/achievements/engine.py — code comment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-06-29 15:18:05 -04:00
12 changed files with 5 additions and 130 deletions
-1
View File
@@ -47,7 +47,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where``query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller, enabling an honest "Now listening to: <device>" readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
+1 -1
View File
@@ -109,7 +109,7 @@ def apply_activity(counters, delta):
def consecutive_run_length(dates):
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
Used by the `secret_witching` Feat (practise in the 25am window on N
Used by the `secret_witching` Feat (practice in the 25am window on N
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
feeds it the distinct night-dates recorded in `comp_ledger`.
"""
+1 -1
View File
@@ -76,7 +76,7 @@
{
"id": "secret_witching",
"title": "The Witching Hour",
"description": "Practise in the dead of night, seven nights running.",
"description": "Practice in the dead of night, seven nights running.",
"category": "global",
"sourceId": "achievements",
"secret": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.2",
"version": "1.3.1",
"bundled": true,
"private": false,
"script": "screen.js",
@@ -18,8 +18,6 @@
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_LABEL_H = 12; // px height of each drum label
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
var _TUNER_IN_TUNE_THRESHOLD = 2;
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
@@ -323,34 +321,9 @@
currentAngle += (targetAngle - currentAngle) * lf;
_setNeedle(currentAngle);
// Stop once the needle has settled on its target — a static needle
// needs no repaint. update() re-kicks the loop when a new reading
// moves the target, so this idles the always-on tuner (no signal /
// steady pitch) instead of pinning a core at 60 fps forever.
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
currentDrumY = targetDrumY; currentAngle = targetAngle;
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
_setNeedle(currentAngle);
rafId = null;
return;
}
rafId = requestAnimationFrame(_animate);
}
// Restart the loop only when there's actually something to animate toward
// (a new target). Reset lastTime so the first frame after an idle gap
// doesn't take one big easing step.
function _kick() {
if (rafId === null
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
lastTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────
@@ -387,7 +360,6 @@
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
_kick(); // animate back to rest, then the loop self-stops
return;
}
@@ -404,7 +376,6 @@
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
}
_kick(); // a new reading moved the target → run until it settles
}
function destroy() {
-13
View File
@@ -620,20 +620,8 @@
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
_applyTickStates();
// No signal and both the glow and strobe drift have fully settled →
// idle the loop. update() re-kicks it on the next note.
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
_mt3RafId = null;
_mt3LastTime = null;
return;
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
// ── MODE button ───────────────────────────────────────────────
@@ -689,7 +677,6 @@
_renderNote(' ');
_applyAccidental();
}
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
@@ -354,19 +354,10 @@
if (_smoothedCents > 0) { speed = -speed; }
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
} else if (_currentCents === 0) {
// Fully decelerated and no live signal → idle the loop instead of
// rescheduling forever. update() re-kicks it on the next note.
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
}
_rafId = requestAnimationFrame(_animateStrobe);
// ── Helper: derive octave number from frequency ───────────────
@@ -448,7 +439,6 @@
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
_currentCents = hasNote ? cents : 0;
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
-12
View File
@@ -142,20 +142,9 @@ window._tunerViz_strobe = function (container) {
strobeEl.style.opacity = '0';
}
// Idle the loop when there's no live signal — the strobe only needs to
// paint while a note is sounding. update() re-kicks it on the next note,
// so a silent tuner stops repainting instead of spinning at 60 fps.
if (!strobeActive) { rafId = null; return; }
rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (rafId === null) {
lastAnimateTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────────
@@ -199,7 +188,6 @@ window._tunerViz_strobe = function (container) {
const inTune = Math.abs(cents) < 5;
strobeEl.style.opacity = inTune ? '1' : '0.6';
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
_kick();
}
function destroy() {
@@ -122,26 +122,6 @@
plungerEl.style.left = _leftPct.toFixed(2) + '%';
plungerEl.style.top = _topPct.toFixed(2) + '%';
// No live signal and the plunger has eased back to its resting centre
// → idle the loop. update() re-kicks it on the next note.
if (_currentNote === null && !_plungerDipped
&& Math.abs(targetLeft - _leftPct) < 0.05) {
_leftPct = targetLeft;
plungerEl.style.left = _leftPct.toFixed(2) + '%';
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (_rafId !== null) return;
// Already parked at rest with no signal → nothing to animate, stay idle.
if (_currentNote === null && !_plungerDipped
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
_lastTime = null;
_rafId = requestAnimationFrame(_animate);
}
@@ -150,7 +130,6 @@
_currentNote = note;
_currentCents = note === null ? 0 : cents;
if (!_plungerDipped) { noteEl.textContent = note || ''; }
_kick(); // a new reading may move the plunger → ensure the loop runs
}
function destroy() {
+1 -11
View File
@@ -1766,16 +1766,6 @@
const providerResult = _providerOutcome(raw);
openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome);
openSession.reason = providerResult.reason;
// Read-back: the provider (desktop renderer) reports which device the
// native engine ACTUALLY bound. Surface it to the in-process caller so
// the wizard can show "Now listening to: <device>" and catch a silent
// mismatch (picked BlackHole, got the internal mic). Kept OUT of the
// redacted summary/event below: that flows into diagnostics, where a raw
// device name (e.g. "Byron's AirPods") is PII — but it is fine to return
// verbatim to the trusted same-renderer caller, exactly as list-sources
// already returns the device `label` verbatim.
const boundInfo = _plainObject(providerResult.payload);
const bound = { type: _string(boundInfo.boundType, ''), name: _string(boundInfo.boundName, '') };
if (providerResult.outcome === 'handled') {
openSession.openedAt = _now();
currentSession.openInputSessions.set(key, openSession);
@@ -1783,7 +1773,7 @@
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' });
capabilities.emitEvent('audio-input', 'source-opened', summary);
_touch();
return _handled((bound.name || bound.type) ? { ...summary, bound } : summary);
return _handled(summary);
}
const summary = _redactedOpenSession(openSession, _newPseudonymizer());
_recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason });
+1 -1
View File
@@ -375,7 +375,7 @@
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
'<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
'<p class="text-sm text-fb-textDim mb-3">As you practise youll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
'<p class="text-sm text-fb-textDim mb-3">As you practice youll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
-29
View File
@@ -281,35 +281,6 @@ test('open-source and close-source record outcomes events and no live handles',
assert.equal(encoded.includes('token=abc'), false);
});
test('open-source returns the actually-bound device for read-back without leaking it into diagnostics', async () => {
const window = loadAudioSession();
const api = window.feedBack.capabilities;
const openedEvents = captureEvents(window, 'audio-input:source-opened');
await registerSource(api, {
sourceId: 'readback-source',
logicalSourceKey: 'test:readback',
operationHandlers: {
'source.open': () => ({ outcome: 'handled', status: 'open', payload: { boundType: 'CoreAudio', boundName: 'BlackHole 16ch', requestedName: 'BlackHole 16ch' } }),
'source.close': () => ({ outcome: 'handled', status: 'closed' }),
},
});
await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:readback' } });
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } });
// The trusted in-process caller (input_setup's confirmation gate) gets the real bound device,
// so it can show "Now listening to: <device>" and catch a silent wrong-mic substitution.
assert.equal(open.outcome, 'handled');
assert.equal(open.payload.bound.type, 'CoreAudio');
assert.equal(open.payload.bound.name, 'BlackHole 16ch');
// ...but a raw device name is PII: it must NOT reach the emitted event or the diagnostics snapshot.
assert.equal(openedEvents.length, 1);
assert.equal('bound' in openedEvents[0], false);
const encoded = JSON.stringify(window.feedBack.audioSession.snapshot());
assert.equal(encoded.includes('BlackHole'), false);
});
test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => {
const window = loadAudioSession();
const api = window.feedBack.capabilities;