mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<SettingsState>((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.
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof appearanceConfigSchema>;
|
||||
|
||||
/** 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user