mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
Add font size slider. Closes #132.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
frontend/src/test/fontScale.test.ts
Normal file
66
frontend/src/test/fontScale.test.ts
Normal 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}%`);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
53
frontend/src/utils/fontScale.ts
Normal file
53
frontend/src/utils/fontScale.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user