From 4742915389e5028aafb118e7303097cf1b2b91f4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 29 Jun 2026 10:59:25 +0200 Subject: [PATCH] feat(appearance): add per-user appearance config contract Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly. --- client/src/store/settingsStore.ts | 2 + client/src/types.ts | 4 + server/src/services/settingsService.ts | 5 + .../src/appearance/appearance.schema.spec.ts | 70 ++++++++ shared/src/appearance/appearance.schema.ts | 168 ++++++++++++++++++ shared/src/index.ts | 1 + 6 files changed, 250 insertions(+) create mode 100644 shared/src/appearance/appearance.schema.spec.ts create mode 100644 shared/src/appearance/appearance.schema.ts diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index 84090c830..eba06eb77 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { settingsApi } from '../api/client' import type { Settings } from '../types' +import { DEFAULT_APPEARANCE } from '@trek/shared' import { getApiErrorMessage } from '../types' import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages' @@ -43,6 +44,7 @@ export const useSettingsStore = create((set, get) => ({ mapbox_quality_mode: false, dashboard_fx_from: 'EUR', dashboard_fx_to: 'USD', + appearance: DEFAULT_APPEARANCE, // dashboard_timezones is intentionally left unset so the widget can tell "never // chosen" (fall back to home + defaults) from an explicitly emptied list. }, diff --git a/client/src/types.ts b/client/src/types.ts index 05cd485ed..56c4b3c16 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -25,6 +25,7 @@ import type { Accommodation, Tag, Category, + AppearanceConfig, } from '@trek/shared' export type { @@ -47,6 +48,7 @@ export type { Accommodation, Tag, Category, + AppearanceConfig, } export interface User { @@ -135,6 +137,8 @@ export interface Settings { llm_base_url?: string llm_multimodal?: boolean llm_api_key?: string + /** Per-user appearance/customization config (theming, transparency, typography, dashboard widgets). */ + appearance?: AppearanceConfig } export interface AssignmentsMap { diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts index db9a850e0..d58f53495 100644 --- a/server/src/services/settingsService.ts +++ b/server/src/services/settingsService.ts @@ -1,5 +1,6 @@ import { db } from '../db/database'; import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto'; +import { normalizeAppearance } from '@trek/shared'; const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']); // Encrypted keys that are masked (••••••••) when returned to the client. @@ -136,6 +137,10 @@ export function getUserSettings(userId: number): Record { } function serializeValue(key: string, value: unknown): string { + // The appearance blob drives the DOM on the client — normalize it on the way + // in so a malformed/partial/future-versioned payload can never be stored (and + // thus never reach the writer). Unknown/invalid fields collapse to the default. + if (key === 'appearance') value = normalizeAppearance(value); const raw = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : ''); if (ENCRYPTED_SETTING_KEYS.has(key)) return maybe_encrypt_api_key(raw) ?? raw; return raw; diff --git a/shared/src/appearance/appearance.schema.spec.ts b/shared/src/appearance/appearance.schema.spec.ts new file mode 100644 index 000000000..da29694d4 --- /dev/null +++ b/shared/src/appearance/appearance.schema.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + APPEARANCE_SCALE_MAX, + APPEARANCE_SCALE_MIN, + DEFAULT_APPEARANCE, + normalizeAppearance, +} from './appearance.schema'; + +describe('normalizeAppearance', () => { + it('returns the neutral default for undefined / null / non-objects', () => { + expect(normalizeAppearance(undefined)).toEqual(DEFAULT_APPEARANCE); + expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE); + expect(normalizeAppearance('garbage')).toEqual(DEFAULT_APPEARANCE); + expect(normalizeAppearance(42)).toEqual(DEFAULT_APPEARANCE); + }); + + it('round-trips the default unchanged', () => { + expect(normalizeAppearance(DEFAULT_APPEARANCE)).toEqual(DEFAULT_APPEARANCE); + }); + + it('fills missing fields from the default for a partial blob', () => { + const out = normalizeAppearance({ schemeId: 'indigo' }); + expect(out.schemeId).toBe('indigo'); + expect(out.transparency).toBe(true); + expect(out.density).toBe('comfortable'); + expect(out.typeScale).toEqual({ title: 1, subtitle: 1, body: 1, caption: 1 }); + expect(out.dashboard).toEqual(DEFAULT_APPEARANCE.dashboard); + }); + + it('falls back an unknown scheme to default without throwing', () => { + expect(normalizeAppearance({ schemeId: 'neon-vaporwave' }).schemeId).toBe('default'); + }); + + it('collapses an unknown version to the default look', () => { + const out = normalizeAppearance({ version: 99, schemeId: 'teal' }); + expect(out.version).toBe(1); + // unknown-version blobs still keep the fields we understand + expect(out.schemeId).toBe('teal'); + }); + + it('clamps font + type scales into the allowed band', () => { + const out = normalizeAppearance({ fontScale: 9, typeScale: { title: 0.1, subtitle: 1.2, body: 5, caption: -3 } }); + expect(out.fontScale).toBe(APPEARANCE_SCALE_MAX); + expect(out.typeScale.title).toBe(APPEARANCE_SCALE_MIN); + expect(out.typeScale.subtitle).toBe(1.2); + expect(out.typeScale.body).toBe(APPEARANCE_SCALE_MAX); + expect(out.typeScale.caption).toBe(APPEARANCE_SCALE_MIN); + }); + + it('accepts a valid custom accent and rejects a malformed one', () => { + const ok = normalizeAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#818cf8' } }); + expect(ok.accent).toEqual({ light: '#4f46e5', dark: '#818cf8' }); + + const bad = normalizeAppearance({ schemeId: 'custom', accent: { light: 'not-a-color', dark: '#fff' } }); + expect(bad.accent).toBeNull(); + }); + + it('keeps per-device dashboard widget flags and defaults missing ones to true', () => { + const out = normalizeAppearance({ dashboard: { desktop: { sidebar: false, distanceFlown: false } } }); + expect(out.dashboard.desktop.sidebar).toBe(false); + expect(out.dashboard.desktop.distanceFlown).toBe(false); + expect(out.dashboard.desktop.atlas).toBe(true); + expect(out.dashboard.mobile.tripsTotal).toBe(true); + }); + + it('ignores bogus dashboard values without throwing', () => { + const out = normalizeAppearance({ dashboard: 'nope' }); + expect(out.dashboard).toEqual(DEFAULT_APPEARANCE.dashboard); + }); +}); diff --git a/shared/src/appearance/appearance.schema.ts b/shared/src/appearance/appearance.schema.ts new file mode 100644 index 000000000..898c27bf4 --- /dev/null +++ b/shared/src/appearance/appearance.schema.ts @@ -0,0 +1,168 @@ +import { z } from 'zod'; + +/** + * Appearance contract — the per-user "look & feel" config stored as ONE JSON + * blob under the `appearance` settings key (see /api/settings). Shared by the + * server (validation on write) and the client (the single DOM writer + * applyAppearance + the pre-paint boot script). + * + * Design rules that keep this safe and non-breaking: + * - DEFAULT_APPEARANCE reproduces TREK's current look byte-for-byte, so a user + * with no `appearance` key (or a malformed one) is indistinguishable from + * today. + * - Every field is individually resilient (`.catch` + `.default`) and + * normalizeAppearance() never throws — a bad blob can never reach the DOM. + * - Unknown future fields are stripped, and an unrecognised `version` collapses + * to the known default rather than mis-applying. + */ + +/** Curated v1 color schemes (light + dark token sets live client-side). */ +export const APPEARANCE_PRESET_SCHEMES = [ + 'default', // monochrome — today's look, sets no data-scheme attribute + 'highContrast', // raises neutral text/border contrast (#951/#1025) + 'indigo', // TREK's classic indigo accent + 'teal', // calm green-blue + 'rose', // warm rose/coral + 'amber', // warm gold/sunrise + 'violet', // purple/plum +] as const; + +/** All selectable scheme ids, including the user-defined custom accent. */ +export const APPEARANCE_SCHEME_IDS = [...APPEARANCE_PRESET_SCHEMES, 'custom'] as const; + +export type AppearanceSchemeId = (typeof APPEARANCE_SCHEME_IDS)[number]; + +export const APPEARANCE_DENSITIES = ['comfortable', 'compact'] as const; +export type AppearanceDensity = (typeof APPEARANCE_DENSITIES)[number]; + +/** Text-tier scale bounds — clamped so layouts never blow out. */ +export const APPEARANCE_SCALE_MIN = 0.8; +export const APPEARANCE_SCALE_MAX = 1.6; + +const clampScale = (n: number): number => + Number.isFinite(n) ? Math.min(APPEARANCE_SCALE_MAX, Math.max(APPEARANCE_SCALE_MIN, n)) : 1; + +/** #RGB or #RRGGBB — solid colors only (alpha would break accent-text derivation). */ +const hexColor = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + +const scaleField = z.number().catch(1).default(1); + +const typeScaleSchema = z + .object({ + title: scaleField, + subtitle: scaleField, + body: scaleField, + caption: scaleField, + }) + .catch({ title: 1, subtitle: 1, body: 1, caption: 1 }) + .default({ title: 1, subtitle: 1, body: 1, caption: 1 }); + +// ── Dashboard widget visibility (per device) ────────────────────────────── +// Consumed by the Dashboard for conditional rendering + column reflow — NOT by +// the DOM token writer. Desktop and mobile are independent so a widget can be +// shown on one and hidden on the other. All-true = today's full layout. +const widgetFlag = z.boolean().catch(true).default(true); + +const DEFAULT_DASHBOARD_DESKTOP = { + sidebar: true, // master toggle for the whole right sidebar (off → center) + currency: true, + timezones: true, + upcomingReservations: true, + atlas: true, // the "countries visited" passport tile + tripsTotal: true, + daysTraveled: true, + distanceFlown: true, +}; +const DEFAULT_DASHBOARD_MOBILE = { + tripsTotal: true, + daysTraveled: true, + currency: true, + timezones: true, + upcomingReservations: true, +}; +export const DEFAULT_DASHBOARD_WIDGETS = { + desktop: DEFAULT_DASHBOARD_DESKTOP, + mobile: DEFAULT_DASHBOARD_MOBILE, +}; + +const dashboardWidgetsSchema = z + .object({ + desktop: z + .object({ + sidebar: widgetFlag, + currency: widgetFlag, + timezones: widgetFlag, + upcomingReservations: widgetFlag, + atlas: widgetFlag, + tripsTotal: widgetFlag, + daysTraveled: widgetFlag, + distanceFlown: widgetFlag, + }) + .catch(DEFAULT_DASHBOARD_DESKTOP) + .default(DEFAULT_DASHBOARD_DESKTOP), + mobile: z + .object({ + tripsTotal: widgetFlag, + daysTraveled: widgetFlag, + currency: widgetFlag, + timezones: widgetFlag, + upcomingReservations: widgetFlag, + }) + .catch(DEFAULT_DASHBOARD_MOBILE) + .default(DEFAULT_DASHBOARD_MOBILE), + }) + .catch(DEFAULT_DASHBOARD_WIDGETS) + .default(DEFAULT_DASHBOARD_WIDGETS); + +export const appearanceConfigSchema = z.object({ + version: z.literal(1).catch(1).default(1), + schemeId: z.enum(APPEARANCE_SCHEME_IDS).catch('default').default('default'), + // Only consulted when schemeId === 'custom'. {light, dark} so the picker can + // tune each mode independently. + accent: z.object({ light: hexColor, dark: hexColor }).nullable().catch(null).default(null), + // true = translucency ON (today's behaviour). false = solid surfaces. + transparency: z.boolean().catch(true).default(true), + typeScale: typeScaleSchema, + fontScale: scaleField, + density: z.enum(APPEARANCE_DENSITIES).catch('comfortable').default('comfortable'), + reduceMotion: z.boolean().catch(false).default(false), + // Per-device dashboard widget visibility (not a DOM token — read by the Dashboard). + dashboard: dashboardWidgetsSchema, +}); + +export type AppearanceConfig = z.infer; + +/** The neutral default — must equal TREK's current appearance exactly. */ +export const DEFAULT_APPEARANCE: AppearanceConfig = { + version: 1, + schemeId: 'default', + accent: null, + transparency: true, + typeScale: { title: 1, subtitle: 1, body: 1, caption: 1 }, + fontScale: 1, + density: 'comfortable', + reduceMotion: false, + dashboard: DEFAULT_DASHBOARD_WIDGETS, +}; + +/** + * Coerce any stored/posted value into a valid AppearanceConfig. Never throws: + * a non-object, partial, malformed, or future-versioned blob degrades to the + * neutral default (field-by-field where possible). Used on the server before + * persisting and on the client before touching the DOM. + */ +export function normalizeAppearance(raw: unknown): AppearanceConfig { + const input = raw && typeof raw === 'object' ? raw : {}; + const parsed = appearanceConfigSchema.safeParse(input); + const cfg = parsed.success ? parsed.data : { ...DEFAULT_APPEARANCE }; + return { + ...cfg, + fontScale: clampScale(cfg.fontScale), + typeScale: { + title: clampScale(cfg.typeScale.title), + subtitle: clampScale(cfg.typeScale.subtitle), + body: clampScale(cfg.typeScale.body), + caption: clampScale(cfg.typeScale.caption), + }, + }; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 395fee279..2dfdb1c67 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -37,6 +37,7 @@ export * from './file/file.schema'; export * from './journey/journey.schema'; export * from './share/share.schema'; export * from './settings/settings.schema'; +export * from './appearance/appearance.schema'; export * from './backup/backup.schema'; export * from './auth/auth.schema'; export * from './oidc/oidc.schema';