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[]