feat(web): group top nav, add mobile drawer and app-wide text size control
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m37s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s

The appbar had grown to 11 flat links with no responsive behaviour, and
overflowed below ~1100px.

Nav is now 7 top-level entries: Inicio, Clientes, Pólizas, Propiedades,
Reportes stay one click away, while the movement screens (Captura, Estado de
cuenta, Chequera) and the admin screens (Catálogos, Usuarios, Operaciones)
collapse into "Cobranza" and "Admin" dropdowns. Groups are ability-filtered
and disappear entirely when the user can see none of their items, so VIEWER
never renders an empty Admin menu. activeHref now scans the flattened link
list, and a group trigger highlights while one of its children is current.

Below 980px the nav collapses to a burger drawer that lists every group
expanded, closing on navigation and on Escape.

Text size is user-adjustable app-wide. Every font-size in globals.css is
converted from px to rem (mechanically, 133 declarations) and the root size
becomes calc(100% * var(--ui-scale)), so one variable on <html> rescales the
whole UI. The preference persists in localStorage and is applied by a
pre-hydration script in layout.tsx to avoid a flash at the default size; the
Aa control lives in the appbar and, as a segmented row, in the drawer.
Spacing stays in px by design, which is why 1.3 is the largest preset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 22:16:30 -07:00
co-authored by Claude Opus 5
parent 7df928c3ab
commit 0bf97e6d2c
5 changed files with 787 additions and 161 deletions
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
UI_SCALES,
applyUiScale,
readUiScale,
saveUiScale,
} from "@/lib/ui-scale";
/**
* Text-size picker. Writes --ui-scale on <html>; because every font-size in
* globals.css is in rem, that rescales the whole app at once.
*
* `variant="menu"` is the compact appbar popover; `variant="inline"` is the
* flat row used inside the mobile drawer, where a popover inside a popover
* would be awkward.
*/
export function FontScaleControl({
variant = "menu",
}: {
variant?: "menu" | "inline";
}) {
const [scale, setScale] = useState<number | null>(null);
const [open, setOpen] = useState(false);
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(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
function choose(value: number) {
setScale(value);
applyUiScale(value);
saveUiScale(value);
setOpen(false);
}
const options = UI_SCALES.map((option) => ({
...option,
selected: scale !== null && Math.abs(scale - option.value) < 0.001,
}));
if (variant === "inline") {
return (
<div className="scale-inline" role="radiogroup" aria-label="Tamaño de texto">
<span className="appbar-drawer-heading">Tamaño de texto</span>
<div className="scale-inline-options">
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={option.selected}
className={`scale-chip${option.selected ? " active" : ""}`}
style={{ fontSize: `${option.value}em` }}
onClick={() => choose(option.value)}
>
{option.short}
<span className="sr-only"> {option.label}</span>
</button>
))}
</div>
</div>
);
}
return (
<div className="appbar-menu" ref={ref}>
<button
type="button"
className="appbar-scale-trigger"
aria-expanded={open}
aria-haspopup="true"
aria-label="Tamaño de texto"
title="Tamaño de texto"
onClick={() => setOpen((v) => !v)}
>
<span aria-hidden="true">
A<span className="appbar-scale-big">A</span>
</span>
</button>
{open && (
<div className="appbar-dropdown appbar-dropdown-right" role="menu">
{options.map((option) => (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={option.selected}
className={`appbar-dropdown-link scale-option${option.selected ? " active" : ""}`}
onClick={() => choose(option.value)}
>
<span style={{ fontSize: `${option.value}em` }}>{option.short}</span>
<span className="scale-option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
);
}