Add font size slider. Closes #132.

This commit is contained in:
Jack Kingsman
2026-03-30 16:47:24 -07:00
parent 6534946bc7
commit 7460c3ea9d
7 changed files with 290 additions and 2 deletions

View File

@@ -81,6 +81,7 @@ frontend/src/
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
│ ├── radioPresets.ts # LoRa radio preset configurations
│ ├── fontScale.ts # Browser-local relative font scale persistence/application
│ └── theme.ts # Theme switching helpers
├── components/
│ ├── StatusBar.tsx
@@ -110,7 +111,7 @@ frontend/src/
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, relative font scale, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats

View File

@@ -17,6 +17,14 @@ import {
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_SLIDER_STEP,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
const commitFontScale = (nextScale: number) => {
const normalized = setSavedFontScale(nextScale);
setFontScale(normalized);
setFontScaleSlider(normalized);
setFontScaleInput(String(normalized));
};
const restoreFontScaleInput = () => {
setFontScaleInput(String(fontScale));
};
const handleSliderChange = (nextScale: number) => {
setFontScaleSlider(nextScale);
setFontScaleInput(String(nextScale));
};
const handleSliderCommit = (nextScale: number) => {
commitFontScale(nextScale);
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select

View File

@@ -85,7 +85,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-size: 14px;
font-size: 0.875rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -5,9 +5,11 @@ import './index.css';
import './themes.css';
import './styles.css';
import { getSavedTheme, applyTheme } from './utils/theme';
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
// Apply saved theme before first render
applyTheme(getSavedTheme());
applyFontScale(getSavedFontScale());
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
applyFontScale,
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../utils/fontScale';
describe('fontScale utilities', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.style.fontSize = '';
});
afterEach(() => {
document.documentElement.style.fontSize = '';
});
it('defaults to 100% when nothing is saved', () => {
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('reads a saved scale from localStorage', () => {
localStorage.setItem(FONT_SCALE_KEY, '135');
expect(getSavedFontScale()).toBe(135);
});
it('falls back to the default when the saved value is invalid', () => {
localStorage.setItem(FONT_SCALE_KEY, 'giant');
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('applies the scale to the document root', () => {
expect(applyFontScale(150)).toBe(150);
expect(document.documentElement.style.fontSize).toBe('150%');
});
it('stores non-default values and applies them immediately', () => {
expect(setSavedFontScale(137.5)).toBe(137.5);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
it('removes the saved value when returning to the default scale', () => {
localStorage.setItem(FONT_SCALE_KEY, '150');
expect(setSavedFontScale(DEFAULT_FONT_SCALE)).toBe(DEFAULT_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
it('clamps saved and applied values to the supported range', () => {
localStorage.setItem(FONT_SCALE_KEY, '900');
expect(getSavedFontScale()).toBe(MAX_FONT_SCALE);
expect(setSavedFontScale(5)).toBe(MIN_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe(String(MIN_FONT_SCALE));
expect(document.documentElement.style.fontSize).toBe(`${MIN_FONT_SCALE}%`);
});
});

View File

@@ -20,6 +20,12 @@ import {
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
} from '../utils/fontScale';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -186,6 +192,7 @@ describe('SettingsModal', () => {
vi.restoreAllMocks();
localStorage.clear();
window.location.hash = '';
document.documentElement.style.fontSize = '';
});
it('refreshes app settings when opened', async () => {
@@ -549,6 +556,55 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
const input = screen.getByLabelText('Relative font size percentage');
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
expect(slider).toHaveAttribute('step', '5');
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
});
it('stores and applies relative font size changes locally', async () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
fireEvent.change(slider, { target: { value: '135' } });
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('');
fireEvent.mouseUp(slider);
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
expect(document.documentElement.style.fontSize).toBe('135%');
});
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
target: { value: '137.5' },
});
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
});
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,

View File

@@ -0,0 +1,53 @@
export const FONT_SCALE_KEY = 'remoteterm-font-scale';
export const DEFAULT_FONT_SCALE = 100;
export const MIN_FONT_SCALE = 25;
export const MAX_FONT_SCALE = 400;
export const FONT_SCALE_SLIDER_STEP = 5;
function normalizeFontScale(scale: number): number {
if (!Number.isFinite(scale)) {
return DEFAULT_FONT_SCALE;
}
const clamped = Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, scale));
return Number.parseFloat(clamped.toFixed(2));
}
export function getSavedFontScale(): number {
try {
const raw = localStorage.getItem(FONT_SCALE_KEY);
if (raw === null) {
return DEFAULT_FONT_SCALE;
}
return normalizeFontScale(Number.parseFloat(raw));
} catch {
return DEFAULT_FONT_SCALE;
}
}
export function applyFontScale(scale: number): number {
const normalized = normalizeFontScale(scale);
if (typeof document !== 'undefined') {
document.documentElement.style.fontSize = `${normalized}%`;
}
return normalized;
}
export function setSavedFontScale(scale: number): number {
const normalized = applyFontScale(scale);
try {
if (normalized === DEFAULT_FONT_SCALE) {
localStorage.removeItem(FONT_SCALE_KEY);
} else {
localStorage.setItem(FONT_SCALE_KEY, String(normalized));
}
} catch {
// localStorage may be unavailable
}
return normalized;
}