feat(web,api): scale spacing with text size, persist preference per account
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m13s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m16s

Two follow-ups to the text-size control.

Spacing now scales with the text. All padding, margin, gap and min-height
declarations in globals.css move from px to rem (263 declarations, converted
mechanically), so --ui-scale drives the whole layout rather than just the
glyphs. Deliberately left in px: border widths, which must stay hairlines;
box-shadow offsets; border-radius, which reads as bloated when scaled on large
cards; --shell-max, a container cap that must not outgrow the viewport; and
media-query breakpoints, which are conditions rather than declarations. With
spacing following along, the presets gain a 1.5 "Máximo" step and MAX_UI_SCALE
rises from 1.4.

The preference now lives on the account instead of only in one browser.
User.uiScale (Float, default 1) is added to the schema and to the safe select,
so it rides along on /auth/login and /auth/me. PATCH /auth/preferences writes
it, guarded by AuthenticatedGuard only — every role including VIEWER may set
their own, and the target is always the session's user id, never a body
parameter, so this cannot be used to touch another account. The global
ValidationPipe's whitelist rejects any extra field, so role cannot ride in
alongside uiScale.

localStorage stays, demoted to a pre-paint cache for the layout.tsx script;
AppShell reconciles it against the account once /auth/me answers, with the
account winning. FontScaleControl becomes a controlled component since the
same value is now edited from the appbar and the drawer.

