feedBack/static/v3/dashboard.js
ChrisBeWithYou 727b8c8f24
feat(a11y): app-wide "Interface size" setting (Accessibility) (#664)
* feat(a11y): app-wide "Interface size" setting (Accessibility)

Adds a dedicated Accessibility -> Interface size control so users on large,
low-DPI displays can enlarge the app's menus, buttons and text (reported: eye
strain on a 32" 1440p panel with no OS scaling).

Mechanism: a host-owned scale capability (window.feedBack.scale) applies a
RELATIVE root font-size (a % of the user-agent base, never a px literal, so a
raised browser/OS base font is respected) and publishes an always-present
--fb-scale token. The rem-based v3 chrome scales together; the gameplay highway
canvas (device-pixel sized) is deliberately untouched, so playback resolution
and FPS are unchanged. Medium (100%) clears the override, so default rendering
is byte-identical to before -- zero blast radius.

- Settings -> new Accessibility tab: Small/Medium/Large/Extra-Large presets
  (0.90/1.00/1.15/1.30) + a fine-tune slider (to 150%).
- Applied pre-paint from an inline <head> script (mirrors the ss-follower
  pattern) so there is no flash-of-reflow on load.
- window.feedBack.scale read-API (get/set + scale:changed, fires once on load)
  so canvas/WebGL surfaces that cannot inherit rem can follow the size. Shape
  mirrors the working-tuning read-API; persists as a durable preference.
- Cosmetic px->rem sweep so text scales cleanly at the larger stops (2 v3.css
  font-sizes + 20 text-[Npx] utilities across 8 v3 files; tailwind.min.css
  rebuilt byte-stable via the pinned toolchain).
- One-time first-run nudge for the large/low-DPI display profile that deep-links
  to the control (never fires once the setting is touched, or on other displays).

v3-only. Verified headless: core apply/persist/reset/reload/UI-sync + nudge
gating, with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): regenerate tailwind.min.css to satisfy tailwind-fresh CI (PR #664 review)

Regenerate static/tailwind.min.css via scripts/build-tailwind.sh
(tailwindcss@3.4.19) so a fresh build matches the committed artifact and
the tailwind-fresh CI job's `git diff --quiet` passes. Two consecutive
regenerations are byte-identical.

Also (Fix 2) switch the fine-tune interface-size slider to the documented
transient-preview path: oninput now calls scale.set(v, { persist:false })
so dragging previews without writing localStorage/emitting a commit each
tick, and a new onchange commits with persistence on release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 14:00:30 +02:00

277 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* fee[dB]ack v0.3.0 — Dashboard / Home (#v3-home).
*
* Composes data from the backends built in other prompts: profile (15),
* song-stats/recent (14), continue (16), library stats + plugins. Each widget
* fetches + renders independently and DEGRADES GRACEFULLY — a missing/empty
* endpoint shows a placeholder, never blocks first paint (design/05 §1).
* Vanilla JS, fb-* tokens (constitution P-II).
*/
(function () {
'use strict';
const sm = window.feedBack;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const jget = async (u) => { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } };
function libArtUrl(song) {
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
return '/api/song/' + enc(song.filename) + '/art' + v;
}
// A random library song (for the "Pick a song" card when nothing's been played).
async function randomSong() {
const stats = await jget('/api/library/stats');
const total = (stats && (stats.total_songs ?? stats.total)) || 0;
if (!total) return null;
const idx = Math.floor(Math.random() * total);
const data = await jget('/api/library?size=1&page=' + idx);
return data && data.songs && data.songs[0] ? data.songs[0] : null;
}
// Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 5089% mid, <50% low.
function accuracyBadge(acc) {
if (acc == null) return '';
const pct = Math.round(acc * 100);
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text +
' px-2 py-1 rounded-tl-md text-xs font-bold flex items-center gap-1">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' +
pct + '%</span>';
}
function songArt(url, extra) {
return '<img src="' + esc(url) + '" alt="" class="' + (extra || '') +
'" onerror="this.style.visibility=\'hidden\'">';
}
function tuningChip(name, cls) {
if (!name) return '';
return '<span class="' + (cls || '') + ' bg-fb-mid text-black text-xs font-bold px-2 py-1 rounded-sm">' + esc(name) + '</span>';
}
// Source format of a song — prefer the server's `format`, fall back to the
// filename extension. '' = unknown.
function fmtName(song) {
let f = ((song && song.format) || '').toLowerCase();
if (!f) {
const fn = ((song && song.filename) || '').toLowerCase();
f = (fn.endsWith('.feedpak') || fn.endsWith('.sloppak')) ? 'sloppak' : '';
}
return f === 'sloppak' ? 'FEEDPAK' : f === 'loose' ? 'FOLDER' : '';
}
// Corner badge for art-thumbnail cards (sloppak accented, others muted).
function fmtBadge(song) {
const l = fmtName(song);
if (!l) return '';
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
}
// Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid
// and a corner badge would collide — sits next to the card's label instead.
function fmtTag(song) {
const l = fmtName(song);
if (!l) return '';
const c = l === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
return '<span class="' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
}
// ── Continue-Playing resume ──────────────────────────────────────────---
function resume(filename, lastPosition, arrangement) {
if (typeof window.playSong !== 'function') return;
// playSong expects an encoded filename (it decodeURIComponent()s it for
// the highway WS) and takes the arrangement index as its 2nd arg — pass
// both so resume reopens the exact arrangement the user left, not the
// default. It has no start-time arg, so play then best-effort seek once
// the session is up (constitution: highway WS).
Promise.resolve(window.playSong(enc(filename), arrangement)).then(() => {
if (Number.isFinite(lastPosition) && lastPosition > 0 && sm && typeof sm.seek === 'function') {
setTimeout(() => { try { sm.seek(lastPosition, 'continue-playing'); } catch (e) { /* */ } }, 800);
}
});
}
// ── Render ───────────────────────────────────────────────────────────---
async function render() {
const root = document.getElementById('v3-home');
if (!root) return;
// Kick off independent fetches.
const [profile, version, cont, libStats, plugins, recent] = await Promise.all([
jget('/api/profile'), jget('/api/version'), jget('/api/session/continue'),
jget('/api/library/stats'), jget('/api/plugins'), jget('/api/stats/recent?limit=6'),
]);
const name = (profile && profile.display_name) || 'there';
const ver = (version && version.version) || '';
const changelogUrl = ((version && version.source_url) || 'https://github.com/got-feedback/feedBack') + '/blob/main/CHANGELOG.md';
const songCount = (libStats && (libStats.total_songs ?? libStats.total)) || 0;
const pluginCount = Array.isArray(plugins)
? plugins.filter((p) => (p && (p.status || 'ready') === 'ready')).length : 0;
// "Jump back in": scored recents, else fall back to recently-added songs
// so the section is never empty on a fresh profile.
let recentList = Array.isArray(recent) ? recent : [];
if (!recentList.length) {
const lib = await jget('/api/library?sort=recent&size=6&page=0');
recentList = ((lib && lib.songs) || []).map((s) => ({
filename: s.filename, title: s.title, artist: s.artist,
art_url: libArtUrl(s), best_accuracy: null,
}));
}
// When nothing's been played, the Continue card becomes a random
// pick-a-song that plays on click.
const pick = (cont && cont.filename) ? null : await randomSong();
// Continue card.
let continueCard;
if (cont && cont.filename) {
const dur = cont.duration || 0;
const segs = 4;
const filled = dur > 0 ? Math.round((cont.last_position / dur) * segs) : 0;
const bars = Array.from({ length: segs }, (_, i) =>
'<span class="flex-1 h-1.5 rounded-full ' + (i < filled ? 'bg-fb-primary' : 'bg-gray-500/40') + '"></span>').join('');
continueCard =
'<button id="v3-continue" data-tour="continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(cont.art_url, 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(cont.tuning_name, 'absolute top-3 right-3') +
'<div class="relative p-4">' +
'<div class="flex items-center justify-between gap-2 mb-1"><span class="text-xs uppercase tracking-wider text-fb-textDim">Continue Playing</span>' + fmtTag(cont) + '</div>' +
'<div class="text-fb-text font-bold truncate">' + esc(cont.title) + '</div>' +
'<div class="text-sm text-fb-textDim truncate mb-3">' + esc(cont.artist) + '</div>' +
'<div class="flex gap-1">' + bars + '</div></div>' +
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else if (pick) {
continueCard =
'<button id="v3-pick" data-tour="continue" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(libArtUrl(pick), 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(pick.tuning_name, 'absolute top-3 right-3') +
'<div class="relative p-4">' +
'<div class="flex items-center justify-between gap-2 mb-1"><span class="text-xs uppercase tracking-wider text-fb-textDim">Pick a song</span>' + fmtTag(pick) + '</div>' +
'<div class="text-fb-text font-bold truncate">' + esc(pick.title) + '</div>' +
'<div class="text-sm text-fb-textDim truncate">' + esc(pick.artist) + '</div></div>' +
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else {
continueCard =
'<div data-tour="continue" class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
'<div class="text-fb-textDim text-sm mb-3">Pick a song to get started</div>' +
'<button id="v3-continue-pick" class="bg-fb-card hover:bg-fb-card/70 border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">Browse library</button></div>';
}
// Recently played.
let recentSection;
if (recentList.length) {
const cards = recentList.map((r) =>
'<button data-recent="' + esc(r.filename) + '" data-arr="' + esc(r.arrangement != null ? r.arrangement : '') + '" class="group text-left">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card">' +
songArt(r.art_url, 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105') +
accuracyBadge(r.best_accuracy) + fmtBadge(r) + '</div>' +
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div></button>').join('');
recentSection =
'<section class="mt-10"><h3 class="text-2xl font-bold text-fb-text mb-4">Jump back in!</h3>' +
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">' + cards + '</div></section>';
} else {
recentSection =
'<section class="mt-10"><h3 class="text-2xl font-bold text-fb-text mb-4">Jump back in!</h3>' +
'<p class="text-fb-textDim text-sm">Play a song to see it here.</p></section>';
}
root.innerHTML =
'<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
// (Title "Welcome back, {name}!" lives in the topbar header row.)
(ver ? '<p class="text-sm text-fb-textDim">Have you read the latest changes in the ' +
'<a href="' + esc(changelogUrl) + '" target="_blank" rel="noopener" class="text-fb-primary hover:text-fb-primaryHi">Patch Notes for ' + esc(ver) + '</a>?</p>' : '') +
// Featured grid: hero + continue
'<div class="grid lg:grid-cols-3 gap-6 mt-6">' +
'<div id="v3-hero" class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
// Hero artwork (neon note-highway), right-anchored. Placeholder
// cropped from the design mock — swap static/v3/brand/hero.png for
// the designer's high-res original (same path) when available.
'<img src="/static/v3/brand/hero.png" alt="" aria-hidden="true" ' +
'class="absolute inset-y-0 right-0 h-full w-2/3 object-cover object-right" ' +
'onerror="this.style.display=\'none\'">' +
// 135° gradient overlay: solid navy on the left for text legibility,
// fading to transparent so the artwork shows through on the right.
'<div class="absolute inset-0" style="background-image:linear-gradient(135deg,#0f172a 0%,#0f172a 40%,rgba(15,23,42,0.55) 65%,rgba(15,23,42,0.15) 100%);"></div>' +
'<div class="relative p-8 max-w-md">' +
'<h3 class="text-4xl font-bold leading-tight text-fb-text">Turn any song into practice.</h3>' +
'<p class="text-fb-textDim mt-2">Play along to your library, track your accuracy, and rank up.</p>' +
'<div class="flex gap-3 mt-5">' +
'<button id="v3-start" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-6 py-2 rounded-md font-medium shadow-lg shadow-fb-primary/20">Start Playing</button>' +
'<button id="v3-lessons-btn" class="bg-transparent border border-fb-textDim hover:border-white text-white px-6 py-2 rounded-md font-medium">Lessons</button>' +
'</div></div></div>' +
continueCard +
'</div>' +
// Stats row
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
audioRoutingCard() +
statCard(String(songCount), 'songs', 'text-fb-gold') +
statCard(String(pluginCount), 'active', 'text-fb-good') +
'</div>' +
recentSection +
'</div>';
// Wire interactions.
const startBtn = root.querySelector('#v3-start');
if (startBtn) startBtn.addEventListener('click', () => {
if (cont && cont.filename) resume(cont.filename, cont.last_position, cont.arrangement);
else if (pick) window.playSong && window.playSong(enc(pick.filename));
else window.showScreen && window.showScreen('v3-songs');
});
root.querySelector('#v3-continue')?.addEventListener('click', () => resume(cont.filename, cont.last_position, cont.arrangement));
root.querySelector('#v3-pick')?.addEventListener('click', () => window.playSong && window.playSong(enc(pick.filename)));
root.querySelector('#v3-continue-pick')?.addEventListener('click', () => window.showScreen && window.showScreen('v3-songs'));
root.querySelector('#v3-lessons-btn')?.addEventListener('click', () => window.showScreen && window.showScreen('v3-lessons'));
root.querySelectorAll('[data-recent]').forEach((b) =>
b.addEventListener('click', () => {
if (!window.playSong) return;
// /api/stats/recent rows are arrangement-specific — reopen the
// arrangement that was actually played, not the default.
const arr = b.getAttribute('data-arr');
window.playSong(enc(b.getAttribute('data-recent')), arr === '' || arr == null ? undefined : Number(arr));
}));
// Let prompt 18 enhance the audio-routing card once it exists.
if (window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') {
try { window.v3AudioRouting.render(document.getElementById('v3-audio-routing')); } catch (e) { /* */ }
}
// Signal that #v3-home has been (re)built, so the first-run onboarding
// tour can wait for the real cards instead of attaching to nodes from a
// prior render that this innerHTML swap just replaced.
try { document.dispatchEvent(new CustomEvent('v3:dashboard-rendered')); } catch (e) { /* older runtimes */ }
}
function statCard(value, unit, unitColor) {
return '<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 flex flex-col justify-center">' +
'<div class="text-2xl font-bold text-fb-text">' + esc(value) +
' <span class="text-sm font-medium ' + unitColor + '">' + esc(unit) + '</span></div></div>';
}
// Audio-routing widget placeholder (prompt 18 replaces #v3-audio-routing's
// body via window.v3AudioRouting). Until then: "Not Connected".
function audioRoutingCard() {
return '<div id="v3-audio-routing" class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50">' +
'<div class="flex items-center justify-between text-xs text-fb-textDim mb-2"><span>Audio Routing</span></div>' +
'<div class="flex items-center gap-2 text-xs text-fb-textDim">' +
'<span>Input</span><span class="flex-1 border-t border-dashed border-fb-border"></span>' +
'<span class="w-2 h-2 rounded-full bg-gray-500"></span>' +
'<span>VST/NAM/IR</span><span class="flex-1 border-t border-dashed border-fb-border"></span>' +
'<span class="w-2 h-2 rounded-full bg-gray-500"></span><span>Output</span></div>' +
'<div class="mt-2 text-sm font-medium text-fb-textDim">Not Connected</div></div>';
}
window.v3Dashboard = { render: render };
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-home') render(); });
sm.on('v3:profile-updated', () => render());
}
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();