From c100dfa224233e9fc1593e62eccea85bc2fab18e Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Mon, 27 Jul 2026 22:30:06 -0700 Subject: [PATCH] feat(web,api): scale spacing with text size, persist preference per account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/auth/auth.controller.ts | 29 +- apps/api/src/auth/update-preferences.dto.ts | 17 + apps/api/src/users/users.service.ts | 13 + apps/web/src/app/globals.css | 526 +++++++++---------- apps/web/src/app/layout.tsx | 2 +- apps/web/src/components/AppShell.tsx | 51 +- apps/web/src/components/FontScaleControl.tsx | 32 +- apps/web/src/lib/api.ts | 8 + apps/web/src/lib/types.ts | 4 + apps/web/src/lib/ui-scale.ts | 19 +- packages/database/prisma/schema.prisma | 4 + 11 files changed, 405 insertions(+), 300 deletions(-) create mode 100644 apps/api/src/auth/update-preferences.dto.ts diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index f0403e1..70687c7 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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 { LocalAuthGuard } from "./local-auth.guard"; import { AuthenticatedGuard } from "./authenticated.guard"; import { LoginDto } from "./login.dto"; +import { UpdatePreferencesDto } from "./update-preferences.dto"; 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. */ function withAbilities(user: unknown) { @@ -14,6 +26,8 @@ function withAbilities(user: unknown) { @Controller("auth") export class AuthController { + constructor(private readonly users: UsersService) {} + // LoginDto is only used for request-shape documentation/validation here — // the actual credential check happens inside LocalStrategy via Passport, // which populates req.user before this handler runs. @@ -30,6 +44,19 @@ export class AuthController { 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") @HttpCode(200) logout(@Req() req: Request) { diff --git a/apps/api/src/auth/update-preferences.dto.ts b/apps/api/src/auth/update-preferences.dto.ts new file mode 100644 index 0000000..70fc969 --- /dev/null +++ b/apps/api/src/auth/update-preferences.dto.ts @@ -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; +} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index c8907a7..e6cc936 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -18,6 +18,7 @@ const safeSelect = { email: true, role: true, active: true, + uiScale: true, createdAt: true, updatedAt: true, } 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 { + return this.prisma.user.update({ + where: { id }, + data: { uiScale }, + select: safeSelect, + }); + } + async resetPassword(id: string, password: string): Promise { await this.ensureExists(id); const passwordHash = await argon2.hash(password); diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 732b716..be1eb62 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -154,11 +154,11 @@ button { .shell-main { max-width: var(--shell-max); margin: 0 auto; - padding: 34px 28px 96px; + padding: 2.125rem 1.75rem 6rem; } @media (max-width: 640px) { .shell-main { - padding: 22px 16px 72px; + padding: 1.375rem 1rem 4.5rem; } } @@ -189,23 +189,23 @@ button { .appbar-inner { max-width: var(--shell-max); margin: 0 auto; - padding: 0 28px; + padding: 0 1.75rem; /* min-height, not height: the bar has to grow when the user scales text up. */ min-height: 3.875rem; display: flex; align-items: center; - gap: 26px; + gap: 1.625rem; } @media (max-width: 640px) { .appbar-inner { - padding: 0 16px; - gap: 14px; + padding: 0 1rem; + gap: 0.875rem; } } .brand { display: flex; align-items: center; - gap: 12px; + gap: 0.75rem; color: #f6f3ec; text-decoration: none; } @@ -219,7 +219,7 @@ button { background: #fbf8f0; border: 1px solid rgba(255, 255, 255, 0.18); object-fit: contain; - padding: 3px; + padding: 0.1875rem; box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.2); } .login-brand .brand-mark { @@ -252,12 +252,12 @@ button { .appbar-nav { display: flex; align-items: center; - gap: 4px; - margin-left: 8px; + gap: 0.25rem; + margin-left: 0.5rem; } .appbar-link { color: rgba(242, 239, 231, 0.78); - padding: 7px 12px; + padding: 0.4375rem 0.75rem; border-radius: 7px; font-size: 0.875rem; font-weight: 500; @@ -280,7 +280,7 @@ button { .appbar-menu-trigger { display: inline-flex; align-items: center; - gap: 6px; + gap: 0.375rem; border: 0; background: transparent; font: inherit; @@ -305,10 +305,10 @@ button { top: calc(100% + 8px); left: 0; min-width: 194px; - padding: 6px; + padding: 0.375rem; display: flex; flex-direction: column; - gap: 2px; + gap: 0.125rem; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; @@ -316,7 +316,7 @@ button { z-index: 50; } .appbar-dropdown-link { - padding: 8px 11px; + padding: 0.5rem 0.6875rem; border-radius: 7px; font-size: 0.875rem; color: var(--ink); @@ -466,25 +466,25 @@ button { .appbar-drawer { display: none; flex-direction: column; - gap: 2px; - padding: 8px 16px 16px; + gap: 0.125rem; + padding: 0.5rem 1rem 1rem; border-top: 1px solid rgba(255, 255, 255, 0.1); } .appbar-drawer-group { display: flex; flex-direction: column; - gap: 2px; - margin-top: 8px; + gap: 0.125rem; + margin-top: 0.5rem; } .appbar-drawer-heading { - padding: 6px 12px 2px; + padding: 0.375rem 0.75rem 0.125rem; font-size: 0.65625rem; letter-spacing: 0.14em; text-transform: uppercase; color: rgba(242, 239, 231, 0.45); } .appbar-drawer-link { - padding: 10px 12px; + padding: 0.625rem 0.75rem; border-radius: 8px; font-size: 0.9375rem; color: rgba(242, 239, 231, 0.82); @@ -500,8 +500,8 @@ button { font-weight: 600; } .appbar-drawer-user { - margin-top: 12px; - padding: 10px 12px 0; + margin-top: 0.75rem; + padding: 0.625rem 0.75rem 0; border-top: 1px solid rgba(255, 255, 255, 0.1); font-size: 0.78125rem; color: rgba(242, 239, 231, 0.55); @@ -520,7 +520,7 @@ button { .appbar-user { display: flex; align-items: center; - gap: 12px; + gap: 0.75rem; font-size: 0.8125rem; color: rgba(242, 239, 231, 0.7); } @@ -546,17 +546,17 @@ button { .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; + gap: 1rem; } .form-actions { display: flex; - gap: 10px; - margin-top: 18px; + gap: 0.625rem; + margin-top: 1.125rem; flex-wrap: wrap; } .row-actions { display: flex; - gap: 8px; + gap: 0.5rem; align-items: center; justify-content: flex-end; } @@ -564,25 +564,25 @@ button { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 0.75rem; flex-wrap: wrap; - margin-bottom: 8px; + margin-bottom: 0.5rem; } .inline-form-note { font-size: 0.8125rem; color: var(--muted, #6b7280); - margin: 4px 0 14px; + margin: 0.25rem 0 0.875rem; } .child-head { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 10px; + margin-bottom: 0.625rem; } .child-editor { border-top: 1px solid rgba(0, 0, 0, 0.08); - margin-top: 12px; - padding-top: 14px; + margin-top: 0.75rem; + padding-top: 0.875rem; } .picker { position: relative; @@ -591,8 +591,8 @@ button { display: flex; align-items: center; justify-content: space-between; - gap: 8px; - padding: 8px 12px; + gap: 0.5rem; + padding: 0.5rem 0.75rem; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 8px; } @@ -602,8 +602,8 @@ button { top: 100%; left: 0; right: 0; - margin: 4px 0 0; - padding: 4px; + margin: 0.25rem 0 0; + padding: 0.25rem; list-style: none; background: var(--card-bg, #fff); border: 1px solid rgba(0, 0, 0, 0.15); @@ -616,7 +616,7 @@ button { display: block; width: 100%; text-align: left; - padding: 8px 10px; + padding: 0.5rem 0.625rem; border: 0; background: transparent; border-radius: 6px; @@ -634,10 +634,10 @@ button { display: inline-flex; align-items: center; justify-content: center; - gap: 8px; + gap: 0.5rem; border-radius: var(--radius-sm); border: 1px solid transparent; - padding: 9px 16px; + padding: 0.5625rem 1rem; font-size: 0.875rem; font-weight: 600; line-height: 1; @@ -709,8 +709,8 @@ button { .badge { display: inline-flex; align-items: center; - gap: 6px; - padding: 3px 9px; + gap: 0.375rem; + padding: 0.1875rem 0.5625rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; @@ -835,14 +835,14 @@ button { ========================================================================== */ .field { display: block; - margin-bottom: 18px; + margin-bottom: 1.125rem; } .field-label { display: block; font-size: 0.8125rem; font-weight: 600; color: var(--ink-soft); - margin-bottom: 7px; + margin-bottom: 0.4375rem; } .input { width: 100%; @@ -852,7 +852,7 @@ button { background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); - padding: 11px 13px; + padding: 0.6875rem 0.8125rem; transition: border-color 0.15s, box-shadow 0.15s; } .input::placeholder { @@ -881,7 +881,7 @@ button { position: relative; background: linear-gradient(155deg, var(--brand-900) 0%, var(--brand-700) 62%, #17544a 100%); color: #f2efe7; - padding: 44px 52px; + padding: 2.75rem 3.25rem; display: flex; flex-direction: column; justify-content: space-between; @@ -903,16 +903,16 @@ button { } @media (max-width: 860px) { .login-aside { - padding: 32px 28px 40px; + padding: 2rem 1.75rem 2.5rem; } .login-aside-mid { - margin: 30px 0; + margin: 1.875rem 0; } } .login-brand { display: flex; align-items: center; - gap: 13px; + gap: 0.8125rem; position: relative; z-index: 1; } @@ -927,7 +927,7 @@ button { font-size: clamp(30px, 4.6vw, 46px); font-weight: 500; line-height: 1.08; - margin: 14px 0 20px; + margin: 0.875rem 0 1.25rem; letter-spacing: -0.015em; } .login-headline em { @@ -945,12 +945,12 @@ button { z-index: 1; display: flex; align-items: center; - gap: 16px; + gap: 1rem; flex-wrap: wrap; } .login-lob { display: flex; - gap: 8px; + gap: 0.5rem; } .login-foot-note { font-size: 0.78125rem; @@ -960,7 +960,7 @@ button { .login-form-panel { display: grid; place-items: center; - padding: 40px 28px; + padding: 2.5rem 1.75rem; background: var(--paper); } .login-form-inner { @@ -969,21 +969,21 @@ button { } .login-form-title { font-size: 1.875rem; - margin-top: 4px; + margin-top: 0.25rem; } .login-error { background: var(--negative-tint); color: var(--negative); border: 1px solid rgba(162, 58, 44, 0.28); border-radius: var(--radius-sm); - padding: 10px 13px; + padding: 0.625rem 0.8125rem; font-size: 0.84375rem; font-weight: 500; - margin-bottom: 18px; + margin-bottom: 1.125rem; } .login-submit { width: 100%; - padding: 12px 16px; + padding: 0.75rem 1rem; font-size: 0.9375rem; } @@ -991,22 +991,22 @@ button { Page heading + stats (customer list) ========================================================================== */ .page-head { - margin-bottom: 26px; + margin-bottom: 1.625rem; } .page-title { font-size: clamp(28px, 4vw, 38px); font-weight: 500; - margin: 4px 0 0; + margin: 0.25rem 0 0; } .stat-strip { display: grid; grid-template-columns: repeat(6, 1fr); - gap: 1px; + gap: 0.0625rem; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius-lg); overflow: hidden; - margin-top: 22px; + margin-top: 1.375rem; } @media (max-width: 900px) { .stat-strip { @@ -1020,7 +1020,7 @@ button { } .stat-cell { background: var(--surface); - padding: 15px 16px 14px; + padding: 0.9375rem 1rem 0.875rem; } .stat-cell.accent { background: var(--brand-tint); @@ -1041,7 +1041,7 @@ button { color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; - margin-top: 3px; + margin-top: 0.1875rem; font-weight: 600; } @@ -1050,10 +1050,10 @@ button { ========================================================================== */ .toolbar { display: flex; - gap: 14px; + gap: 0.875rem; align-items: center; flex-wrap: wrap; - margin: 26px 0 18px; + margin: 1.625rem 0 1.125rem; } .search-box { position: relative; @@ -1070,15 +1070,15 @@ button { } .search-input { width: 100%; - padding-left: 38px; + padding-left: 2.375rem; } .seg { display: inline-flex; background: var(--paper-2); border: 1px solid var(--line-strong); border-radius: 999px; - padding: 3px; - gap: 2px; + padding: 0.1875rem; + gap: 0.125rem; } .seg-btn { border: none; @@ -1086,7 +1086,7 @@ button { color: var(--muted); font-size: 0.8125rem; font-weight: 600; - padding: 7px 15px; + padding: 0.4375rem 0.9375rem; border-radius: 999px; transition: color 0.15s, background 0.15s; white-space: nowrap; @@ -1106,14 +1106,14 @@ button { } .seg-btn { flex: 1; - padding: 8px 6px; + padding: 0.5rem 0.375rem; } } .result-meta { font-size: 0.8125rem; color: var(--muted); - margin-bottom: 12px; + margin-bottom: 0.75rem; } /* ============================================================================ @@ -1122,17 +1122,17 @@ button { .cust-list { display: flex; flex-direction: column; - gap: 10px; + gap: 0.625rem; } .cust-row { display: grid; grid-template-columns: 1fr auto; - gap: 16px; + gap: 1rem; align-items: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); - padding: 15px 18px; + padding: 0.9375rem 1.125rem; text-decoration: none; color: inherit; transition: transform 0.14s var(--ease-out-quart), @@ -1155,7 +1155,7 @@ button { letter-spacing: -0.01em; display: flex; align-items: center; - gap: 10px; + gap: 0.625rem; } .cust-name .inactive-dot { width: 8px; @@ -1182,15 +1182,15 @@ button { color: var(--muted-2); border: 1px solid var(--line-strong); border-radius: 4px; - padding: 2px 6px; + padding: 0.125rem 0.375rem; white-space: nowrap; cursor: help; } .cust-sub { display: flex; flex-wrap: wrap; - gap: 6px 16px; - margin-top: 5px; + gap: 0.375rem 1rem; + margin-top: 0.3125rem; font-size: 0.8125rem; color: var(--muted); } @@ -1200,7 +1200,7 @@ button { .cust-side { display: flex; align-items: center; - gap: 8px; + gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; } @@ -1220,8 +1220,8 @@ button { display: flex; align-items: center; justify-content: center; - gap: 12px; - margin-top: 26px; + gap: 0.75rem; + margin-top: 1.625rem; } .pager-info { font-size: 0.8125rem; @@ -1235,25 +1235,25 @@ button { ========================================================================== */ .state-box { text-align: center; - padding: 60px 24px; + padding: 3.75rem 1.5rem; color: var(--muted); } .state-box h3 { color: var(--ink-soft); font-size: 1.25rem; - margin-bottom: 6px; + margin-bottom: 0.375rem; } .state-glyph { font-size: 2.125rem; opacity: 0.5; - margin-bottom: 10px; + margin-bottom: 0.625rem; } .state-error { background: var(--negative-tint); border: 1px solid rgba(162, 58, 44, 0.25); color: var(--negative); border-radius: var(--radius); - padding: 16px 18px; + padding: 1rem 1.125rem; font-size: 0.875rem; } @@ -1268,11 +1268,11 @@ button { .back-link { display: inline-flex; align-items: center; - gap: 6px; + gap: 0.375rem; font-size: 0.84375rem; font-weight: 600; color: var(--muted); - margin-bottom: 18px; + margin-bottom: 1.125rem; } .back-link:hover { color: var(--brand-700); @@ -1283,7 +1283,7 @@ button { background: linear-gradient(150deg, var(--brand-900), var(--brand-700) 88%); color: #f2efe7; border-radius: var(--radius-lg); - padding: 26px 28px; + padding: 1.625rem 1.75rem; position: relative; overflow: hidden; box-shadow: var(--shadow); @@ -1304,7 +1304,7 @@ button { display: flex; align-items: flex-start; justify-content: space-between; - gap: 18px; + gap: 1.125rem; flex-wrap: wrap; position: relative; z-index: 1; @@ -1324,12 +1324,12 @@ button { .hero-provenance { font-size: 0.78125rem; color: rgba(242, 239, 231, 0.62); - margin-top: 8px; + margin-top: 0.5rem; font-family: var(--font-mono); } .hero-badges { display: flex; - gap: 8px; + gap: 0.5rem; flex-wrap: wrap; align-items: center; } @@ -1344,9 +1344,9 @@ button { z-index: 1; display: flex; flex-wrap: wrap; - gap: 26px; - margin-top: 22px; - padding-top: 18px; + gap: 1.625rem; + margin-top: 1.375rem; + padding-top: 1.125rem; border-top: 1px solid rgba(255, 255, 255, 0.12); } .hero-fact-label { @@ -1359,7 +1359,7 @@ button { .hero-fact-value { font-size: 0.9375rem; color: #f6f3ec; - margin-top: 2px; + margin-top: 0.125rem; font-weight: 500; } @@ -1367,19 +1367,19 @@ button { Section scaffolding ========================================================================== */ .section { - margin-top: 34px; + margin-top: 2.125rem; } .section-head { display: flex; align-items: baseline; - gap: 12px; - margin-bottom: 16px; + gap: 0.75rem; + margin-bottom: 1rem; } .section-rule { width: 4px; align-self: stretch; border-radius: 3px; - min-height: 22px; + min-height: 1.375rem; } .section-rule.servicios { background: var(--servicios); @@ -1411,8 +1411,8 @@ button { .kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 18px 24px; - padding: 22px 24px; + gap: 1.125rem 1.5rem; + padding: 1.375rem 1.5rem; } .kv-label { font-size: 0.6875rem; @@ -1420,7 +1420,7 @@ button { letter-spacing: 0.06em; color: var(--muted); font-weight: 600; - margin-bottom: 3px; + margin-bottom: 0.1875rem; } .kv-value { font-size: 0.90625rem; @@ -1432,13 +1432,13 @@ button { .kv-block { grid-column: 1 / -1; border-top: 1px dashed var(--line-strong); - padding-top: 16px; - margin-top: -2px; + padding-top: 1rem; + margin-top: -0.125rem; } /* ---- property cards ---- */ .prop-card { - padding: 20px 22px; + padding: 1.25rem 1.375rem; } .prop-card + .prop-card { border-top: 1px solid var(--line); @@ -1452,23 +1452,23 @@ button { .prop-meta { font-size: 0.8125rem; color: var(--muted); - margin-top: 3px; + margin-top: 0.1875rem; display: flex; - gap: 14px; + gap: 0.875rem; flex-wrap: wrap; } .svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); - gap: 10px; - margin-top: 16px; + gap: 0.625rem; + margin-top: 1rem; } .svc-item { border: 1px solid var(--line); border-left: 3px solid var(--servicios); border-radius: var(--radius-sm); background: var(--surface-2); - padding: 11px 13px; + padding: 0.6875rem 0.8125rem; } .svc-item.inactive { opacity: 0.62; @@ -1477,7 +1477,7 @@ button { .svc-head { display: flex; align-items: center; - gap: 8px; + gap: 0.5rem; justify-content: space-between; } .svc-kind { @@ -1486,30 +1486,30 @@ button { color: var(--servicios-ink); display: flex; align-items: center; - gap: 7px; + gap: 0.4375rem; } .svc-glyph { font-size: 0.875rem; } .svc-detail { - margin-top: 7px; + margin-top: 0.4375rem; font-size: 0.75rem; color: var(--muted); display: flex; flex-direction: column; - gap: 2px; + gap: 0.125rem; } .svc-detail .mono { color: var(--ink-soft); } .trust-box { - margin-top: 16px; + margin-top: 1rem; border: 1px solid var(--fideicomiso-tint); background: linear-gradient(0deg, var(--fideicomiso-tint), transparent 140%); border-left: 3px solid var(--fideicomiso); border-radius: var(--radius-sm); - padding: 13px 15px; + padding: 0.8125rem 0.9375rem; } .trust-title { font-size: 0.78125rem; @@ -1517,18 +1517,18 @@ button { color: var(--fideicomiso-ink); text-transform: uppercase; letter-spacing: 0.04em; - margin-bottom: 8px; + margin-bottom: 0.5rem; } .trust-facts { display: flex; flex-wrap: wrap; - gap: 8px 22px; + gap: 0.5rem 1.375rem; font-size: 0.8125rem; } /* ---- policy cards ---- */ .policy-card { - padding: 20px 22px; + padding: 1.25rem 1.375rem; } .policy-card + .policy-card { border-top: 1px solid var(--line); @@ -1537,7 +1537,7 @@ button { display: flex; align-items: flex-start; justify-content: space-between; - gap: 16px; + gap: 1rem; flex-wrap: wrap; } .policy-num { @@ -1550,8 +1550,8 @@ button { .policy-type-row { display: flex; align-items: center; - gap: 8px; - margin-top: 5px; + gap: 0.5rem; + margin-top: 0.3125rem; font-size: 0.8125rem; color: var(--muted); flex-wrap: wrap; @@ -1573,16 +1573,16 @@ button { color: var(--muted); } .policy-body { - margin-top: 16px; + margin-top: 1rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 16px; + gap: 1rem; } .subpanel { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-sm); - padding: 12px 14px; + padding: 0.75rem 0.875rem; } .subpanel-title { font-size: 0.6875rem; @@ -1590,7 +1590,7 @@ button { letter-spacing: 0.07em; color: var(--muted); font-weight: 700; - margin-bottom: 9px; + margin-bottom: 0.5625rem; display: flex; justify-content: space-between; } @@ -1598,9 +1598,9 @@ button { display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: 0.625rem; font-size: 0.8125rem; - padding: 5px 0; + padding: 0.3125rem 0; border-bottom: 1px dashed var(--line); } .pay-row:last-child { @@ -1623,7 +1623,7 @@ button { color: var(--ink-soft); display: flex; flex-direction: column; - gap: 5px; + gap: 0.3125rem; } .mini-list .mini-sub { font-size: 0.75rem; @@ -1639,13 +1639,13 @@ button { in full — auto-fill packs fixed-width tracks, so a single-currency card never widens on its own and the min is what has to fit the number. */ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 12px; - margin-bottom: 18px; + gap: 0.75rem; + margin-bottom: 1.125rem; } .summary-card { border: 1px solid var(--line); border-radius: var(--radius); - padding: 15px 16px; + padding: 0.9375rem 1rem; background: var(--surface); border-top: 3px solid var(--muted-2); } @@ -1661,7 +1661,7 @@ button { .summary-domain { display: flex; align-items: center; - gap: 7px; + gap: 0.4375rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; @@ -1672,7 +1672,7 @@ button { font-family: var(--font-display); font-size: 1.375rem; font-weight: 560; - margin-top: 8px; + margin-top: 0.5rem; font-feature-settings: "tnum" 1; color: var(--ink); /* Never truncate a balance — a clipped money figure reads as a wrong number. @@ -1681,7 +1681,7 @@ button { .summary-count { font-size: 0.75rem; color: var(--muted); - margin-top: 2px; + margin-top: 0.125rem; } .tx-table { @@ -1696,7 +1696,7 @@ button { letter-spacing: 0.07em; color: var(--muted); font-weight: 700; - padding: 0 12px 10px; + padding: 0 0.75rem 0.625rem; border-bottom: 1px solid var(--line); } .tx-table th.num, @@ -1704,7 +1704,7 @@ button { text-align: right; } .tx-table td { - padding: 11px 12px; + padding: 0.6875rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; } @@ -1727,7 +1727,7 @@ button { width: 8px; height: 8px; border-radius: 2px; - margin-right: 7px; + margin-right: 0.4375rem; vertical-align: middle; } .tx-dot.UTILITY { @@ -1772,16 +1772,16 @@ button { .doc-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 10px; - padding: 20px 22px; + gap: 0.625rem; + padding: 1.25rem 1.375rem; } .doc-item { display: flex; align-items: center; - gap: 11px; + gap: 0.6875rem; border: 1px solid var(--line); border-radius: var(--radius-sm); - padding: 11px 13px; + padding: 0.6875rem 0.8125rem; background: var(--surface-2); } .doc-icon { @@ -1808,7 +1808,7 @@ button { } .empty-inline { - padding: 22px 24px; + padding: 1.375rem 1.5rem; color: var(--muted); font-size: 0.875rem; font-style: italic; @@ -1817,7 +1817,7 @@ button { .section-note { font-size: 0.75rem; color: var(--muted); - margin-top: 10px; + margin-top: 0.625rem; } /* ============================================================================ @@ -1870,10 +1870,10 @@ button { .premium-strip { display: flex; align-items: center; - gap: 16px; + gap: 1rem; flex-wrap: wrap; - margin-top: 14px; - padding: 0 2px; + margin-top: 0.875rem; + padding: 0 0.125rem; } .premium-caption { font-size: 0.71875rem; @@ -1885,7 +1885,7 @@ button { .premium-chip { display: inline-flex; align-items: baseline; - gap: 8px; + gap: 0.5rem; } .premium-chip strong { font-family: var(--font-mono); @@ -1903,15 +1903,15 @@ button { ========================================================================== */ .filter-row { display: flex; - gap: 14px; + gap: 0.875rem; align-items: flex-end; flex-wrap: wrap; - margin-bottom: 18px; + margin-bottom: 1.125rem; } .filter-field { display: flex; flex-direction: column; - gap: 5px; + gap: 0.3125rem; flex: 1 1 210px; min-width: 0; } @@ -1925,7 +1925,7 @@ button { .select { appearance: none; cursor: pointer; - padding-right: 34px; + padding-right: 2.125rem; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 18px) 55%, calc(100% - 13px) 55%; @@ -1942,7 +1942,7 @@ button { ========================================================================== */ .pol-row .cust-name { flex-wrap: wrap; - gap: 9px; + gap: 0.5625rem; } .pol-number { font-size: 0.9375rem; @@ -1954,7 +1954,7 @@ button { display: flex; flex-direction: column; align-items: flex-end; - gap: 3px; + gap: 0.1875rem; flex: 0 0 auto; } .pol-premium { @@ -1983,7 +1983,7 @@ button { .pol-side { align-items: flex-start; text-align: left; - margin-top: 8px; + margin-top: 0.5rem; } } @@ -1994,8 +1994,8 @@ button { display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 18px 22px; + gap: 1rem; + padding: 1.125rem 1.375rem; color: inherit; text-decoration: none; border-radius: var(--radius-lg); @@ -2008,7 +2008,7 @@ button { font-family: var(--font-display); font-size: 1.1875rem; font-weight: 560; - margin-bottom: 3px; + margin-bottom: 0.1875rem; } .owner-cta { font-size: 0.8125rem; @@ -2018,34 +2018,34 @@ button { } .linked-props { border-top: 1px solid var(--line); - padding: 16px 22px 18px; + padding: 1rem 1.375rem 1.125rem; } .linked-prop { font-size: 0.875rem; - margin-top: 4px; + margin-top: 0.25rem; } .veh-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 12px; - padding: 16px; + gap: 0.75rem; + padding: 1rem; } .veh-card { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius); - padding: 13px 15px; + padding: 0.8125rem 0.9375rem; } .veh-title { font-weight: 600; font-size: 0.90625rem; - margin-bottom: 6px; + margin-bottom: 0.375rem; } .veh-facts { display: flex; flex-direction: column; - gap: 3px; + gap: 0.1875rem; font-size: 0.78125rem; color: var(--muted); } @@ -2066,16 +2066,16 @@ button { .mix-strip { display: flex; align-items: center; - gap: 10px; + gap: 0.625rem; flex-wrap: wrap; - margin-top: 14px; - padding: 0 2px; + margin-top: 0.875rem; + padding: 0 0.125rem; } .mix-chip { display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 11px; + gap: 0.5rem; + padding: 0.375rem 0.6875rem; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; @@ -2098,7 +2098,7 @@ button { .mix-body { display: inline-flex; align-items: baseline; - gap: 6px; + gap: 0.375rem; } .mix-body strong { font-family: var(--font-mono); @@ -2114,7 +2114,7 @@ button { font-size: 0.6875rem; color: var(--muted); border-left: 1px solid var(--line); - padding-left: 8px; + padding-left: 0.5rem; } /* ============================================================================ @@ -2122,19 +2122,19 @@ button { ========================================================================== */ .prop-row .cust-name { flex-wrap: wrap; - gap: 9px; + gap: 0.5625rem; } .prop-side { text-align: right; display: flex; flex-direction: column; align-items: flex-end; - gap: 5px; + gap: 0.3125rem; flex: 0 0 auto; } .svc-chips { display: flex; - gap: 5px; + gap: 0.3125rem; flex-wrap: wrap; justify-content: flex-end; } @@ -2157,7 +2157,7 @@ button { .prop-side { align-items: flex-start; text-align: left; - margin-top: 8px; + margin-top: 0.5rem; } .svc-chips { justify-content: flex-start; @@ -2189,7 +2189,7 @@ button { width: 1px; height: 1px; padding: 0; - margin: -1px; + margin: -0.0625rem; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; @@ -2236,7 +2236,7 @@ button { text-align: right; display: flex; flex-direction: column; - gap: 2px; + gap: 0.125rem; align-items: flex-end; min-width: 160px; } @@ -2292,8 +2292,8 @@ button { .bal-breakdown { display: grid; grid-template-columns: auto 1fr; - gap: 1px 8px; - margin-top: 8px; + gap: 0.0625rem 0.5rem; + margin-top: 0.5rem; font-size: 0.75rem; align-items: baseline; } @@ -2306,8 +2306,8 @@ button { .ledger-chip { display: flex; align-items: center; - gap: 14px; - padding: 8px 14px; + gap: 0.875rem; + padding: 0.5rem 0.875rem; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); @@ -2333,15 +2333,15 @@ button { .filtered-totals { display: flex; flex-wrap: wrap; - gap: 10px; - margin-bottom: 12px; + gap: 0.625rem; + margin-bottom: 0.75rem; } .filtered-total { display: flex; flex-wrap: wrap; align-items: baseline; - gap: 14px; - padding: 9px 14px; + gap: 0.875rem; + padding: 0.5625rem 0.875rem; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); @@ -2371,14 +2371,14 @@ button { .concept-list { display: flex; flex-direction: column; - padding: 4px 16px; + padding: 0.25rem 1rem; } .concept-row { display: grid; grid-template-columns: minmax(150px, 1.2fr) minmax(60px, 2fr) auto; - gap: 14px; + gap: 0.875rem; align-items: center; - padding: 9px 0; + padding: 0.5625rem 0; border-bottom: 1px solid var(--line); } .concept-row:last-child { @@ -2423,9 +2423,9 @@ button { /* Cross-link out of a detail hero (statement -> customer file). */ .hero-links { - margin-top: 16px; + margin-top: 1rem; display: flex; - gap: 10px; + gap: 0.625rem; flex-wrap: wrap; position: relative; z-index: 1; @@ -2446,8 +2446,8 @@ button { } .ops-log { - margin: 12px 0 0; - padding: 12px 14px; + margin: 0.75rem 0 0; + padding: 0.75rem 0.875rem; max-height: 320px; overflow: auto; background: var(--surface-2); @@ -2466,7 +2466,7 @@ button { background: rgba(0, 0, 0, 0.4); display: grid; place-items: center; - padding: 20px; + padding: 1.25rem; z-index: 50; } @@ -2476,23 +2476,23 @@ button { .home { display: flex; flex-direction: column; - gap: 36px; - padding-top: 8px; + gap: 2.25rem; + padding-top: 0.5rem; } .home-last-seen { - margin-top: 14px; + margin-top: 0.875rem; font-size: 0.8125rem; } .home-section { display: flex; flex-direction: column; - gap: 14px; + gap: 0.875rem; } .section-head { display: flex; align-items: baseline; justify-content: space-between; - gap: 16px; + gap: 1rem; flex-wrap: wrap; } .section-title { @@ -2515,7 +2515,7 @@ button { .home > .home-section:first-of-type { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 14px; + gap: 0.875rem; } @media (max-width: 1080px) { .home > .home-section:first-of-type { @@ -2531,7 +2531,7 @@ button { .kpi-card { display: flex; flex-direction: column; - padding: 18px 18px 16px; + padding: 1.125rem 1.125rem 1rem; text-decoration: none; color: inherit; transition: transform 0.18s var(--ease-out-expo), box-shadow 0.18s; @@ -2553,19 +2553,19 @@ button { font-size: 2rem; font-weight: 560; color: var(--ink); - margin-top: 8px; + margin-top: 0.5rem; font-feature-settings: "tnum" 1; letter-spacing: -0.015em; line-height: 1.1; - min-height: 36px; + min-height: 2.25rem; } .kpi-sub { list-style: none; padding: 0; - margin: 12px 0 0; + margin: 0.75rem 0 0; display: flex; flex-direction: column; - gap: 4px; + gap: 0.25rem; font-size: 0.78125rem; color: var(--ink-soft); } @@ -2573,7 +2573,7 @@ button { line-height: 1.35; } .kpi-cta { - margin-top: 14px; + margin-top: 0.875rem; font-size: 0.75rem; font-weight: 700; color: var(--brand-700); @@ -2588,13 +2588,13 @@ button { .attention-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 14px; + gap: 0.875rem; } .attention-card { display: flex; flex-direction: column; - gap: 6px; - padding: 16px 18px 18px; + gap: 0.375rem; + padding: 1rem 1.125rem 1.125rem; text-decoration: none; color: inherit; border-left: 3px solid var(--line-strong); @@ -2618,7 +2618,7 @@ button { display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: 0.625rem; } .attention-title { font-size: 0.75rem; @@ -2644,8 +2644,8 @@ button { font-feature-settings: "tnum" 1; letter-spacing: -0.01em; line-height: 1.1; - min-height: 30px; - margin-top: 2px; + min-height: 1.875rem; + margin-top: 0.125rem; } .attention-sub { font-size: 0.78125rem; @@ -2655,20 +2655,20 @@ button { .attention-meta { font-size: 0.71875rem; color: var(--muted); - margin-top: 4px; + margin-top: 0.25rem; } /* Currency totals inside an attention card */ .home-currency-totals { display: inline-flex; flex-wrap: wrap; - gap: 8px 14px; + gap: 0.5rem 0.875rem; align-items: baseline; } .home-currency-totals-row { display: inline-flex; align-items: baseline; - gap: 6px; + gap: 0.375rem; } .home-currency-totals-num { font-family: var(--font-display); @@ -2681,12 +2681,12 @@ button { .quick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 12px; + gap: 0.75rem; } .quick-link { display: flex; flex-direction: column; - padding: 14px 16px; + padding: 0.875rem 1rem; text-decoration: none; color: inherit; position: relative; @@ -2705,7 +2705,7 @@ button { .quick-sub { font-size: 0.75rem; color: var(--muted); - margin-top: 2px; + margin-top: 0.125rem; } .quick-arrow { position: absolute; @@ -2731,13 +2731,13 @@ button { .report-catalog { display: flex; flex-direction: column; - gap: 28px; - margin-top: 20px; + gap: 1.75rem; + margin-top: 1.25rem; } .report-catalog-group { display: flex; flex-direction: column; - gap: 10px; + gap: 0.625rem; } .report-catalog-title { font-family: var(--font-display); @@ -2745,7 +2745,7 @@ button { font-size: 1.25rem; color: var(--brand-800); margin: 0; - padding-bottom: 6px; + padding-bottom: 0.375rem; border-bottom: 1px solid var(--line); } .report-catalog-list { @@ -2754,7 +2754,7 @@ button { padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 12px; + gap: 0.75rem; } .report-catalog-item { margin: 0; @@ -2762,8 +2762,8 @@ button { .report-catalog-link { display: flex; flex-direction: column; - gap: 6px; - padding: 14px 16px; + gap: 0.375rem; + padding: 0.875rem 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; @@ -2788,14 +2788,14 @@ button { .report-catalog-item-meta { display: flex; flex-wrap: wrap; - gap: 6px; - margin-top: 4px; + gap: 0.375rem; + margin-top: 0.25rem; } .report-catalog-tag { font-size: 0.6875rem; background: var(--brand-tint); color: var(--brand-800); - padding: 2px 8px; + padding: 0.125rem 0.5rem; border-radius: 999px; font-family: var(--font-mono); } @@ -2808,23 +2808,23 @@ button { .report-runner { display: flex; flex-direction: column; - gap: 16px; - margin-top: 20px; + gap: 1rem; + margin-top: 1.25rem; } .report-filters { display: flex; flex-wrap: wrap; - gap: 12px; + gap: 0.75rem; align-items: flex-end; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; - padding: 14px 16px; + padding: 0.875rem 1rem; } .report-filters-actions { display: flex; align-items: center; - gap: 8px; + gap: 0.5rem; margin-left: auto; } .report-error { @@ -2832,23 +2832,23 @@ button { color: var(--negative); border: 1px solid #e6b6a4; border-radius: 6px; - padding: 10px 14px; + padding: 0.625rem 0.875rem; font-size: 0.8125rem; } .report-loading { font-size: 0.75rem; color: var(--muted); - padding: 4px 0; + padding: 0.25rem 0; } .report-result { display: flex; flex-direction: column; - gap: 12px; + gap: 0.75rem; } .report-result-head { display: flex; align-items: flex-end; - gap: 16px; + gap: 1rem; flex-wrap: wrap; } .report-result-meta { @@ -2858,10 +2858,10 @@ button { .report-output-buttons { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 0.375rem; } .btn-sm { - padding: 4px 10px; + padding: 0.25rem 0.625rem; font-size: 0.75rem; } .report-table-wrap { @@ -2877,7 +2877,7 @@ button { } .report-table th, .report-table td { - padding: 8px 12px; + padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); } .report-table th { @@ -2896,32 +2896,32 @@ button { background: var(--accent); color: #f5f1e8; font-weight: 600; - padding: 10px 14px; + padding: 0.625rem 0.875rem; } .statement { display: flex; flex-direction: column; - gap: 20px; + gap: 1.25rem; } .statement-head { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; - padding: 16px 18px; + padding: 1rem 1.125rem; } .statement-name { font-family: var(--font-display); font-size: 1.375rem; font-weight: 600; color: var(--ink); - margin: 0 0 4px; + margin: 0 0 0.25rem; } .statement-section-title { font-family: var(--font-display); font-size: 1rem; font-weight: 500; color: var(--brand-800); - margin: 0 0 8px; + margin: 0 0 0.5rem; } /* Renewal notices (aviso-renovacion) — one card per policy due, see @@ -2929,13 +2929,13 @@ button { .renewal-letters { display: flex; flex-direction: column; - gap: 20px; + gap: 1.25rem; } .renewal-letter { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; - padding: 18px 20px; + padding: 1.125rem 1.25rem; break-inside: avoid; } .renewal-letter-head { @@ -2943,8 +2943,8 @@ button { justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--line); - padding-bottom: 10px; - margin-bottom: 12px; + padding-bottom: 0.625rem; + margin-bottom: 0.75rem; } .renewal-letter-title { font-family: var(--font-display); @@ -2956,37 +2956,37 @@ button { .renewal-letter-grid { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 12px; - margin-bottom: 12px; + gap: 0.75rem; + margin-bottom: 0.75rem; } .renewal-letter-vehicle { background: var(--paper-2); border-radius: 6px; - padding: 10px 12px; - margin-bottom: 12px; + padding: 0.625rem 0.75rem; + margin-bottom: 0.75rem; } .renewal-letter-coverage { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 12px; - margin-bottom: 12px; + gap: 0.75rem; + margin-bottom: 0.75rem; } .renewal-letter-coverage-item { border-left: 2px solid var(--line-strong); - padding-left: 10px; + padding-left: 0.625rem; } .renewal-letter-value { font-size: 0.875rem; font-weight: 600; color: var(--ink); - margin: 2px 0 0; + margin: 0.125rem 0 0; } .renewal-letter-premium { display: flex; - gap: 20px; + gap: 1.25rem; font-size: 0.875rem; border-top: 1px solid var(--line); - padding-top: 10px; + padding-top: 0.625rem; } .renewal-letter-total { font-weight: 700; @@ -3006,7 +3006,7 @@ button { .context-reports { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 0.375rem; align-items: center; margin-left: auto; } @@ -3015,12 +3015,12 @@ button { color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.06em; - margin-right: 4px; + margin-right: 0.25rem; font-weight: 500; } .context-report-link { font-size: 0.75rem; - padding: 4px 10px; + padding: 0.25rem 0.625rem; border: 1px solid var(--line); background: var(--surface); color: var(--ink); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 47bc66c..ae43f65 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -38,7 +38,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { __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){}`, + `"--ui-scale",String(Math.min(1.5,Math.max(0.9,s))));}catch(e){}`, }} /> {/* Google Fonts via so an offline build still runs with the diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 044cf46..0a56067 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -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 . useEffect(() => { @@ -275,7 +308,7 @@ export function AppShell({ children }: { children: ReactNode }) {
- + {user && ( {user.name} @@ -329,7 +362,11 @@ export function AppShell({ children }: { children: ReactNode }) {
), )} - + {user && (
{user.name} · {ROLE_LABEL[user.role]} diff --git a/apps/web/src/components/FontScaleControl.tsx b/apps/web/src/components/FontScaleControl.tsx index 7bd4d9c..e73706d 100644 --- a/apps/web/src/components/FontScaleControl.tsx +++ b/apps/web/src/components/FontScaleControl.tsx @@ -1,36 +1,30 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { - UI_SCALES, - applyUiScale, - readUiScale, - saveUiScale, -} from "@/lib/ui-scale"; +import { UI_SCALES } 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. + * Text-size picker. Controlled: AppShell owns the value and handles applying + * 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 * flat row used inside the mobile drawer, where a popover inside a popover * would be awkward. */ export function FontScaleControl({ + value, + onChange, variant = "menu", }: { + value: number; + onChange: (scale: number) => void; 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) { @@ -49,16 +43,14 @@ export function FontScaleControl({ }; }, [open]); - function choose(value: number) { - setScale(value); - applyUiScale(value); - saveUiScale(value); + function choose(next: number) { + onChange(next); setOpen(false); } const options = UI_SCALES.map((option) => ({ ...option, - selected: scale !== null && Math.abs(scale - option.value) < 0.001, + selected: Math.abs(value - option.value) < 0.001, })); if (variant === "inline") { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3050896..cfee559 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -136,6 +136,14 @@ export function me(): Promise { return apiFetch("/auth/me"); } +/** Persist the caller's own text-size preference on their account. */ +export function updateUiScale(uiScale: number): Promise { + return apiFetch("/auth/preferences", { + method: "PATCH", + body: JSON.stringify({ uiScale }), + }); +} + export function logout(): Promise<{ success: boolean }> { return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" }); } diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 0661a1f..32a707b 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -29,6 +29,10 @@ export interface AuthUser { email: string; role: Role; 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 // reads this map, never re-derives the rules. Server still enforces. abilities: Record; diff --git a/apps/web/src/lib/ui-scale.ts b/apps/web/src/lib/ui-scale.ts index fd3f049..6348bac 100644 --- a/apps/web/src/lib/ui-scale.ts +++ b/apps/web/src/lib/ui-scale.ts @@ -1,22 +1,25 @@ -// 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. +// App-wide text size. Every font-size *and* every spacing value 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. +// The account is the source of truth (User.uiScale, served on /auth/me). +// localStorage holds a copy purely so the pre-hydration script in +// 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 DEFAULT_UI_SCALE = 1; 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 }[] = [ { 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" }, + { value: 1.5, label: "Máximo", short: "A" }, ]; /** Clamp to the supported range; anything unparseable falls back to default. */ diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index d9c333d..317977e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -546,6 +546,10 @@ model User { passwordHash String role UserRole @default(STAFF) 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()) updatedAt DateTime @updatedAt activityLogs ActivityLog[]