Verified against the dev API: PATCH persists and is reflected by a subsequent
/auth/me, out-of-range values are rejected 400, and an extra "role" field in
the body is rejected 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 22:30:06 -07:00
co-authored by Claude Opus 5
parent 0bf97e6d2c
commit c100dfa224
11 changed files with 405 additions and 300 deletions
+28 -1
View File
@@ -1,9 +1,21 @@
import { Controller, Get, HttpCode, Post, Req, Res, UseGuards } from "@nestjs/common"; import {
Body,
Controller,
Get,
HttpCode,
Patch,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { LocalAuthGuard } from "./local-auth.guard"; import { LocalAuthGuard } from "./local-auth.guard";
import { AuthenticatedGuard } from "./authenticated.guard"; import { AuthenticatedGuard } from "./authenticated.guard";
import { LoginDto } from "./login.dto"; import { LoginDto } from "./login.dto";
import { UpdatePreferencesDto } from "./update-preferences.dto";
import { abilitiesFor, Role } from "./abilities"; import { abilitiesFor, Role } from "./abilities";
import { UsersService } from "../users/users.service";
/** Attach the resolved ability map so the web can gate its UI off one payload. */ /** Attach the resolved ability map so the web can gate its UI off one payload. */
function withAbilities(user: unknown) { function withAbilities(user: unknown) {
@@ -14,6 +26,8 @@ function withAbilities(user: unknown) {
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor(private readonly users: UsersService) {}
// LoginDto is only used for request-shape documentation/validation here — // LoginDto is only used for request-shape documentation/validation here —
// the actual credential check happens inside LocalStrategy via Passport, // the actual credential check happens inside LocalStrategy via Passport,
// which populates req.user before this handler runs. // which populates req.user before this handler runs.
@@ -30,6 +44,19 @@ export class AuthController {
return withAbilities(req.user); return withAbilities(req.user);
} }
/**
* Update the caller's own UI preferences. Deliberately not on /users/:id —
* that controller is ADMIN-only, and this has to work for every role. The
* target is always the session's own user id, never a body parameter.
*/
@UseGuards(AuthenticatedGuard)
@Patch("preferences")
async updatePreferences(@Req() req: Request, @Body() dto: UpdatePreferencesDto) {
const id = (req.user as { id: string }).id;
const user = await this.users.updatePreferences(id, dto.uiScale);
return withAbilities(user);
}
@Post("logout") @Post("logout")
@HttpCode(200) @HttpCode(200)
logout(@Req() req: Request) { logout(@Req() req: Request) {
@@ -0,0 +1,17 @@
import { IsNumber, Max, Min } from "class-validator";
/**
* Self-service UI preferences — any authenticated user may set these on their
* own account, including VIEWER. No ability gate: it changes nothing but how
* the app looks to that one person.
*
* The bounds mirror MIN_UI_SCALE/MAX_UI_SCALE in apps/web/src/lib/ui-scale.ts;
* keep them in sync. The API clamps rather than trusting the client because
* this endpoint is reachable outside the UI.
*/
export class UpdatePreferencesDto {
@IsNumber()
@Min(0.9)
@Max(1.5)
uiScale!: number;
}
+13
View File
@@ -18,6 +18,7 @@ const safeSelect = {
email: true, email: true,
role: true, role: true,
active: true, active: true,
uiScale: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} satisfies Prisma.UserSelect; } satisfies Prisma.UserSelect;
@@ -101,6 +102,18 @@ export class UsersService {
} }
} }
/**
* Self-service preference write — no ability check, because the only account
* it can touch is the caller's own (the controller passes the session id).
*/
updatePreferences(id: string, uiScale: number): Promise<SafeUserRow> {
return this.prisma.user.update({
where: { id },
data: { uiScale },
select: safeSelect,
});
}
async resetPassword(id: string, password: string): Promise<SafeUserRow> { async resetPassword(id: string, password: string): Promise<SafeUserRow> {
await this.ensureExists(id); await this.ensureExists(id);
const passwordHash = await argon2.hash(password); const passwordHash = await argon2.hash(password);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -38,7 +38,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
__html: __html:
`try{var s=parseFloat(localStorage.getItem("jc.ui-scale"));` + `try{var s=parseFloat(localStorage.getItem("jc.ui-scale"));` +
`if(isFinite(s))document.documentElement.style.setProperty(` + `if(isFinite(s))document.documentElement.style.setProperty(` +
`"--ui-scale",String(Math.min(1.4,Math.max(0.9,s))));}catch(e){}`, `"--ui-scale",String(Math.min(1.5,Math.max(0.9,s))));}catch(e){}`,
}} }}
/> />
{/* Google Fonts via <link> so an offline build still runs with the {/* Google Fonts via <link> so an offline build still runs with the
+42 -5
View File
@@ -3,9 +3,16 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { logout, me } from "@/lib/api"; import { logout, me, updateUiScale } from "@/lib/api";
import { AuthContext, can } from "@/lib/abilities"; import { AuthContext, can } from "@/lib/abilities";
import { ROLE_LABEL } from "@/lib/labels"; import { ROLE_LABEL } from "@/lib/labels";
import {
DEFAULT_UI_SCALE,
applyUiScale,
normalizeUiScale,
readUiScale,
saveUiScale,
} from "@/lib/ui-scale";
import { FontScaleControl } from "./FontScaleControl"; import { FontScaleControl } from "./FontScaleControl";
import type { AuthUser, Ability } from "@/lib/types"; import type { AuthUser, Ability } from "@/lib/types";
@@ -176,6 +183,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE);
const current = activeHref(pathname); const current = activeHref(pathname);
const nav = visibleNav(user); const nav = visibleNav(user);
@@ -183,10 +191,17 @@ export function AppShell({ children }: { children: ReactNode }) {
let alive = true; let alive = true;
me() me()
.then((u) => { .then((u) => {
if (alive) { if (!alive) return;
setUser(u); setUser(u);
setChecking(false); setChecking(false);
} // The account wins over the localStorage copy the pre-hydration script
// painted with: that copy is this browser's, while the account follows
// the person between machines. Re-save so the next cold paint here is
// already correct.
const accountScale = normalizeUiScale(u.uiScale ?? DEFAULT_UI_SCALE);
setUiScale(accountScale);
applyUiScale(accountScale);
saveUiScale(accountScale);
}) })
.catch(() => { .catch(() => {
router.replace("/login"); router.replace("/login");
@@ -196,6 +211,24 @@ export function AppShell({ children }: { children: ReactNode }) {
}; };
}, [router]); }, [router]);
// Before /auth/me answers, show whatever the pre-hydration script applied so
// the control isn't briefly out of step with the page.
useEffect(() => {
setUiScale(readUiScale());
}, []);
function changeUiScale(next: number) {
setUiScale(next);
applyUiScale(next);
saveUiScale(next);
setUser((prev) => (prev ? { ...prev, uiScale: next } : prev));
// Fire and forget: the change is already applied and cached locally, so a
// failed write only means it won't follow the user to another machine.
updateUiScale(next).catch(() => {
/* ignore */
});
}
// Navigating away closes the mobile drawer — the route change is the only // Navigating away closes the mobile drawer — the route change is the only
// "done" signal we get from a <Link>. // "done" signal we get from a <Link>.
useEffect(() => { useEffect(() => {
@@ -275,7 +308,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</nav> </nav>
<span className="appbar-spacer" /> <span className="appbar-spacer" />
<div className="appbar-user"> <div className="appbar-user">
<FontScaleControl /> <FontScaleControl value={uiScale} onChange={changeUiScale} />
{user && ( {user && (
<span className="appbar-user-name"> <span className="appbar-user-name">
{user.name} {user.name}
@@ -329,7 +362,11 @@ export function AppShell({ children }: { children: ReactNode }) {
</div> </div>
), ),
)} )}
<FontScaleControl variant="inline" /> <FontScaleControl
value={uiScale}
onChange={changeUiScale}
variant="inline"
/>
{user && ( {user && (
<div className="appbar-drawer-user"> <div className="appbar-drawer-user">
{user.name} · {ROLE_LABEL[user.role]} {user.name} · {ROLE_LABEL[user.role]}
+12 -20
View File
@@ -1,36 +1,30 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { UI_SCALES } from "@/lib/ui-scale";
UI_SCALES,
applyUiScale,
readUiScale,
saveUiScale,
} from "@/lib/ui-scale";
/** /**
* Text-size picker. Writes --ui-scale on <html>; because every font-size in * Text-size picker. Controlled: AppShell owns the value and handles applying
* globals.css is in rem, that rescales the whole app at once. * and persisting it, because the same setting is edited from two places (the
* appbar popover and the mobile drawer) and reconciled against the account on
* load.
* *
* `variant="menu"` is the compact appbar popover; `variant="inline"` is the * `variant="menu"` is the compact appbar popover; `variant="inline"` is the
* flat row used inside the mobile drawer, where a popover inside a popover * flat row used inside the mobile drawer, where a popover inside a popover
* would be awkward. * would be awkward.
*/ */
export function FontScaleControl({ export function FontScaleControl({
value,
onChange,
variant = "menu", variant = "menu",
}: { }: {
value: number;
onChange: (scale: number) => void;
variant?: "menu" | "inline"; variant?: "menu" | "inline";
}) { }) {
const [scale, setScale] = useState<number | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// Read after mount only: localStorage doesn't exist during SSR, and rendering
// a guessed value would mismatch the pre-hydration script's.
useEffect(() => {
setScale(readUiScale());
}, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onPointerDown(event: MouseEvent) { function onPointerDown(event: MouseEvent) {
@@ -49,16 +43,14 @@ export function FontScaleControl({
}; };
}, [open]); }, [open]);
function choose(value: number) { function choose(next: number) {
setScale(value); onChange(next);
applyUiScale(value);
saveUiScale(value);
setOpen(false); setOpen(false);
} }
const options = UI_SCALES.map((option) => ({ const options = UI_SCALES.map((option) => ({
...option, ...option,
selected: scale !== null && Math.abs(scale - option.value) < 0.001, selected: Math.abs(value - option.value) < 0.001,
})); }));
if (variant === "inline") { if (variant === "inline") {
+8
View File
@@ -136,6 +136,14 @@ export function me(): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/me"); return apiFetch<AuthUser>("/auth/me");
} }
/** Persist the caller's own text-size preference on their account. */
export function updateUiScale(uiScale: number): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/preferences", {
method: "PATCH",
body: JSON.stringify({ uiScale }),
});
}
export function logout(): Promise<{ success: boolean }> { export function logout(): Promise<{ success: boolean }> {
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" }); return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
} }
+4
View File
@@ -29,6 +29,10 @@ export interface AuthUser {
email: string; email: string;
role: Role; role: Role;
active: boolean; active: boolean;
// Text-size preference, stored per account so it follows the person across
// machines. localStorage still holds a copy, but only as a pre-paint cache —
// this value is the source of truth. See lib/ui-scale.ts.
uiScale: number;
// Resolved server-side from role (abilitiesFor in the API); the UI only ever // Resolved server-side from role (abilitiesFor in the API); the UI only ever
// reads this map, never re-derives the rules. Server still enforces. // reads this map, never re-derives the rules. Server still enforces.
abilities: Record<Ability, boolean>; abilities: Record<Ability, boolean>;
+11 -8
View File
@@ -1,22 +1,25 @@
// App-wide text size. Every font-size in globals.css is in rem and the root // App-wide text size. Every font-size *and* every spacing value in globals.css
// size is `calc(100% * var(--ui-scale))`, so writing one variable on <html> // is in rem and the root size is `calc(100% * var(--ui-scale))`, so writing one
// rescales the entire UI — no per-component work, and the browser's own base // variable on <html> rescales the entire UI — no per-component work, and the
// font size still applies underneath. // browser's own base font size still applies underneath.
// //
// The value lives in localStorage (per browser, per machine) and is applied by // The account is the source of truth (User.uiScale, served on /auth/me).
// a pre-hydration script in app/layout.tsx so the page never paints at the // localStorage holds a copy purely so the pre-hydration script in
// wrong size first. Keep UI_SCALE_KEY and the bounds in sync with that script. // app/layout.tsx can paint at the right size before the session is known;
// AppShell reconciles the two once /auth/me answers. Keep UI_SCALE_KEY and the
// bounds in sync with that script and with the API's UpdatePreferencesDto.
export const UI_SCALE_KEY = "jc.ui-scale"; export const UI_SCALE_KEY = "jc.ui-scale";
export const DEFAULT_UI_SCALE = 1; export const DEFAULT_UI_SCALE = 1;
export const MIN_UI_SCALE = 0.9; export const MIN_UI_SCALE = 0.9;
export const MAX_UI_SCALE = 1.4; export const MAX_UI_SCALE = 1.5;
export const UI_SCALES: { value: number; label: string; short: string }[] = [ export const UI_SCALES: { value: number; label: string; short: string }[] = [
{ value: 0.9, label: "Compacto", short: "A" }, { value: 0.9, label: "Compacto", short: "A" },
{ value: 1, label: "Normal", short: "A" }, { value: 1, label: "Normal", short: "A" },
{ value: 1.15, label: "Grande", short: "A" }, { value: 1.15, label: "Grande", short: "A" },
{ value: 1.3, label: "Muy grande", short: "A" }, { value: 1.3, label: "Muy grande", short: "A" },
{ value: 1.5, label: "Máximo", short: "A" },
]; ];
/** Clamp to the supported range; anything unparseable falls back to default. */ /** Clamp to the supported range; anything unparseable falls back to default. */
+4
View File
@@ -546,6 +546,10 @@ model User {
passwordHash String passwordHash String
role UserRole @default(STAFF) role UserRole @default(STAFF)
active Boolean @default(true) active Boolean @default(true)
// UI text-size preference, so it follows the person between machines
// instead of living only in one browser's localStorage. Range is clamped
// API-side (see UpdatePreferencesDto) to match the web's presets.
uiScale Float @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
activityLogs ActivityLog[] activityLogs ActivityLog[]