From f706726eebb8690896a3e1d8c436f0d98f1b6e32 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 28 Jun 2026 12:25:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(prefs):=20per-user=20UI=20font=20scale=20(?= =?UTF-8?q?A=E2=88=92/A+),=20saved=20to=20the=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A header A−/value/A+ control scales the whole UI, persisted per user and restored on login from any booth — cloning the theme-pref pattern end to end. - DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default). - Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a 10-step); fontScale flows through sessionView → login + /me. - Client: setFontScalePref + applyFontScale; applied in App alongside theme; FontScaleToggle in the header; i18n sq+en. Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned in px (text-[12px] etc., ~230 spots), which a font-size change would not scale — so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is the readability win for operators who need larger text. Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100). Full workspace build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/auth.ts | 26 ++++++++++ apps/server/src/routes/profile.test.ts | 54 ++++++++++++++++++++ apps/web/src/App.tsx | 10 ++-- apps/web/src/api.ts | 12 +++++ apps/web/src/lib/i18n/en.ts | 3 ++ apps/web/src/lib/i18n/sq.ts | 3 ++ apps/web/src/lib/theme.ts | 23 ++++++--- apps/web/src/router.tsx | 48 ++++++++++++++++- packages/db/drizzle/0014_user_font_scale.sql | 5 ++ packages/db/drizzle/meta/_journal.json | 7 +++ packages/db/src/schema.ts | 5 ++ wiki/log.md | 19 +++++++ 12 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 packages/db/drizzle/0014_user_font_scale.sql diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 33349d6..b47f439 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -29,6 +29,13 @@ interface ThemeBody { theme: Theme; } +// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent. +const FONT_SCALE_MIN = 80; +const FONT_SCALE_MAX = 160; +interface FontScaleBody { + fontScale: number; +} + // Self-service profile: a signed-in user edits their OWN display name + email. This is // NOT the admin user-management path (routes/users.ts) — it only ever touches the caller // (req.user.sub), needs no `user:*` permission, and can't change username, role, or any @@ -67,6 +74,7 @@ function sessionView( roleId: string; language: string; theme: string; + fontScale: number; fullName?: string | null; email?: string | null; }, @@ -81,6 +89,7 @@ function sessionView( permissions, language: user.language, theme: user.theme, + fontScale: user.fontScale, fullName: user.fullName ?? null, email: user.email ?? null, }; @@ -171,6 +180,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { }, ); + // Change MY own UI font scale (any signed-in user). Percent of base, clamped to + // [80, 160] in steps of 10. Persisted like `theme`, restored on the next login. + app.put<{ Body: FontScaleBody }>( + "/api/auth/font-scale", + { preHandler: requireAuth }, + async (req, reply) => { + const raw = req.body?.fontScale; + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return reply.code(400).send({ error: "fontScale must be a number" }); + } + // Snap to a 10-step and clamp to the allowed band (defensive — the UI already does). + const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10)); + await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run(); + return { fontScale }; + }, + ); + // Edit MY own display name / email (any signed-in user; no permission needed — it only // touches the caller). Cannot change username or role — those stay admin-only (users.ts). app.put<{ Body: ProfileBody }>( diff --git a/apps/server/src/routes/profile.test.ts b/apps/server/src/routes/profile.test.ts index 0fdadc3..b705e8c 100644 --- a/apps/server/src/routes/profile.test.ts +++ b/apps/server/src/routes/profile.test.ts @@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => { expect(res.statusCode).toBe(400); }); }); + +describe("PUT /api/auth/font-scale (self-service)", () => { + it("persists a valid scale and returns it on the next session", async () => { + const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] }); + const { cookie, csrf } = await login(app, username, password); + const res = await app.inject({ + method: "PUT", url: "/api/auth/font-scale", + headers: { cookie, "x-csrf-token": csrf }, + payload: { fontScale: 120 }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().fontScale).toBe(120); + // Persisted to the caller's row… + expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120); + // …and surfaced on /me (the session bootstrap). + const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); + expect(me.json().fontScale).toBe(120); + }); + + it("clamps + snaps out-of-band / off-step values", async () => { + const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] }); + const { cookie, csrf } = await login(app, username, password); + const tooBig = await app.inject({ + method: "PUT", url: "/api/auth/font-scale", + headers: { cookie, "x-csrf-token": csrf }, + payload: { fontScale: 999 }, + }); + expect(tooBig.json().fontScale).toBe(160); // clamped to max + const offStep = await app.inject({ + method: "PUT", url: "/api/auth/font-scale", + headers: { cookie, "x-csrf-token": csrf }, + payload: { fontScale: 113 }, + }); + expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step + }); + + it("rejects a non-numeric scale (400)", async () => { + const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] }); + const { cookie, csrf } = await login(app, username, password); + const res = await app.inject({ + method: "PUT", url: "/api/auth/font-scale", + headers: { cookie, "x-csrf-token": csrf }, + payload: { fontScale: "big" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("defaults to 100 for a fresh user", async () => { + const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] }); + const { cookie } = await login(app, username, password); + const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); + expect(me.json().fontScale).toBe(100); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index be3ab73..779d416 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js"; import { Login } from "./Login.js"; import { queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; -import { applyTheme } from "./lib/theme.js"; +import { applyTheme, applyFontScale } from "./lib/theme.js"; import { router } from "./router.js"; // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands @@ -24,15 +24,17 @@ export function App() { .finally(() => setLoading(false)); }, []); - // Apply the signed-in user's preferred language + theme whenever they resolve/ - // change (login, bootstrap, or a toggle). Albanian + dark are the defaults before - // auth resolves; on logout, fall back to dark so the Login screen is consistent. + // Apply the signed-in user's preferred language + theme + font scale whenever they + // resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults + // before auth resolves; on logout, fall back so the Login screen is consistent. useEffect(() => { if (user) { setLanguage(user.language); applyTheme(user.theme); + applyFontScale(user.fontScale); } else { applyTheme("dark"); + applyFontScale(100); } }, [user]); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index f46473f..dd260eb 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -73,6 +73,8 @@ export interface SessionUser { language: Lang; /** Preferred UI theme (loaded from the server on login). */ theme: Theme; + /** Preferred UI font scale, percent of base (100 = base; clamped 80–160). */ + fontScale: number; /** Optional display name (profile metadata); null if unset. */ fullName: string | null; /** Optional contact email (profile metadata); null if unset. */ @@ -105,6 +107,16 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> { return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) }); } +/** Allowed font-scale band (percent of base) + step. The header control clamps to these. */ +export const FONT_SCALE_MIN = 80; +export const FONT_SCALE_MAX = 160; +export const FONT_SCALE_STEP = 10; + +/** Persist the current user's UI font scale (percent; restored on next login). */ +export function setFontScalePref(fontScale: number): Promise<{ fontScale: number }> { + return apiFetch("/api/auth/font-scale", { method: "PUT", body: JSON.stringify({ fontScale }) }); +} + /** Edit MY own profile (display name / email). Returns the refreshed session. * Self-service — touches only the signed-in user; no `user:*` permission needed. */ export function updateMyProfile(patch: { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 2fef6d6..b68d06f 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -14,6 +14,9 @@ export const en: Catalog = { themeDark: "dark", themeLight: "light", theme: "Theme", + fontSmaller: "Smaller text", + fontLarger: "Larger text", + fontSize: "Text size", today: "Today", yesterday: "Yesterday", months: [ diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 21be3be..172b80e 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -14,6 +14,9 @@ export const sq = { themeDark: "errët", themeLight: "çelët", theme: "Tema", + fontSmaller: "Zvogëlo tekstin", + fontLarger: "Rrit tekstin", + fontSize: "Madhësia e tekstit", today: "Sot", yesterday: "Dje", // Month names (index 0 = January) — kept in the catalog because the appliance's diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts index 4e8c1ee..5e2997b 100644 --- a/apps/web/src/lib/theme.ts +++ b/apps/web/src/lib/theme.ts @@ -1,14 +1,25 @@ import type { Theme } from "../api.js"; +import { FONT_SCALE_MAX, FONT_SCALE_MIN } from "../api.js"; -// Theme application. The whole UI reads colour through the --color-term-* tokens; -// the light palette lives in index.css under `html.theme-light`. Applying a theme is -// just toggling that class on . The active theme is the LOGGED-IN USER's stored -// preference (users.theme), applied via applyTheme() after auth resolves — mirroring -// how language works. Dark is the default before auth resolves. Printed tickets are -// unaffected (always Albanian, dark-agnostic). +// Theme + font-scale application. The whole UI reads colour through the --color-term-* +// tokens; the light palette lives in index.css under `html.theme-light`. Applying a theme +// is just toggling that class on . Both are the LOGGED-IN USER's stored preferences +// (users.theme / users.font_scale), applied after auth resolves — mirroring how language +// works. Defaults (dark, 100%) apply before auth resolves. Printed tickets are unaffected. /** Apply a theme by toggling `theme-light` on . Dark is the absence of the * class (the base tokens). No-op-safe to call repeatedly. */ export function applyTheme(theme: Theme): void { document.documentElement.classList.toggle("theme-light", theme === "light"); } + +/** Apply a font scale as a whole-UI ZOOM (`pct`% on the root). The app's type is pinned in + * px (`text-[12px]` etc.), which a root font-size would NOT scale — `zoom` scales everything + * uniformly (text, spacing, icons), exactly like the browser's Ctrl+/−, so the feed/session + * logs grow too. Clamped to the allowed band; no-op-safe to call repeatedly. */ +export function applyFontScale(pct: number): void { + const clamped = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(pct))); + // `zoom` is supported in all the booth's target browsers (Chromium/WebKit/modern FF). + // 1 = 100%. Reset to "" at base so we don't leave an inline override lying around. + document.documentElement.style.zoom = clamped === 100 ? "" : String(clamped / 100); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 5eb57f0..a78d7fd 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -10,11 +10,23 @@ import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { Lang, Permission, SessionUser, Theme } from "./api.js"; -import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js"; +import { + can, + closeShift, + fetchShiftReport, + logout, + openShift, + setLanguagePref, + setThemePref, + setFontScalePref, + FONT_SCALE_MIN, + FONT_SCALE_MAX, + FONT_SCALE_STEP, +} from "./api.js"; import { qk, queryClient } from "./lib/query.js"; import { Modal } from "./ui/Modal.js"; import { setLanguage } from "./lib/i18n/index.js"; -import { applyTheme } from "./lib/theme.js"; +import { applyTheme, applyFontScale } from "./lib/theme.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; import { useShift } from "./lib/use-shift.js"; import { DeviceFooter } from "./ui/DeviceFooter.js"; @@ -210,6 +222,37 @@ function ThemeToggle({ ); } +/** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted + * to the user profile like the theme, restored on next login. Local `active` state seeded + * from the prop (the router context doesn't re-render on setUser); App's effect keeps the + * DOM in sync with the persisted user on (re)login. */ +function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) { + const { t } = useTranslation(); + const [active, setActive] = useState(user.fontScale); + function step(delta: number) { + const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta)); + if (next === active) return; + setActive(next); + applyFontScale(next); // instant UI + setUser({ ...user, fontScale: next }); + void setFontScalePref(next).catch(() => { + /* non-fatal — the choice still applies this session */ + }); + } + const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40"; + return ( +
+ + {active}% + +
+ ); +} + /** * Header shift control — the site-wide single-open shift expressed as one button: * - no shift open → "Open shift" (enabled; opens this operator's shift) @@ -399,6 +442,7 @@ function RootLayout() { {user && } {user && } {user && } + {user && } {user && (