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
+44 -7
View File
@@ -3,9 +3,16 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
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 { ROLE_LABEL } from "@/lib/labels";
import {
DEFAULT_UI_SCALE,
applyUiScale,
normalizeUiScale,
readUiScale,
saveUiScale,
} from "@/lib/ui-scale";
import { FontScaleControl } from "./FontScaleControl";
import type { AuthUser, Ability } from "@/lib/types";
@@ -176,6 +183,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const [checking, setChecking] = useState(true);
const [loggingOut, setLoggingOut] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE);
const current = activeHref(pathname);
const nav = visibleNav(user);
@@ -183,10 +191,17 @@ export function AppShell({ children }: { children: ReactNode }) {
let alive = true;
me()
.then((u) => {
if (alive) {
setUser(u);
setChecking(false);
}
if (!alive) return;
setUser(u);
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(() => {
router.replace("/login");
@@ -196,6 +211,24 @@ export function AppShell({ children }: { children: ReactNode }) {
};
}, [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
// "done" signal we get from a <Link>.
useEffect(() => {
@@ -275,7 +308,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</nav>
<span className="appbar-spacer" />
<div className="appbar-user">
<FontScaleControl />
<FontScaleControl value={uiScale} onChange={changeUiScale} />
{user && (
<span className="appbar-user-name">
{user.name}
@@ -329,7 +362,11 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
),
)}
<FontScaleControl variant="inline" />
<FontScaleControl
value={uiScale}
onChange={changeUiScale}
variant="inline"
/>
{user && (
<div className="appbar-drawer-user">
{user.name} · {ROLE_LABEL[user.role]}