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 toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue); const toggleQueue = usePlayerStore(s => s.toggleQueue);
const uiScale = useFontStore(s => s.uiScale);
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue); const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -341,6 +342,7 @@ function AppShell() {
/> />
)} )}
<main className="main-content"> <main className="main-content">
<div className="main-content-zoom" style={uiScale !== 1 ? { zoom: uiScale } : undefined}>
<header className="content-header"> <header className="content-header">
<LiveSearch /> <LiveSearch />
<div className="spacer" /> <div className="spacer" />
@@ -389,6 +391,7 @@ function AppShell() {
<Route path="/device-sync" element={<DeviceSync />} /> <Route path="/device-sync" element={<DeviceSync />} />
</Routes> </Routes>
</div> </div>
</div>
</main> </main>
{!isMobile && ( {!isMobile && (
<div <div
@@ -919,8 +922,6 @@ export default function App() {
useThemeStore(s => s.theme); // keep subscription so re-render on manual change useThemeStore(s => s.theme); // keep subscription so re-render on manual change
const effectiveTheme = useThemeScheduler(); const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font); const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const setUiScale = useFontStore(s => s.setUiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false); const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -931,17 +932,10 @@ export default function App() {
document.documentElement.setAttribute('data-font', font); document.documentElement.setAttribute('data-font', font);
}, [font]); }, [font]);
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework. // UI scaling is scoped to .main-content via an inner wrapper (see <main>
// Reset any stored non-100% value so users aren't stuck at a broken scale. // below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx. // because they live in separate grid cells. Document-level zoom is not used
useEffect(() => { // — it broke portal positioning and Tauri window measurement.
if (uiScale !== 1.0) setUiScale(1.0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
useEffect(() => { useEffect(() => {
return initAudioListeners(); return initAudioListeners();
+44 -38
View File
@@ -1739,50 +1739,56 @@ export default function Settings() {
<h2>{t('settings.uiScaleTitle')}</h2> <h2>{t('settings.uiScaleTitle')}</h2>
</div> </div>
<div className="settings-card"> <div className="settings-card">
{/* TODO: UI scaling is being reworked — disabled until fixed */} <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<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', alignItems: 'center', justifyContent: 'space-between' }}> <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, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
{Math.round(fontStore.uiScale * 100)}% {Math.round(fontStore.uiScale * 100)}%
</span> </span>
</div> </div>
<input {(() => {
type="range" const presets = [80, 90, 100, 110, 125, 150];
min={0.8} const currentPct = Math.round(fontStore.uiScale * 100);
max={1.5} let idx = presets.indexOf(currentPct);
step={0.05} if (idx < 0) {
value={fontStore.uiScale} // Snap legacy off-preset values to the closest preset.
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))} idx = presets.reduce((best, p, i) =>
className="ui-scale-slider" Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
/> }
<div style={{ position: 'relative', height: 24 }}> return (
{[80, 90, 100, 110, 125, 150].map(p => { <>
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100; <input
const active = Math.round(fontStore.uiScale * 100) === p; type="range"
return ( min={0}
<button max={presets.length - 1}
key={p} step={1}
className="btn btn-ghost" value={idx}
style={{ onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
position: 'absolute', className="ui-scale-slider"
left: `${pct}%`, />
transform: 'translateX(-50%)', <div style={{ display: 'flex', justifyContent: 'space-between' }}>
fontSize: 11, {presets.map(p => {
padding: '2px 6px', const active = currentPct === p;
opacity: active ? 1 : 0.5, return (
color: active ? 'var(--accent)' : undefined, <button
}} key={p}
onClick={() => fontStore.setUiScale(p / 100)} className="btn btn-ghost"
> style={{
{p}% fontSize: 11,
</button> padding: '2px 6px',
); opacity: active ? 1 : 0.5,
})} color: active ? 'var(--accent)' : undefined,
</div> }}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
</>
);
})()}
</div> </div>
</div> </div>
</section> </section>
+14
View File
@@ -940,6 +940,20 @@
z-index: 1; 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 { .content-header {
display: flex; display: flex;
align-items: center; align-items: center;