fix: font zone classes on tables + body/heading/nav zones in AppShell

- fs-zone-table on OrdersTable + LogisticsReadinessBoard (was missing)
- fs-zone-body on main content area
- fs-zone-heading on mobile + desktop headers
- fs-zone-nav on mobile tab nav
- CSS: added arbitrary px size overrides (10px/11px/12px/13px/14px)
- CSS: reordered body zone last so table/card/nav/heading win at equal specificity
This commit is contained in:
root 2026-07-02 13:19:10 +00:00
parent 5e02f61567
commit f69a52779f
8 changed files with 393 additions and 10 deletions

View File

@ -319,7 +319,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
}
return (
<div className="space-y-6">
<div className="space-y-6 fs-zone-table">
<Panel className="space-y-4 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">

View File

@ -94,7 +94,7 @@ export const OrdersTable = ({
}
return (
<Panel className="p-0">
<Panel className="p-0 fs-zone-table">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>

View File

@ -0,0 +1,73 @@
import React, { createContext, useContext, useEffect, useState } from "react";
const FontSettingsContext = createContext(null);
const STORAGE_KEY = "supersam-font-settings";
// Categories: key { label, defaultScale, min, max, step, description }
export const FONT_CATEGORIES = [
{ key: "table", label: "Таблицы", description: "Текст в таблицах и списках", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "card", label: "Карточки", description: "Текст в карточках и панелях", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "nav", label: "Меню и навигация", description: "Пункты меню, вкладки, кнопки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "heading", label: "Заголовки", description: "Названия секций и страниц", defaultScale: 1.0, min: 0.8, max: 1.6, step: 0.05 },
{ key: "body", label: "Основной текст", description: "Обычный текст в интерфейсе", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
{ key: "small", label: "Мелкий текст", description: "Подписи, метки, временные отметки", defaultScale: 1.0, min: 0.8, max: 1.5, step: 0.05 },
];
const DEFAULT_SETTINGS = FONT_CATEGORIES.reduce((acc, cat) => {
acc[cat.key] = cat.defaultScale;
return acc;
}, {});
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw);
// Merge with defaults to handle new categories
return { ...DEFAULT_SETTINGS, ...parsed };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function applySettings(settings) {
const root = document.documentElement;
for (const cat of FONT_CATEGORIES) {
root.style.setProperty(`--fs-scale-${cat.key}`, String(settings[cat.key] ?? 1.0));
}
}
export const FontSettingsProvider = ({ children }) => {
const [settings, setSettings] = useState(loadSettings);
useEffect(() => {
applySettings(settings);
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const updateCategory = (key, scale) => {
setSettings((prev) => ({ ...prev, [key]: scale }));
};
const resetAll = () => {
setSettings({ ...DEFAULT_SETTINGS });
};
const value = {
settings,
updateCategory,
resetAll,
categories: FONT_CATEGORIES,
};
return <FontSettingsContext.Provider value={value}>{children}</FontSettingsContext.Provider>;
};
export const useFontSettings = () => {
const context = useContext(FontSettingsContext);
if (!context) {
throw new Error("useFontSettings must be used within FontSettingsProvider");
}
return context;
};

View File

@ -1,4 +1,5 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { ROLE_LABELS } from "../constants/roles";
import { Badge } from "../components/UI/Badge";
import { Button } from "../components/UI/Button";
@ -47,7 +48,7 @@ export const AppShell = ({
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
{/* Desktop sidebar */}
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
<Panel className="fs-zone-nav hidden h-fit flex-col gap-5 p-4 xl:flex">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
Панель
@ -81,6 +82,9 @@ export const AppShell = ({
{isGuideOpen ? "К рабочей области" : "Справка"}
</Button>
) : null}
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={() => navigate("/settings")}>
Настройки
</Button>
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
Выйти
</Button>
@ -88,9 +92,9 @@ export const AppShell = ({
</Panel>
{/* Main content area */}
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0">
<div className="min-w-0 space-y-5 pb-20 xl:space-y-8 xl:pb-0 fs-zone-body">
{/* Mobile header */}
<Panel className="p-4 xl:hidden">
<Panel className="p-4 xl:hidden fs-zone-heading">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
@ -117,6 +121,9 @@ export const AppShell = ({
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
</Button>
<ThemeToggle />
<Button size="sm" variant="ghost" onClick={onSignOut}>
Выйти
@ -127,7 +134,7 @@ export const AppShell = ({
{/* Mobile tab navigation — STICKY TOP */}
{shouldShowMobileNav && (
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6">
<div className="sticky inset-x-0 top-0 z-40 -mx-3 -mt-4 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 backdrop-blur xl:hidden sm:-mx-4 md:-mx-6 fs-zone-nav">
<div className="flex gap-1 overflow-x-auto" style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none' }}>
{navItems.map((item) => (
<Button
@ -153,7 +160,7 @@ export const AppShell = ({
)}
{/* Desktop header */}
<Panel className="hidden p-4 md:p-5 xl:block">
<Panel className="hidden p-4 md:p-5 xl:block fs-zone-heading">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
@ -184,6 +191,9 @@ export const AppShell = ({
</Button>
) : null}
<PwaInstallButton onInstall={onInstallApp} isInstalled={isInstalled} isInstallAvailable={isInstallAvailable} />
<Button size="sm" variant="ghost" onClick={() => navigate("/settings")} aria-label="Настройки">
</Button>
<ThemeToggle />
</div>
</div>

View File

@ -4,10 +4,12 @@ import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { ThemeProvider } from "./context/ThemeContext";
import { AuthProvider } from "./context/AuthContext";
import { FontSettingsProvider } from "./context/FontSettingsContext";
import ErrorBoundary from "./components/ErrorBoundary";
import { initErrorLogging } from "./utils/errorLogger";
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
import "./index.css";
import "./styles/fontSettings.css";
registerPwaServiceWorker();
initErrorLogging();
@ -15,9 +17,11 @@ initErrorLogging();
ReactDOM.createRoot(document.getElementById("root")).render(
<ThemeProvider>
<AuthProvider>
<ErrorBoundary>
<RouterProvider router={router} />
</ErrorBoundary>
<FontSettingsProvider>
<ErrorBoundary>
<RouterProvider router={router} />
</ErrorBoundary>
</FontSettingsProvider>
</AuthProvider>
</ThemeProvider>,
);

186
src/pages/SettingsPage.jsx Normal file
View File

@ -0,0 +1,186 @@
/**
* @file SettingsPage.jsx
* @description Page for user settings: font size controls for all UI categories.
* Settings persist to localStorage via FontSettingsContext.
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { useFontSettings, FONT_CATEGORIES } from "../context/FontSettingsContext";
import { Panel } from "../components/UI/Panel";
import { Button } from "../components/UI/Button";
const PRESETS = [
{ label: "Мелкий", scales: { table: 0.85, card: 0.85, nav: 0.85, heading: 0.85, body: 0.85, small: 0.85 } },
{ label: "Стандарт", scales: { table: 1.0, card: 1.0, nav: 1.0, heading: 1.0, body: 1.0, small: 1.0 } },
{ label: "Крупный", scales: { table: 1.15, card: 1.15, nav: 1.15, heading: 1.2, body: 1.15, small: 1.1 } },
{ label: "Очень крупный", scales: { table: 1.3, card: 1.3, nav: 1.25, heading: 1.4, body: 1.3, small: 1.2 } },
];
const Slider = ({ category, value, onChange }) => {
const pct = ((value - category.min) / (category.max - category.min)) * 100;
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<div>
<span className="font-medium text-[var(--color-text)]">{category.label}</span>
<p className="text-xs text-[var(--color-text-muted)]">{category.description}</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--color-accent)]">
{Math.round(value * 100)}%
</span>
</div>
<div className="flex items-center gap-3">
<input
type="range"
min={category.min}
max={category.max}
step={category.step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="fs-slider flex-1"
style={{
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${pct}%, var(--color-border) ${pct}%, var(--color-border) 100%)`,
}}
aria-label={category.label}
/>
<span className="text-xs tabular-nums text-[var(--color-text-muted)] w-16 text-right">
{value.toFixed(2)}×
</span>
</div>
</div>
);
};
export const SettingsPage = () => {
const { settings, updateCategory, resetAll, categories } = useFontSettings();
const navigate = useNavigate();
const applyPreset = (scales) => {
for (const [key, scale] of Object.entries(scales)) {
updateCategory(key, scale);
}
};
// Check if current settings match a preset
const activePreset = PRESETS.findIndex((p) =>
Object.entries(p.scales).every(([k, v]) => Math.abs((settings[k] ?? 1.0) - v) < 0.001)
);
return (
<div className="mx-auto max-w-3xl space-y-6 px-3 py-4 sm:px-4 md:px-6 md:py-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Настройки</p>
<h1 className="mt-1 text-2xl font-bold leading-tight" style={{ fontSize: `calc(1.5rem * var(--fs-scale-heading, 1))` }}>
Настройки интерфейса
</h1>
</div>
<Button variant="ghost" onClick={() => navigate(-1)}>
Назад
</Button>
</div>
{/* Presets */}
<Panel className="p-5 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Быстрые пресеты
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{PRESETS.map((preset, i) => (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.scales)}
className={[
"rounded-[18px] border-2 px-4 py-3 text-center transition",
activePreset === i
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)] font-semibold"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
].join(" ")}
>
<div className="text-sm font-medium">{preset.label}</div>
</button>
))}
</div>
</Panel>
{/* Font size sliders */}
<Panel className="p-5 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Размеры шрифтов
</h2>
<Button size="sm" variant="ghost" onClick={resetAll}>
Сбросить
</Button>
</div>
<div className="space-y-5">
{categories.map((cat) => (
<Slider
key={cat.key}
category={cat}
value={settings[cat.key] ?? cat.defaultScale}
onChange={(v) => updateCategory(cat.key, v)}
/>
))}
</div>
{/* Preview */}
<div className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 space-y-2">
<p className="text-xs uppercase tracking-wider text-[var(--color-text-muted)]">Предпросмотр</p>
<div className="fs-zone-heading space-y-1">
<h3 className="font-bold" style={{ fontSize: `calc(1.25rem * var(--fs-scale-heading, 1))` }}>
Заголовок секции
</h3>
</div>
<div className="fs-zone-nav flex gap-2">
<span className="rounded-[14px] bg-[var(--color-accent)] px-3 py-1.5 text-[var(--color-accent-contrast)]"
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
Пункт меню
</span>
<span className="rounded-[14px] bg-[var(--color-surface)] px-3 py-1.5 border border-[var(--color-border)]"
style={{ fontSize: `calc(0.875rem * var(--fs-scale-nav, 1))` }}>
Вкладка
</span>
</div>
<div className="fs-zone-table rounded-[14px] border border-[var(--color-border)] overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-[var(--color-surface)]">
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Дата</th>
<th className="px-3 py-2 text-left font-semibold" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>Статус</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>02.07.2026</td>
<td className="px-3 py-2" style={{ fontSize: `calc(0.875rem * var(--fs-scale-table, 1))` }}>В работе</td>
</tr>
</tbody>
</table>
</div>
<div className="fs-zone-card rounded-[14px] bg-[var(--color-surface)] p-3">
<p style={{ fontSize: `calc(0.875rem * var(--fs-scale-card, 1))` }}>
Текст в карточке описание доставки или заказа.
</p>
</div>
<p className="fs-zone-body text-[var(--color-text)]" style={{ fontSize: `calc(1rem * var(--fs-scale-body, 1))` }}>
Основной текст интерфейса.
</p>
<p className="fs-zone-small text-[var(--color-text-muted)]" style={{ fontSize: `calc(0.75rem * var(--fs-scale-small, 1))` }}>
мелкая подпись · временная отметка
</p>
</div>
</Panel>
<p className="text-center text-xs text-[var(--color-text-muted)]">
Настройки сохраняются на этом устройстве
</p>
</div>
);
};
export default SettingsPage;

View File

@ -7,6 +7,7 @@ import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { ForbiddenPage } from "./pages/ForbiddenPage";
import { SettingsPage } from "./pages/SettingsPage";
import { useAuth } from "./context/AuthContext";
/**
@ -66,6 +67,14 @@ export const router = createBrowserRouter([
</RequireAuth>
),
},
{
path: "settings",
element: (
<RequireAuth>
<SettingsPage />
</RequireAuth>
),
},
{
path: "*",
element: <NotFoundPage />,

101
src/styles/fontSettings.css Normal file
View File

@ -0,0 +1,101 @@
/* Font size scale variables — applied by FontSettingsContext */
:root {
--fs-scale-table: 1.0;
--fs-scale-card: 1.0;
--fs-scale-nav: 1.0;
--fs-scale-heading: 1.0;
--fs-scale-body: 1.0;
--fs-scale-small: 1.0;
}
/* Slider styling */
.fs-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.fs-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid var(--color-surface-strong);
cursor: pointer;
transition: transform 120ms ease;
}
.fs-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.fs-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid var(--color-surface-strong);
cursor: pointer;
border: none;
}
/* Zone overrides
.fs-zone-* wrappers override Tailwind text-* classes inside them.
Specificity: .fs-zone-X .text-Y (0,2,0) > .text-Y (0,1,0).
This lets us scale fonts without patching every component.
*/
/* Table zone */
.fs-zone-table { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-xs { font-size: calc(0.75rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-sm { font-size: calc(0.875rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-base { font-size: calc(1rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-lg { font-size: calc(1.125rem * var(--fs-scale-table, 1)); }
.fs-zone-table .text-xl { font-size: calc(1.25rem * var(--fs-scale-table, 1)); }
/* Arbitrary pixel sizes used in tables */
.fs-zone-table .text-\[10px\] { font-size: calc(10px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[11px\] { font-size: calc(11px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[12px\] { font-size: calc(12px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[13px\] { font-size: calc(13px * var(--fs-scale-table, 1)); }
.fs-zone-table .text-\[14px\] { font-size: calc(14px * var(--fs-scale-table, 1)); }
/* Card zone */
.fs-zone-card { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-xs { font-size: calc(0.75rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-sm { font-size: calc(0.875rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-base { font-size: calc(1rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-lg { font-size: calc(1.125rem * var(--fs-scale-card, 1)); }
.fs-zone-card .text-\[10px\] { font-size: calc(10px * var(--fs-scale-card, 1)); }
.fs-zone-card .text-\[11px\] { font-size: calc(11px * var(--fs-scale-card, 1)); }
/* Nav zone */
.fs-zone-nav { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-xs { font-size: calc(0.75rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-sm { font-size: calc(0.875rem * var(--fs-scale-nav, 1)); }
.fs-zone-nav .text-base { font-size: calc(1rem * var(--fs-scale-nav, 1)); }
/* Heading zone */
.fs-zone-heading { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-sm { font-size: calc(0.875rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-base { font-size: calc(1rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-lg { font-size: calc(1.125rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-xl { font-size: calc(1.25rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-2xl { font-size: calc(1.5rem * var(--fs-scale-heading, 1)); }
.fs-zone-heading .text-3xl { font-size: calc(1.875rem * var(--fs-scale-heading, 1)); }
/* Small text zone */
.fs-zone-small { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
.fs-zone-small .text-xs { font-size: calc(0.75rem * var(--fs-scale-small, 1)); }
.fs-zone-small .text-sm { font-size: calc(0.875rem * var(--fs-scale-small, 1)); }
/* Body zone — LAST so table/card/nav/heading zones inside body win at equal specificity */
.fs-zone-body { font-size: calc(1rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-xs { font-size: calc(0.75rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-sm { font-size: calc(0.875rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-base { font-size: calc(1rem * var(--fs-scale-body, 1)); }
.fs-zone-body .text-lg { font-size: calc(1.125rem * var(--fs-scale-body, 1)); }