feat(ui-scale): re-enable scoped interface scaling

Document-level zoom broke portal positioning and Tauri window measurement,
so the slider had been forced to 100 %. Scope the zoom to a wrapper inside
.main-content instead — sidebar, queue, player bar and the Linux custom
title bar live in sibling grid cells of .app-shell and stay 1:1.

- App.tsx: drop the document-level zoom + auto-reset; wrap main-content
  children in <div class="main-content-zoom" style={zoom: uiScale}>.
- layout.css: add .main-content-zoom (flex column, fills parent).
- Settings.tsx: re-enable the slider and snap it to the 6 preset stops
  (80/90/100/110/125/150) so the thumb lands directly under each preset
  button. Legacy off-preset values snap to the nearest stop on load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 01:34:49 +02:00
parent b9093883a4
commit 832bacb569
3 changed files with 65 additions and 51 deletions
+7 -13
View File
@@ -127,6 +127,7 @@ function AppShell() {
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const uiScale = useFontStore(s => s.uiScale);
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -341,6 +342,7 @@ function AppShell() {
/>
)}
<main className="main-content">
<div className="main-content-zoom" style={uiScale !== 1 ? { zoom: uiScale } : undefined}>
<header className="content-header">
<LiveSearch />
<div className="spacer" />
@@ -389,6 +391,7 @@ function AppShell() {
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
</div>
</div>
</main>
{!isMobile && (
<div
@@ -919,8 +922,6 @@ export default function App() {
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const setUiScale = useFontStore(s => s.setUiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
@@ -931,17 +932,10 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
// Reset any stored non-100% value so users aren't stuck at a broken scale.
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
useEffect(() => {
if (uiScale !== 1.0) setUiScale(1.0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
// because they live in separate grid cells. Document-level zoom is not used
// — it broke portal positioning and Tauri window measurement.
useEffect(() => {
return initAudioListeners();
+44 -38
View File
@@ -1739,50 +1739,56 @@ export default function Settings() {
<h2>{t('settings.uiScaleTitle')}</h2>
</div>
<div className="settings-card">
{/* TODO: UI scaling is being reworked — disabled until fixed */}
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
Interface scaling is currently being reworked and will be available in a future update.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
{Math.round(fontStore.uiScale * 100)}%
</span>
</div>
<input
type="range"
min={0.8}
max={1.5}
step={0.05}
value={fontStore.uiScale}
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
className="ui-scale-slider"
/>
<div style={{ position: 'relative', height: 24 }}>
{[80, 90, 100, 110, 125, 150].map(p => {
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
const active = Math.round(fontStore.uiScale * 100) === p;
return (
<button
key={p}
className="btn btn-ghost"
style={{
position: 'absolute',
left: `${pct}%`,
transform: 'translateX(-50%)',
fontSize: 11,
padding: '2px 6px',
opacity: active ? 1 : 0.5,
color: active ? 'var(--accent)' : undefined,
}}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
{(() => {
const presets = [80, 90, 100, 110, 125, 150];
const currentPct = Math.round(fontStore.uiScale * 100);
let idx = presets.indexOf(currentPct);
if (idx < 0) {
// Snap legacy off-preset values to the closest preset.
idx = presets.reduce((best, p, i) =>
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
}
return (
<>
<input
type="range"
min={0}
max={presets.length - 1}
step={1}
value={idx}
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
className="ui-scale-slider"
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{presets.map(p => {
const active = currentPct === p;
return (
<button
key={p}
className="btn btn-ghost"
style={{
fontSize: 11,
padding: '2px 6px',
opacity: active ? 1 : 0.5,
color: active ? 'var(--accent)' : undefined,
}}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
</>
);
})()}
</div>
</div>
</section>
+14
View File
@@ -940,6 +940,20 @@
z-index: 1;
}
/* Scoped UI scaling target. Sidebar / queue / player / titlebar live in
sibling grid cells of .app-shell and stay at 1:1. The wrapper inherits
.main-content's flex column layout so .content-header (fixed height) and
.content-body (flex: 1, scroll) keep working unchanged.
Zoom is applied via inline style only when uiScale !== 1, so the default
case has no extra layer or layout cost. */
.main-content-zoom {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
}
.content-header {
display: flex;
align-items: center;