+
+ {drawerOpen && (
+
+ )}
{children}
diff --git a/apps/web/src/components/FontScaleControl.tsx b/apps/web/src/components/FontScaleControl.tsx
new file mode 100644
index 0000000..7bd4d9c
--- /dev/null
+++ b/apps/web/src/components/FontScaleControl.tsx
@@ -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 ; 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(null);
+ const [open, setOpen] = useState(false);
+ const ref = useRef(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 (
+
+ Tamaño de texto
+
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {open && (
+
+ {options.map((option) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/ui-scale.ts b/apps/web/src/lib/ui-scale.ts
new file mode 100644
index 0000000..fd3f049
--- /dev/null
+++ b/apps/web/src/lib/ui-scale.ts
@@ -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
+// 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 */
+ }
+}