mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
15fabb62aa
commit
727b8c8f24
@@ -0,0 +1,119 @@
|
||||
// Core interface-scale capability — the app-wide "Interface size" preference.
|
||||
//
|
||||
// Owns a single user setting: a multiplier applied to the ROOT font-size, so
|
||||
// the rem-based v3 chrome (menus, buttons, text, spacing) scales together. This
|
||||
// is the DOM lever — it deliberately does NOT touch the gameplay highway canvas
|
||||
// (which is sized in device pixels, not rem), so scaling the UI never changes
|
||||
// playback resolution or FPS.
|
||||
//
|
||||
// It is exposed as a host read/write API on `window.feedBack.scale` so surfaces
|
||||
// that can't inherit `rem` — canvas / WebGL renderers such as the note-highway
|
||||
// HUD or results scorecards — can read the number via `feedBack.scale.get()`
|
||||
// and follow `scale:changed`. Shape mirrors the other host capabilities (a
|
||||
// frozen, versioned object) and the working-tuning read-API: synchronous
|
||||
// `get()`, a `set()` mutator, and a change event that also fires once on load.
|
||||
//
|
||||
// The visual apply ALSO runs pre-paint from a tiny inline <head> script (see
|
||||
// index.html) so there is no flash-of-reflow on load; this module is the
|
||||
// authoritative owner and re-applies idempotently.
|
||||
(function () {
|
||||
'use strict';
|
||||
window.feedBack = window.feedBack || {};
|
||||
if (window.feedBack.scale && window.feedBack.scale.version === 1) return;
|
||||
|
||||
var STORE_KEY = 'v3-interface-scale';
|
||||
var MIN = 0.85, MAX = 1.50, DEFAULT = 1.0;
|
||||
// The named presets rendered by the Settings segmented control. Kept here so
|
||||
// the control and any consumer read the ladder from one source of truth.
|
||||
var PRESETS = [
|
||||
{ step: 'small', value: 0.90 },
|
||||
{ step: 'medium', value: 1.00 },
|
||||
{ step: 'large', value: 1.15 },
|
||||
{ step: 'x-large', value: 1.30 },
|
||||
];
|
||||
|
||||
function clamp(n) {
|
||||
n = Number(n);
|
||||
if (!isFinite(n)) return DEFAULT;
|
||||
return Math.min(MAX, Math.max(MIN, n));
|
||||
}
|
||||
|
||||
function stepFor(value) {
|
||||
for (var i = 0; i < PRESETS.length; i++) {
|
||||
if (Math.abs(PRESETS[i].value - value) < 0.001) return PRESETS[i].step;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORE_KEY);
|
||||
if (raw == null) return DEFAULT;
|
||||
return clamp(parseFloat(raw));
|
||||
} catch (_) { return DEFAULT; }
|
||||
}
|
||||
|
||||
var current = read();
|
||||
|
||||
// Apply to the DOM. The lever is a RELATIVE root font-size (a percentage of
|
||||
// the user-agent base), never a px literal — so a user who raised their
|
||||
// browser/OS base font size is respected, not silently overridden. We also
|
||||
// publish the always-present `--fb-scale` token for canvas consumers and CSS.
|
||||
function apply(value) {
|
||||
var el = document.documentElement;
|
||||
if (!el) return;
|
||||
el.style.setProperty('--fb-scale', String(value));
|
||||
// Medium (1.0) clears the inline override so default rendering is
|
||||
// byte-identical to before this feature existed (zero blast radius).
|
||||
el.style.fontSize = (Math.abs(value - 1) < 0.001) ? '' : (value * 100).toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function persist(value) {
|
||||
try {
|
||||
if (Math.abs(value - DEFAULT) < 0.001) localStorage.removeItem(STORE_KEY);
|
||||
else localStorage.setItem(STORE_KEY, String(value));
|
||||
} catch (_) { /* private mode */ }
|
||||
}
|
||||
|
||||
function announce() {
|
||||
try {
|
||||
if (typeof window.feedBack.emit === 'function') {
|
||||
window.feedBack.emit('scale:changed', { value: current, step: stepFor(current) });
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
// Hydrate on load (idempotent with the pre-paint inline script).
|
||||
apply(current);
|
||||
|
||||
window.feedBack.scale = Object.freeze({
|
||||
version: 1,
|
||||
min: MIN,
|
||||
max: MAX,
|
||||
default: DEFAULT,
|
||||
// Synchronous — valid immediately after this module parses.
|
||||
get: function () { return { value: current, step: stepFor(current) }; },
|
||||
// A copy of the preset ladder, so a UI can render it from one source.
|
||||
presets: function () {
|
||||
return PRESETS.map(function (p) { return { step: p.step, value: p.value }; });
|
||||
},
|
||||
// Set + apply + persist + announce. Pass { persist:false } for a
|
||||
// transient preview (e.g. a live slider drag) that shouldn't be written.
|
||||
set: function (value, opts) {
|
||||
var v = clamp(value);
|
||||
current = v;
|
||||
apply(v);
|
||||
if (!opts || opts.persist !== false) persist(v);
|
||||
announce();
|
||||
return current;
|
||||
},
|
||||
});
|
||||
|
||||
// Announce once after the document parses, so any listener wired during page
|
||||
// load can sync without special-casing (consumers may also just call get()).
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', announce, { once: true });
|
||||
} else {
|
||||
announce();
|
||||
}
|
||||
})();
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -71,7 +71,7 @@
|
||||
'<div class="flex items-center gap-2 text-xs text-fb-textDim mb-3">' +
|
||||
'<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13M9 13l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' +
|
||||
'<span>Audio Routing</span></div>' +
|
||||
'<div class="flex items-center gap-2 text-[11px] text-fb-textDim">' +
|
||||
'<div class="flex items-center gap-2 text-[0.6875rem] text-fb-textDim">' +
|
||||
dot(st.inputAvailable) + '<span>Input</span>' +
|
||||
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
|
||||
dot(st.effectActive) + '<span>VST/NAM/IR</span>' +
|
||||
|
||||
+6
-6
@@ -296,7 +296,7 @@
|
||||
'<div class="shrink-0 flex flex-col gap-[3px] items-center justify-center">' + meter + '</div>' +
|
||||
'<div class="min-w-0 text-white text-center leading-none">' +
|
||||
'<div data-tuner-note class="text-2xl font-black italic tracking-tighter leading-none">' + esc(initNote) + '</div>' +
|
||||
'<div data-tuner-hz class="text-[9px] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
|
||||
'<div data-tuner-hz class="text-[0.5625rem] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
|
||||
'</div></button>' +
|
||||
'</div>';
|
||||
host.querySelector('[data-open-tuner]').addEventListener('click', (e) => {
|
||||
@@ -399,7 +399,7 @@
|
||||
// Live working-tuning label: dim while you're still in your home tuning,
|
||||
// amber once you've retuned. Omitted if the host doesn't expose
|
||||
// workingTuning (feature-detect → the card looks exactly as before).
|
||||
(wt ? '<span class="text-[9px] leading-none font-semibold max-w-full truncate px-0.5 ' +
|
||||
(wt ? '<span class="text-[0.5625rem] leading-none font-semibold max-w-full truncate px-0.5 ' +
|
||||
(wt.isHome ? 'text-fb-textDim' : 'text-amber-400') + '">' + esc(wt.short) + '</span>' : '') +
|
||||
'<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>' +
|
||||
'</button>' +
|
||||
@@ -410,21 +410,21 @@
|
||||
? '<div class="flex items-center justify-between gap-2 pb-1 border-b border-fb-border/40">' +
|
||||
'<div class="min-w-0"><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim">Now in</div>' +
|
||||
'<div class="text-xs font-semibold text-amber-400 truncate flex items-center gap-1">' + esc(wt.label) + ' ' + provenanceGlyph(wt.provenance) + '</div></div>' +
|
||||
'<button type="button" data-inst-reset title="Reset this instrument to its home tuning" class="shrink-0 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
|
||||
'<button type="button" data-inst-reset title="Reset this instrument to its home tuning" class="shrink-0 text-[0.6875rem] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
instRow('Instrument', ['guitar', 'bass'].map((v) =>
|
||||
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
|
||||
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
|
||||
pill('strings', v, v + '', settings.string_count === v)).join('')) +
|
||||
'<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
// An offset-array tuning has no named option — surface it as a
|
||||
// disabled, selected 'Custom' entry so the dropdown reflects reality
|
||||
// (picking a named tuning still works and replaces the custom one).
|
||||
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
|
||||
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[10px] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
|
||||
'</div></div>';
|
||||
|
||||
@@ -478,7 +478,7 @@
|
||||
});
|
||||
}
|
||||
function instRow(label, inner) {
|
||||
return '<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
}
|
||||
function pill(group, val, label, active) {
|
||||
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
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-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||
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.
|
||||
@@ -75,7 +75,7 @@
|
||||
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-[9px] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
|
||||
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 ──────────────────────────────────────────---
|
||||
|
||||
@@ -60,6 +60,24 @@
|
||||
} catch (_) { /* file:// or sandboxed iframe */ }
|
||||
})();
|
||||
</script>
|
||||
<!-- Interface size (Accessibility): apply the saved UI scale to the root
|
||||
font-size BEFORE any stylesheet paints, so there is no flash-of-reflow
|
||||
on load. Mirrors capabilities/interface-scale.js, which is the
|
||||
authoritative owner and re-applies once it parses. Relative % (never a
|
||||
px literal) so a raised browser/OS base font size is respected. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('v3-interface-scale');
|
||||
if (raw == null) return;
|
||||
var v = Math.min(1.5, Math.max(0.85, parseFloat(raw)));
|
||||
if (!isFinite(v) || Math.abs(v - 1) < 0.001) return;
|
||||
var el = document.documentElement;
|
||||
el.style.setProperty('--fb-scale', String(v));
|
||||
el.style.fontSize = (v * 100).toFixed(2) + '%';
|
||||
} catch (_) { /* file:// or private mode */ }
|
||||
})();
|
||||
</script>
|
||||
<!-- fee[dB]ack mark (the [dB] motif). SVG primary, PNG fallback. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/v3/brand/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/v3/brand/favicon-32.png">
|
||||
@@ -96,6 +114,7 @@
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
<script src="/static/capabilities/interface-scale.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -359,6 +378,7 @@
|
||||
panels (manifest settings.category routes the others). -->
|
||||
<div class="fb-tabbar" id="settings-tabbar">
|
||||
<button type="button" class="fb-tab" data-tab="gameplay">Gameplay</button>
|
||||
<button type="button" class="fb-tab" data-tab="accessibility">Accessibility</button>
|
||||
<button type="button" class="fb-tab" data-tab="audio">Audio</button>
|
||||
<button type="button" class="fb-tab" data-tab="graphics">Graphics</button>
|
||||
<button type="button" class="fb-tab" data-tab="keybinds">Keybinds</button>
|
||||
@@ -540,6 +560,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ ACCESSIBILITY ═══════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="accessibility">
|
||||
<div class="fb-tabpanel-head"><h3>Accessibility</h3></div>
|
||||
<div class="fb-srows">
|
||||
<!-- Interface size -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7V5a1 1 0 011-1h14a1 1 0 011 1v2M9 20h6M12 4v16"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Interface size</div>
|
||||
<div class="fb-srow-desc">Make the app’s menus, buttons and text larger or smaller. This changes the <strong>app interface</strong> — not the notes on the note highway (set those under <strong>Graphics</strong>). On desktop you can also press <strong>Ctrl +</strong> / <strong>Ctrl −</strong> to zoom the whole window.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-seg" id="setting-interface-size" role="group" aria-label="Interface size">
|
||||
<button type="button" class="fb-seg-btn" data-scale="0.90" aria-pressed="false" onclick="window.feedBack.scale.set(0.90)">Small</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.00" aria-pressed="false" onclick="window.feedBack.scale.set(1.00)">Medium</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.15" aria-pressed="false" onclick="window.feedBack.scale.set(1.15)">Large</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.30" aria-pressed="false" onclick="window.feedBack.scale.set(1.30)">Extra Large</button>
|
||||
</div>
|
||||
<p class="fb-srow-desc" style="margin-top:.55rem;">Larger sizes are recommended for large or high-resolution displays.</p>
|
||||
<details class="fb-finetune">
|
||||
<summary>Fine-tune size</summary>
|
||||
<div class="fb-finetune-body">
|
||||
<input type="range" id="setting-interface-size-slider" min="85" max="150" step="5" value="100"
|
||||
oninput="window.feedBack.scale.set(this.value / 100, { persist: false })"
|
||||
onchange="window.feedBack.scale.set(this.value / 100)" class="fb-srow-wide slider-input"
|
||||
aria-label="Interface size percent">
|
||||
<span class="fb-seg-val"><span id="setting-interface-size-val">100</span>%</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ AUDIO ═══════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="audio">
|
||||
<div class="fb-tabpanel-head"><h3>Audio Settings</h3></div>
|
||||
@@ -1147,10 +1201,12 @@
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<script src="/static/v3/settings.js"></script>
|
||||
<script src="/static/v3/interface-size-ui.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
<script src="/static/v3/onboarding-tour.js"></script>
|
||||
<script src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script src="/static/v3/feedbarcade.js"></script>
|
||||
<script src="/static/v3/player-chrome.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// v3 first-run "Interface size" nudge.
|
||||
//
|
||||
// The Accessibility → Interface size control is the primary discovery path, but
|
||||
// the exact person who needs it — someone on a large, low-DPI display (e.g. a
|
||||
// 32" 1440p panel with no OS scaling) — is the one least likely to go hunting
|
||||
// for it. So, ONCE, for that specific display profile, surface a gentle,
|
||||
// dismissible toast that deep-links to the control. Never fires if the user has
|
||||
// already touched the setting, on smaller/high-DPI displays, or more than once.
|
||||
//
|
||||
// Plain non-module script; degrades to a no-op without the bus, fbNotify, or DOM.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SEEN_KEY = 'v3-interface-size-nudged';
|
||||
var SCALE_KEY = 'v3-interface-scale';
|
||||
|
||||
function alreadyHandled() {
|
||||
try {
|
||||
return localStorage.getItem(SEEN_KEY) === '1' || localStorage.getItem(SCALE_KEY) != null;
|
||||
} catch (_) { return true; }
|
||||
}
|
||||
|
||||
// The target profile: a physically large viewport rendered near 1:1 (so the
|
||||
// OS isn't already enlarging things). This is the eye-strain case.
|
||||
function isLargeLowDpiDisplay() {
|
||||
var w = window.innerWidth || 0;
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
return w >= 1800 && dpr <= 1.25;
|
||||
}
|
||||
|
||||
// Don't interrupt a first-run modal (e.g. profile onboarding). If one is up,
|
||||
// leave the flag UNSET so the nudge gets another chance on a later launch.
|
||||
function aModalIsOpen() {
|
||||
var dialogs = document.querySelectorAll('[role="dialog"], .fixed.inset-0');
|
||||
for (var i = 0; i < dialogs.length; i++) {
|
||||
var el = dialogs[i];
|
||||
if (el.offsetParent !== null && el.getClientRects().length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function openSetting() {
|
||||
try {
|
||||
if (typeof window.showScreen === 'function') window.showScreen('settings');
|
||||
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
|
||||
if (b.dataset.tab === 'accessibility') b.click();
|
||||
});
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
function maybeNudge() {
|
||||
if (alreadyHandled()) return;
|
||||
if (!isLargeLowDpiDisplay()) return;
|
||||
if (!window.fbNotify || typeof window.fbNotify.show !== 'function') return;
|
||||
if (aModalIsOpen()) return; // try again next launch
|
||||
|
||||
try { localStorage.setItem(SEEN_KEY, '1'); } catch (_) { /* private mode */ }
|
||||
|
||||
var card = window.fbNotify.show({
|
||||
title: 'Text looking small?',
|
||||
message: 'Make the menus and text larger — tap to open Interface size.',
|
||||
icon: '🔍',
|
||||
accent: '#0ea5e9',
|
||||
durationMs: 9000,
|
||||
});
|
||||
if (card && card.addEventListener) card.addEventListener('click', openSetting);
|
||||
}
|
||||
|
||||
function start() {
|
||||
// Let the app settle (boot, any onboarding) before offering the nudge.
|
||||
setTimeout(maybeNudge, 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
// v3 Settings → Accessibility: keeps the "Interface size" control in sync with
|
||||
// the host `feedBack.scale` capability. The buttons/slider WRITE via inline
|
||||
// `feedBack.scale.set(...)`; this module only REFLECTS current state (active
|
||||
// preset, slider position, % readout) so the control mirrors the live value on
|
||||
// load, on every change, and each time Settings is opened.
|
||||
//
|
||||
// Plain non-module script, matching the rest of static/v3/*. Null-guarded so it
|
||||
// no-ops on the classic v2 page (which has no Accessibility panel).
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function sync(state) {
|
||||
if (!state) {
|
||||
var cap = window.feedBack && window.feedBack.scale;
|
||||
state = cap && typeof cap.get === 'function' ? cap.get() : null;
|
||||
}
|
||||
if (!state) return;
|
||||
|
||||
var seg = document.getElementById('setting-interface-size');
|
||||
if (seg) {
|
||||
seg.querySelectorAll('.fb-seg-btn').forEach(function (b) {
|
||||
var on = Math.abs(parseFloat(b.dataset.scale) - state.value) < 0.001;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
var slider = document.getElementById('setting-interface-size-slider');
|
||||
if (slider && document.activeElement !== slider) {
|
||||
slider.value = String(Math.round(state.value * 100));
|
||||
}
|
||||
var val = document.getElementById('setting-interface-size-val');
|
||||
if (val) val.textContent = String(Math.round(state.value * 100));
|
||||
}
|
||||
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
// Fires on every set() and once on load. The bus delivers a CustomEvent,
|
||||
// so we ignore the arg and read the authoritative value via sync() → get().
|
||||
window.feedBack.on('scale:changed', function () { sync(); });
|
||||
// Re-sync when the user opens Settings (the panel may have re-rendered).
|
||||
window.feedBack.on('screen:changed', function (e) {
|
||||
var id = e && (e.detail ? e.detail.id : e.id);
|
||||
if (id === 'settings') sync();
|
||||
});
|
||||
}
|
||||
// Settings markup is static, but re-sync when settings.js signals it wired.
|
||||
document.addEventListener('v3:settings-rendered', function () { sync(); });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
|
||||
} else {
|
||||
sync();
|
||||
}
|
||||
|
||||
window.feedBackInterfaceSize = { sync: sync };
|
||||
})();
|
||||
@@ -56,8 +56,8 @@
|
||||
const shown = arr.slice(0, max || 6);
|
||||
const extra = arr.length - shown.length;
|
||||
let html = shown.map((t) =>
|
||||
'<span class="text-[10px] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
|
||||
if (extra > 0) html += '<span class="text-[10px] text-fb-textDim">+' + extra + '</span>';
|
||||
'<span class="text-[0.625rem] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
|
||||
if (extra > 0) html += '<span class="text-[0.625rem] text-fb-textDim">+' + extra + '</span>';
|
||||
return '<div class="flex flex-wrap gap-1">' + html + '</div>';
|
||||
}
|
||||
function progressBar(passed, total) {
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
const handle = opts.draggable
|
||||
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
|
||||
const tuning = s.tuning_name
|
||||
? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
|
||||
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
|
||||
// the work's current keeper when the pinned file is gone) with its
|
||||
@@ -76,9 +76,9 @@
|
||||
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
|
||||
: '';
|
||||
const pin = (isAlbum && s.arrangement)
|
||||
? '<span class="ml-2 text-[10px] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
|
||||
const orphan = (isAlbum && s.resolved_from_orphan)
|
||||
? '<span class="ml-2 text-[10px] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
|
||||
? '<span class="ml-2 text-[0.625rem] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
|
||||
const slotBtn = (isAlbum && !missing)
|
||||
? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
|
||||
return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
|
||||
@@ -313,7 +313,7 @@
|
||||
'<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
|
||||
'<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
|
||||
'<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
|
||||
(sub ? '<span class="block text-[10px] text-fb-textDim truncate">' + sub + '</span>' : '') +
|
||||
(sub ? '<span class="block text-[0.625rem] text-fb-textDim truncate">' + sub + '</span>' : '') +
|
||||
'</span></label>';
|
||||
// Checked = the stored pin; an orphaned slot (stored file gone from the
|
||||
// list) pre-checks the chart it currently resolves to, so Apply re-pins
|
||||
@@ -322,9 +322,9 @@
|
||||
const chartRows = chartList.map((c) => radio(
|
||||
'slot-chart', c.filename,
|
||||
c.filename === slot.filename || (!slotInList && c.filename === curFn),
|
||||
esc(c.title) + (c.is_representative ? ' <span class="text-[10px] text-fb-primary">● preferred</span>' : ''),
|
||||
esc(c.title) + (c.is_representative ? ' <span class="text-[0.625rem] text-fb-primary">● preferred</span>' : ''),
|
||||
esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
|
||||
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[10px] text-fb-textDim">(default)</span>', '')]
|
||||
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[0.625rem] text-fb-textDim">(default)</span>', '')]
|
||||
.concat((slot.arrangements || []).map((a) => {
|
||||
const name = (a && (a.smart_name || a.name)) || '';
|
||||
if (!name) return '';
|
||||
|
||||
@@ -82,10 +82,10 @@
|
||||
'<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
|
||||
'<div class="flex items-end gap-2">' +
|
||||
'<div class="flex flex-col leading-none">' +
|
||||
'<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
|
||||
'<span class="text-gray-400 text-[0.625rem] font-medium">Rank:</span>' +
|
||||
'<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
|
||||
'<div class="flex items-end gap-1">' + bars + '</div>' +
|
||||
'<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
|
||||
'<span class="text-[0.625rem] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
|
||||
'</div></div></button>';
|
||||
// Equipped avatar frame (spec 010 cosmetics).
|
||||
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
|
||||
@@ -178,7 +178,7 @@
|
||||
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
||||
'</div>';
|
||||
const playerIdFooter = (_profile && _profile.player_hash
|
||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
? '<p class="text-center text-[0.625rem] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
: '');
|
||||
root.innerHTML =
|
||||
'<div class="max-w-4xl mx-auto p-6 md:p-8">' +
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@
|
||||
const items = NAV.filter((n) => n.group === group);
|
||||
if (!items.length) continue;
|
||||
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[0.625rem] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
||||
}
|
||||
nav.innerHTML = html;
|
||||
|
||||
+21
-21
@@ -659,7 +659,7 @@
|
||||
const l = fmtLabel(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-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||
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>';
|
||||
}
|
||||
|
||||
// Personal-layer badges (P2): a difficulty pip + a tag count, painted from the
|
||||
@@ -672,8 +672,8 @@
|
||||
const tags = song.tags || [];
|
||||
if (d == null && !tags.length) return '';
|
||||
let out = '<div class="absolute top-2 right-2 flex gap-1 opacity-100 group-hover:opacity-0 transition pointer-events-none">';
|
||||
if (d != null) out += '<span class="bg-black/60 text-white text-[10px] font-bold px-1.5 py-0.5 rounded" title="Your difficulty: ' + esc(DIFF_LABELS[d] || d) + '">◆' + esc(d) + '</span>';
|
||||
if (tags.length) out += '<span class="bg-black/60 text-white text-[10px] font-bold px-1.5 py-0.5 rounded" title="Tags: ' + esc(tags.join(', ')) + '">🏷' + tags.length + '</span>';
|
||||
if (d != null) out += '<span class="bg-black/60 text-white text-[0.625rem] font-bold px-1.5 py-0.5 rounded" title="Your difficulty: ' + esc(DIFF_LABELS[d] || d) + '">◆' + esc(d) + '</span>';
|
||||
if (tags.length) out += '<span class="bg-black/60 text-white text-[0.625rem] font-bold px-1.5 py-0.5 rounded" title="Tags: ' + esc(tags.join(', ')) + '">🏷' + tags.length + '</span>';
|
||||
return out + '</div>';
|
||||
}
|
||||
|
||||
@@ -685,7 +685,7 @@
|
||||
// card layout.
|
||||
function arrChipsHtml(song) {
|
||||
return (song.arrangements || []).slice(0, 4).map((a) =>
|
||||
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
|
||||
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[0.625rem] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
|
||||
}
|
||||
|
||||
// ⚑ multi-chart chip (P5c, design §7.1): the persistent "other versions
|
||||
@@ -698,7 +698,7 @@
|
||||
function chartsChipHtml(song) {
|
||||
const n = song.chart_count;
|
||||
if (!(n >= 2) || !song.work_key) return '';
|
||||
return '<button data-charts="' + esc(song.work_key) + '" title="' + n + ' charts of this song" aria-label="' + n + ' charts of this song" class="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary border border-fb-primary/40 hover:bg-fb-primary hover:text-white transition">⚑ ' + n + ' charts</button>';
|
||||
return '<button data-charts="' + esc(song.work_key) + '" title="' + n + ' charts of this song" aria-label="' + n + ' charts of this song" class="shrink-0 text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary border border-fb-primary/40 hover:bg-fb-primary hover:text-white transition">⚑ ' + n + ' charts</button>';
|
||||
}
|
||||
|
||||
// ── Tuning-match flags (working-tuning PR 6) ───────────────────────────────
|
||||
@@ -776,10 +776,10 @@
|
||||
? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"'
|
||||
+ (chipIsBass ? ' data-tuning-bass="1"' : '') : '';
|
||||
if (targetNotes) {
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center"' + matchAttr + ' title="' + esc(badgeTitle) + '">'
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center"' + matchAttr + ' title="' + esc(badgeTitle) + '">'
|
||||
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
|
||||
} else {
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[10px] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
|
||||
}
|
||||
}
|
||||
// Display-only (pointer-events-none) so a click falls through to the
|
||||
@@ -871,7 +871,7 @@
|
||||
'<button data-act="' + esc(r.id) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 ' +
|
||||
(r.enabled === false ? 'opacity-40 cursor-not-allowed ' : '') +
|
||||
(r.destructive ? 'text-fb-accent' : 'text-fb-text') + '">' + esc(r.label) +
|
||||
(r.plugin && r.plugin !== 'core' ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(r.plugin) + '</span>' : '') + '</button>').join('');
|
||||
(r.plugin && r.plugin !== 'core' ? '<span class="text-[0.625rem] text-fb-textDim ml-1">' + esc(r.plugin) + '</span>' : '') + '</button>').join('');
|
||||
if (pos) {
|
||||
// Right-click: position the menu at the pointer (fixed, viewport-
|
||||
// relative), clamped so it never spills off the right/bottom edge.
|
||||
@@ -945,7 +945,7 @@
|
||||
? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || '');
|
||||
return '<button data-ver="' + esc(c.filename) + '" title="' + esc(c.filename) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text">' +
|
||||
(c.is_representative ? '<span class="text-fb-primary">●</span> ' : '') + esc(c.title) +
|
||||
(tl ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(tl) + '</span>' : '') +
|
||||
(tl ? '<span class="text-[0.625rem] text-fb-textDim ml-1">' + esc(tl) + '</span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
menu.querySelectorAll('[data-ver]').forEach((vb) => vb.addEventListener('click', (e) => {
|
||||
@@ -1041,8 +1041,8 @@
|
||||
'<div class="min-w-0">' +
|
||||
'<div class="text-sm text-fb-text truncate" title="' + esc(c.title) + '">' + esc(c.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(meta) + ' · ' + acc + '</div>' +
|
||||
'<div class="text-[10px] text-fb-textDim/60 truncate" title="' + esc(c.filename) + '">' + esc(c.filename) + '</div>' +
|
||||
(prefLabel ? '<div class="text-[10px] font-semibold text-fb-primary mt-0.5">' + esc(prefLabel) + '</div>' : '') +
|
||||
'<div class="text-[0.625rem] text-fb-textDim/60 truncate" title="' + esc(c.filename) + '">' + esc(c.filename) + '</div>' +
|
||||
(prefLabel ? '<div class="text-[0.625rem] font-semibold text-fb-primary mt-0.5">' + esc(prefLabel) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<button data-ch-play title="Play this chart" aria-label="Play this chart" class="shrink-0 w-8 h-8 rounded-full bg-fb-primary hover:bg-fb-primaryHi text-white text-sm leading-none">▶</button>' +
|
||||
'</div>' +
|
||||
@@ -1075,7 +1075,7 @@
|
||||
// switch — the headline may drop because history stays with each chart
|
||||
// (motor mastery is arrangement-specific). Text only, no toast/sound.
|
||||
const switchNote = (opts && opts.switched)
|
||||
? '<div class="text-[11px] text-fb-primary/90 border border-fb-primary/30 rounded-md px-2 py-1.5">Practice history stays with each chart — your new pick starts from its own stats.</div>'
|
||||
? '<div class="text-[0.6875rem] text-fb-primary/90 border border-fb-primary/30 rounded-md px-2 py-1.5">Practice history stays with each chart — your new pick starts from its own stats.</div>'
|
||||
: '';
|
||||
dr.innerHTML =
|
||||
'<div class="p-5 space-y-4">' +
|
||||
@@ -1092,7 +1092,7 @@
|
||||
'</div>' +
|
||||
(data.preferred_source === 'user'
|
||||
? '<button data-charts-auto class="w-full text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md py-2">Reset to auto pick</button>'
|
||||
: '<div class="text-[11px] text-fb-textDim">Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.</div>') +
|
||||
: '<div class="text-[0.6875rem] text-fb-textDim">Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.</div>') +
|
||||
'</div>';
|
||||
|
||||
dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer);
|
||||
@@ -1457,7 +1457,7 @@
|
||||
|
||||
'<div><div class="text-xs text-fb-textDim mb-1">Difficulty (for you)</div>' +
|
||||
'<div class="flex flex-wrap gap-1 items-center">' + diffBtn('keep', 'Leave') + diffBtn(1, '1') + diffBtn(2, '2') + diffBtn(3, '3') + diffBtn(4, '4') + diffBtn(5, '5') + diffBtn('clear', 'Clear') + '</div>' +
|
||||
'<div class="text-[11px] text-fb-textDim mt-1">"Leave" keeps each song's own value; a number or Clear applies to all ' + fns.length + '.</div></div>' +
|
||||
'<div class="text-[0.6875rem] text-fb-textDim mt-1">"Leave" keeps each song's own value; a number or Clear applies to all ' + fns.length + '.</div></div>' +
|
||||
|
||||
'<div><div class="text-xs text-fb-textDim mb-1">Add tags to all</div>' +
|
||||
'<div class="flex flex-wrap gap-1 mb-2">' + (addChips || '<span class="text-xs text-fb-textDim">None</span>') + '</div>' +
|
||||
@@ -2040,7 +2040,7 @@
|
||||
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
|
||||
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
|
||||
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
|
||||
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
|
||||
(fl ? '<span class="text-[0.5625rem] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
|
||||
accuracyBadge(k, 'tree') +
|
||||
// Same fav / save-for-later / overflow-menu cluster as the grid
|
||||
// card. Always shown (like the arrangement chips), not hover-
|
||||
@@ -2249,7 +2249,7 @@
|
||||
'<button data-tag-rm="' + esc(t) + '" aria-label="Remove tag ' + esc(t) + '" class="text-fb-textDim hover:text-fb-accent leading-none">×</button></span>').join('')
|
||||
: '<span class="text-xs text-fb-textDim">No tags yet</span>';
|
||||
const suggest = (vocab || []).filter((v) => !applied.has(v.tag)).slice(0, 8).map((v) =>
|
||||
'<button data-tag-add="' + esc(v.tag) + '" class="text-[11px] px-2 py-0.5 rounded-full bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(v.tag) + '</button>').join('');
|
||||
'<button data-tag-add="' + esc(v.tag) + '" class="text-[0.6875rem] px-2 py-0.5 rounded-full bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(v.tag) + '</button>').join('');
|
||||
const field = (id, label, val) =>
|
||||
'<div><label for="' + id + '" class="text-xs text-fb-textDim mb-1 block">' + label + '</label>' +
|
||||
'<input type="text" id="' + id + '" value="' + esc(val) + '" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>';
|
||||
@@ -2260,22 +2260,22 @@
|
||||
'<div class="flex items-center gap-3">' +
|
||||
'<div class="relative group cursor-pointer shrink-0" data-det-art title="Change album art">' +
|
||||
'<img src="' + esc(art) + '" alt="" class="w-16 h-16 rounded-lg object-cover bg-fb-card" id="det-art-preview" onerror="this.style.visibility=\'hidden\'">' +
|
||||
'<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition text-[10px] text-white">Change</div>' +
|
||||
'<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition text-[0.625rem] text-white">Change</div>' +
|
||||
'<input type="file" accept="image/*" id="det-art-file" class="hidden"></div>' +
|
||||
'<div class="min-w-0"><div class="text-sm text-fb-text truncate" title="' + esc(song.title || song.filename) + '">' + esc(song.title || song.filename) + '</div>' +
|
||||
'<div class="mt-1"><button data-det-fav class="text-xs ' + (st.fav ? 'text-fb-accent' : 'text-fb-textDim hover:text-fb-text') + '">' + (st.fav ? '♥ Liked' : '♡ Like') + '</button>' +
|
||||
'<span class="text-[10px] text-fb-textDim ml-1">a like, not a rating</span></div></div></div>' +
|
||||
'<span class="text-[0.625rem] text-fb-textDim ml-1">a like, not a rating</span></div></div></div>' +
|
||||
|
||||
// Identity — writes back into the feedpak FILE
|
||||
'<div class="space-y-3"><div class="flex items-center gap-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Identity</div>' +
|
||||
'<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song's feedpak. Editing them writes back to the file.">From pack</span></div>' +
|
||||
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song's feedpak. Editing them writes back to the file.">From pack</span></div>' +
|
||||
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) +
|
||||
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div></div>' +
|
||||
|
||||
// Personal practice layer — local, never shared
|
||||
'<div class="space-y-3 pt-1"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Your practice <span class="normal-case font-normal text-fb-textDim/70">· stays on this device</span></div>' +
|
||||
'<div><div class="flex items-center justify-between mb-1"><label class="text-xs text-fb-textDim">Difficulty (for you)</label>' +
|
||||
'<button data-diff-clear class="text-[11px] text-fb-textDim hover:text-fb-text ' + (st.diff == null ? 'invisible' : '') + '">Clear</button></div>' +
|
||||
'<button data-diff-clear class="text-[0.6875rem] text-fb-textDim hover:text-fb-text ' + (st.diff == null ? 'invisible' : '') + '">Clear</button></div>' +
|
||||
'<div class="flex gap-1 items-center">' + diffBtns + '<span class="text-xs text-fb-textDim ml-2">' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '</span></div></div>' +
|
||||
'<div><label for="det-tag-input" class="text-xs text-fb-textDim mb-1 block">Tags</label>' +
|
||||
'<div class="flex flex-wrap gap-1 mb-2" data-det-tags>' + tagChips + '</div>' +
|
||||
@@ -2437,7 +2437,7 @@
|
||||
const rows = list.map((a) => {
|
||||
const checked = st.sel.has(a.name) ? ' checked' : '';
|
||||
const mapped = (a.canonical && a.canonical.toLowerCase() !== (a.name || '').toLowerCase())
|
||||
? '<span class="text-[11px] text-fb-primary ml-1">→ ' + esc(a.canonical) + '</span>' : '';
|
||||
? '<span class="text-[0.6875rem] text-fb-primary ml-1">→ ' + esc(a.canonical) + '</span>' : '';
|
||||
return '<label class="flex items-center gap-2 px-2 py-1 rounded hover:bg-fb-card/50 cursor-pointer">' +
|
||||
'<input type="checkbox" data-tidy-sel="' + esc(a.name) + '"' + checked + ' class="w-4 h-4 accent-fb-primary shrink-0">' +
|
||||
'<span class="text-sm text-fb-text truncate flex-1">' + esc(a.name) + mapped + '</span>' +
|
||||
|
||||
+34
-2
@@ -4,6 +4,12 @@
|
||||
* `fb` palette in tailwind.config.js.
|
||||
*/
|
||||
|
||||
/* Interface-size (Accessibility): the always-present UI-scale token. JS
|
||||
(capabilities/interface-scale.js) overrides it on :root and drives the
|
||||
visible scaling through the root font-size; this default keeps
|
||||
`var(--fb-scale)` resolvable for any canvas/CSS consumer even when unset. */
|
||||
:root { --fb-scale: 1; }
|
||||
|
||||
/* ── Text-selection policy (v3) ──────────────────────────────────────────────
|
||||
Accidental drag/double-click selection of app chrome (sidebar, transport, the
|
||||
note highway/HUD, buttons, labels) makes the UI look broken and is never
|
||||
@@ -604,7 +610,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
font-size: 32px;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
#v3-player-rail .section-practice-control--v3 .section-practice-pill-icon .v3-rail-svg,
|
||||
@@ -877,7 +883,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
|
||||
padding: 0 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #22d3ee; color: #0f172a;
|
||||
font-size: 10px; font-weight: 800; line-height: 1;
|
||||
font-size: 0.625rem; font-weight: 800; line-height: 1;
|
||||
border-radius: 999px; z-index: 2;
|
||||
}
|
||||
.v3-rail-badge[hidden] { display: none; }
|
||||
@@ -1119,6 +1125,32 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; }
|
||||
.fb-srow-stack .fb-srow-control input[type="text"] { flex: 1 1 auto; min-width: 0; }
|
||||
.fb-srow-wide { width: 100%; }
|
||||
|
||||
/* Segmented control + fine-tune (Accessibility → Interface size) */
|
||||
.fb-seg {
|
||||
display: inline-flex; flex-wrap: wrap; gap: .25rem;
|
||||
background: #0f172a; border: 1px solid rgba(51, 65, 85, .55);
|
||||
border-radius: .7rem; padding: .3rem;
|
||||
}
|
||||
.fb-seg-btn {
|
||||
appearance: none; border: none; cursor: pointer;
|
||||
padding: .5rem .9rem; min-height: 2rem; border-radius: .5rem;
|
||||
font-size: .82rem; font-weight: 600; color: #94a3b8; background: transparent;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.fb-seg-btn:hover { color: #e2e8f0; }
|
||||
.fb-seg-btn.active { background: #0ea5e9; color: #04263a; }
|
||||
.fb-seg-btn:focus-visible { outline: 2px solid #38bdf8; outline-offset: 2px; }
|
||||
.fb-finetune { margin-top: .6rem; }
|
||||
.fb-finetune > summary {
|
||||
font-size: .78rem; font-weight: 600; color: #94a3b8; cursor: pointer;
|
||||
list-style: none; display: inline-flex; align-items: center; gap: .35rem;
|
||||
}
|
||||
.fb-finetune > summary::-webkit-details-marker { display: none; }
|
||||
.fb-finetune > summary::before { content: "\25B8"; font-size: .7rem; transition: transform .15s; }
|
||||
.fb-finetune[open] > summary::before { transform: rotate(90deg); }
|
||||
.fb-finetune-body { display: flex; align-items: center; gap: 1rem; margin-top: .6rem; }
|
||||
.fb-seg-val { font-size: .8rem; color: #94a3b8; min-width: 3rem; text-align: right; }
|
||||
|
||||
/* Toggle switch */
|
||||
.fb-switch { position: relative; display: inline-block; width: 2.6rem; height: 1.5rem; flex: none; }
|
||||
.fb-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
|
||||
Reference in New Issue
Block a user