feat(web): group top nav, add mobile drawer and app-wide text size control
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:
+386
-130
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,17 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`,
|
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Text-size preference, applied before first paint so the page never
|
||||||
|
flashes at the default size. Mirrors lib/ui-scale.ts — keep the key
|
||||||
|
and the clamp in sync with it. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
`try{var s=parseFloat(localStorage.getItem("jc.ui-scale"));` +
|
||||||
|
`if(isFinite(s))document.documentElement.style.setProperty(` +
|
||||||
|
`"--ui-scale",String(Math.min(1.4,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
|
||||||
system fallback stacks defined in globals.css. */}
|
system fallback stacks defined in globals.css. */}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, 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 } 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 { FontScaleControl } from "./FontScaleControl";
|
||||||
import type { AuthUser, Ability } from "@/lib/types";
|
import type { AuthUser, Ability } from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,23 +15,66 @@ import type { AuthUser, Ability } from "@/lib/types";
|
|||||||
* content. Provides the AuthContext so any page can read the user's
|
* content. Provides the AuthContext so any page can read the user's
|
||||||
* abilities. Used by every authenticated page.
|
* abilities. Used by every authenticated page.
|
||||||
*/
|
*/
|
||||||
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
|
|
||||||
{ href: "/inicio", label: "Inicio", exact: true },
|
type NavLink = { href: string; label: string; ability?: Ability; exact?: boolean };
|
||||||
{ href: "/clientes", label: "Clientes" },
|
type NavEntry =
|
||||||
{ href: "/servicios", label: "Propiedades" },
|
| ({ kind: "link" } & NavLink)
|
||||||
{ href: "/polizas", label: "Pólizas" },
|
| { kind: "group"; label: string; items: NavLink[] };
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
|
||||||
// Daily data-entry screen (the legacy "Editor"), so it earns a top-level
|
/**
|
||||||
// entry rather than living one click inside the Movimientos tab. Hidden from
|
* Top nav. Daily screens stay one click away; the movement screens and the
|
||||||
// VIEWER, who can't capture anyway — the page itself also refuses.
|
* admin screens are grouped behind menus so the bar doesn't saturate as the
|
||||||
|
* app grows. A group disappears entirely when the user can't see any of its
|
||||||
|
* items (gating here is cosmetic — the API enforces every write).
|
||||||
|
*/
|
||||||
|
const NAV: NavEntry[] = [
|
||||||
|
{ kind: "link", href: "/inicio", label: "Inicio", exact: true },
|
||||||
|
{ kind: "link", href: "/clientes", label: "Clientes" },
|
||||||
|
{ kind: "link", href: "/polizas", label: "Pólizas" },
|
||||||
|
{ kind: "link", href: "/servicios", label: "Propiedades" },
|
||||||
|
{
|
||||||
|
kind: "group",
|
||||||
|
label: "Cobranza",
|
||||||
|
items: [
|
||||||
|
// Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who
|
||||||
|
// can't capture anyway — the page itself also refuses.
|
||||||
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
|
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
|
||||||
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
{ href: "/reportes", label: "Reportes" },
|
],
|
||||||
|
},
|
||||||
|
{ kind: "link", href: "/reportes", label: "Reportes" },
|
||||||
|
{
|
||||||
|
kind: "group",
|
||||||
|
label: "Admin",
|
||||||
|
items: [
|
||||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Every nav destination, flattened out of the groups. */
|
||||||
|
const NAV_LINKS: NavLink[] = NAV.flatMap((entry) =>
|
||||||
|
entry.kind === "link" ? [entry] : entry.items,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The nav the given user may see, with empty groups dropped. */
|
||||||
|
function visibleNav(user: AuthUser | null): NavEntry[] {
|
||||||
|
const allowed = (item: NavLink) => !item.ability || can(user, item.ability);
|
||||||
|
const out: NavEntry[] = [];
|
||||||
|
for (const entry of NAV) {
|
||||||
|
if (entry.kind === "link") {
|
||||||
|
if (allowed(entry)) out.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = entry.items.filter(allowed);
|
||||||
|
if (items.length > 0) out.push({ ...entry, items });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which nav entry is highlighted for a path. Longest matching href wins, so a
|
* Which nav entry is highlighted for a path. Longest matching href wins, so a
|
||||||
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
|
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
|
||||||
@@ -40,7 +84,7 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
|
|||||||
function activeHref(pathname: string | null): string | null {
|
function activeHref(pathname: string | null): string | null {
|
||||||
if (!pathname) return null;
|
if (!pathname) return null;
|
||||||
let best: string | null = null;
|
let best: string | null = null;
|
||||||
for (const item of NAV) {
|
for (const item of NAV_LINKS) {
|
||||||
const match = item.exact
|
const match = item.exact
|
||||||
? pathname === item.href
|
? pathname === item.href
|
||||||
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
@@ -51,13 +95,89 @@ function activeHref(pathname: string | null): string | null {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One collapsible group in the desktop bar. Opens on click, closes on outside
|
||||||
|
* click, Escape, or navigation. The trigger stays highlighted while any of its
|
||||||
|
* children is the current page.
|
||||||
|
*/
|
||||||
|
function NavMenu({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
current,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
items: NavLink[];
|
||||||
|
current: string | null;
|
||||||
|
pathname: string | null;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const holdsCurrent = items.some((item) => item.href === current);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="appbar-menu" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`appbar-link appbar-menu-trigger${holdsCurrent ? " active" : ""}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="appbar-caret" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="appbar-dropdown" role="menu">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
role="menuitem"
|
||||||
|
className={`appbar-dropdown-link${current === item.href ? " active" : ""}`}
|
||||||
|
aria-current={current === item.href ? "page" : undefined}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
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 current = activeHref(pathname);
|
const current = activeHref(pathname);
|
||||||
|
const nav = visibleNav(user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -76,6 +196,21 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Navigating away closes the mobile drawer — the route change is the only
|
||||||
|
// "done" signal we get from a <Link>.
|
||||||
|
useEffect(() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawerOpen) return;
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [drawerOpen]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
@@ -117,24 +252,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="appbar-nav" aria-label="Principal">
|
<nav className="appbar-nav" aria-label="Principal">
|
||||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
{nav.map((entry) =>
|
||||||
(item) => {
|
entry.kind === "link" ? (
|
||||||
const active = current === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={entry.href}
|
||||||
href={item.href}
|
href={entry.href}
|
||||||
className={`appbar-link${active ? " active" : ""}`}
|
className={`appbar-link${current === entry.href ? " active" : ""}`}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={current === entry.href ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{item.label}
|
{entry.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
) : (
|
||||||
},
|
<NavMenu
|
||||||
|
key={entry.label}
|
||||||
|
label={entry.label}
|
||||||
|
items={entry.items}
|
||||||
|
current={current}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="appbar-spacer" />
|
<span className="appbar-spacer" />
|
||||||
<div className="appbar-user">
|
<div className="appbar-user">
|
||||||
|
<FontScaleControl />
|
||||||
{user && (
|
{user && (
|
||||||
<span className="appbar-user-name">
|
<span className="appbar-user-name">
|
||||||
{user.name}
|
{user.name}
|
||||||
@@ -150,7 +291,52 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="appbar-burger"
|
||||||
|
aria-label={drawerOpen ? "Cerrar menú" : "Abrir menú"}
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
onClick={() => setDrawerOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className={`burger-icon${drawerOpen ? " open" : ""}`} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{drawerOpen && (
|
||||||
|
<nav className="appbar-drawer" aria-label="Principal (móvil)">
|
||||||
|
{nav.map((entry) =>
|
||||||
|
entry.kind === "link" ? (
|
||||||
|
<Link
|
||||||
|
key={entry.href}
|
||||||
|
href={entry.href}
|
||||||
|
className={`appbar-drawer-link${current === entry.href ? " active" : ""}`}
|
||||||
|
aria-current={current === entry.href ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div key={entry.label} className="appbar-drawer-group">
|
||||||
|
<span className="appbar-drawer-heading">{entry.label}</span>
|
||||||
|
{entry.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`appbar-drawer-link${current === item.href ? " active" : ""}`}
|
||||||
|
aria-current={current === item.href ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<FontScaleControl variant="inline" />
|
||||||
|
{user && (
|
||||||
|
<div className="appbar-drawer-user">
|
||||||
|
{user.name} · {ROLE_LABEL[user.role]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="shell-main">{children}</main>
|
<main className="shell-main">{children}</main>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// App-wide text size. Every font-size in globals.css is in rem and the root
|
||||||
|
// size is `calc(100% * var(--ui-scale))`, so writing one variable on <html>
|
||||||
|
// rescales the entire UI — no per-component work, and the browser's own base
|
||||||
|
// font size still applies underneath.
|
||||||
|
//
|
||||||
|
// The value lives in localStorage (per browser, per machine) and is applied by
|
||||||
|
// a pre-hydration script in app/layout.tsx so the page never paints at the
|
||||||
|
// wrong size first. Keep UI_SCALE_KEY and the bounds in sync with that script.
|
||||||
|
|
||||||
|
export const UI_SCALE_KEY = "jc.ui-scale";
|
||||||
|
export const DEFAULT_UI_SCALE = 1;
|
||||||
|
export const MIN_UI_SCALE = 0.9;
|
||||||
|
export const MAX_UI_SCALE = 1.4;
|
||||||
|
|
||||||
|
export const UI_SCALES: { value: number; label: string; short: string }[] = [
|
||||||
|
{ value: 0.9, label: "Compacto", short: "A" },
|
||||||
|
{ value: 1, label: "Normal", short: "A" },
|
||||||
|
{ value: 1.15, label: "Grande", short: "A" },
|
||||||
|
{ value: 1.3, label: "Muy grande", short: "A" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Clamp to the supported range; anything unparseable falls back to default. */
|
||||||
|
export function normalizeUiScale(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number.parseFloat(String(value));
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_UI_SCALE;
|
||||||
|
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUiScale(): number {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_UI_SCALE;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(UI_SCALE_KEY);
|
||||||
|
return raw === null ? DEFAULT_UI_SCALE : normalizeUiScale(raw);
|
||||||
|
} catch {
|
||||||
|
// Private mode / storage disabled — the default is still usable.
|
||||||
|
return DEFAULT_UI_SCALE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUiScale(scale: number): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveUiScale(scale: number): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(UI_SCALE_KEY, String(scale));
|
||||||
|
} catch {
|
||||||
|
/* ignore — the setting just won't survive a reload */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user