diff --git a/backend/migrations/004_theme_storage.sql b/backend/migrations/004_theme_storage.sql new file mode 100644 index 00000000..7c73ebe3 --- /dev/null +++ b/backend/migrations/004_theme_storage.sql @@ -0,0 +1,53 @@ +-- Theme Storage for SubTrackr Merchant Branding +-- Adds merchant theme configuration support + +CREATE TABLE IF NOT EXISTS merchant_themes ( + id TEXT PRIMARY KEY, + merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + config_json JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_merchant_themes_merchant_id ON merchant_themes(merchant_id); +CREATE INDEX IF NOT EXISTS idx_merchant_themes_active ON merchant_themes(merchant_id, is_active) WHERE is_active = true; + +COMMENT ON TABLE merchant_themes IS 'Merchant-branded theme configurations for white-label subscription UI'; +COMMENT ON COLUMN merchant_themes.config_json IS 'Full theme config: colors, fonts, logo URLs, CSS variables, accessibility metadata'; + +CREATE TABLE IF NOT EXISTS theme_variant_pairs ( + id TEXT PRIMARY KEY, + merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE, + name TEXT NOT NULL, + light_theme_id TEXT NOT NULL REFERENCES merchant_themes(id) ON DELETE CASCADE, + dark_theme_id TEXT NOT NULL REFERENCES merchant_themes(id) ON DELETE CASCADE, + shared_config JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_theme_variant_pairs_merchant_id ON theme_variant_pairs(merchant_id); + +COMMENT ON TABLE theme_variant_pairs IS 'Light/dark theme variant pairs for merchant brand families'; + +CREATE OR REPLACE FUNCTION ensure_single_active_theme() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_active THEN + UPDATE merchant_themes + SET is_active = false, updated_at = now() + WHERE merchant_id = NEW.merchant_id AND id != NEW.id AND is_active = true; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_single_active_theme ON merchant_themes; +CREATE TRIGGER trg_single_active_theme + BEFORE INSERT OR UPDATE OF is_active + ON merchant_themes + FOR EACH ROW + WHEN (NEW.is_active = true) + EXECUTE FUNCTION ensure_single_active_theme(); diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index 586d92c7..98b650e1 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -6,7 +6,7 @@ import express, { type Express } from 'express'; import { cacheHeadersMiddleware } from '../shared/middleware'; -import { createPublicApiRouter } from '../subscription/router/publicApiRouter'; +import { createPublicApiRouter, createThemeRouter } from '../subscription/router'; import { API_VERSION_HEADER, API_VERSION_VALUE } from '../services/shared/apiResponse'; export interface CreateApiServerOptions { @@ -33,6 +33,7 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { app.use(cacheHeadersMiddleware()); app.use(createPublicApiRouter()); + app.use('/api/v1/merchant', createThemeRouter()); app.use((_req, res) => { res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Not found' } }); diff --git a/backend/subscription/controller/themeController.ts b/backend/subscription/controller/themeController.ts new file mode 100644 index 00000000..363e77de --- /dev/null +++ b/backend/subscription/controller/themeController.ts @@ -0,0 +1,135 @@ +import type { Request, Response } from 'express'; +import { fail, success } from '../../services/shared/apiResponse'; +import { extractRequestId } from './index'; + +interface ThemeRecord { + id: string; + merchantId: string; + name: string; + config: Record; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +const themeStore = new Map(); + +export function getThemes(req: Request, res: Response): void { + const merchantId = (req.headers['x-merchant-id'] as string) || 'default'; + const merchantThemes = Array.from(themeStore.values()).filter( + (t) => t.merchantId === merchantId, + ); + res.status(200).json( + success(merchantThemes, { + requestId: extractRequestId(req) || 'unknown', + }), + ); +} + +export function getThemeById(req: Request, res: Response): void { + const theme = themeStore.get(req.params.id); + if (!theme) { + res.status(404).json( + fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)), + ); + return; + } + res.status(200).json( + success(theme, { requestId: extractRequestId(req) || 'unknown' }), + ); +} + +export function createTheme(req: Request, res: Response): void { + const merchantId = (req.headers['x-merchant-id'] as string) || 'default'; + const { id, name, config } = req.body; + + if (!id || !name || !config) { + res.status(400).json( + fail('BAD_REQUEST', 'Missing required fields: id, name, config', extractRequestId(req)), + ); + return; + } + + const now = new Date().toISOString(); + const record: ThemeRecord = { + id, + merchantId, + name, + config, + isActive: false, + createdAt: now, + updatedAt: now, + }; + + themeStore.set(id, record); + res.status(201).json( + success(record, { requestId: extractRequestId(req) || 'unknown' }), + ); +} + +export function updateTheme(req: Request, res: Response): void { + const existing = themeStore.get(req.params.id); + if (!existing) { + res.status(404).json( + fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)), + ); + return; + } + + const { name, config, isActive } = req.body; + + if (name !== undefined) existing.name = name; + if (config !== undefined) existing.config = config; + if (isActive !== undefined) { + if (isActive) { + for (const [, t] of themeStore) { + if (t.merchantId === existing.merchantId) t.isActive = false; + } + } + existing.isActive = isActive; + } + existing.updatedAt = new Date().toISOString(); + + themeStore.set(req.params.id, existing); + res.status(200).json( + success(existing, { requestId: extractRequestId(req) || 'unknown' }), + ); +} + +export function deleteTheme(req: Request, res: Response): void { + const existing = themeStore.get(req.params.id); + if (!existing) { + res.status(404).json( + fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)), + ); + return; + } + + themeStore.delete(req.params.id); + res.status(200).json( + success({ deleted: true }, { requestId: extractRequestId(req) || 'unknown' }), + ); +} + +export function activateTheme(req: Request, res: Response): void { + const merchantId = (req.headers['x-merchant-id'] as string) || 'default'; + const theme = themeStore.get(req.params.id); + + if (!theme) { + res.status(404).json( + fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)), + ); + return; + } + + for (const [, t] of themeStore) { + if (t.merchantId === merchantId) t.isActive = false; + } + + theme.isActive = true; + theme.updatedAt = new Date().toISOString(); + + res.status(200).json( + success(theme, { requestId: extractRequestId(req) || 'unknown' }), + ); +} diff --git a/backend/subscription/router/index.ts b/backend/subscription/router/index.ts index 380cf575..18354a53 100644 --- a/backend/subscription/router/index.ts +++ b/backend/subscription/router/index.ts @@ -1 +1,2 @@ export { createPublicApiRouter } from './publicApiRouter'; +export { createThemeRouter } from './themeRouter'; diff --git a/backend/subscription/router/themeRouter.ts b/backend/subscription/router/themeRouter.ts new file mode 100644 index 00000000..682171a9 --- /dev/null +++ b/backend/subscription/router/themeRouter.ts @@ -0,0 +1,73 @@ +import { Router, type Request, type Response, type NextFunction } from 'express'; +import { + getThemes, + getThemeById, + createTheme, + updateTheme, + deleteTheme, + activateTheme, +} from '../controller/themeController'; + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise; + +function asyncHandler(fn: AsyncHandler) { + return (req: Request, res: Response, next: NextFunction): void => { + fn(req, res, next).catch(next); + }; +} + +export function createThemeRouter(): Router { + const router = Router(); + + router.get( + '/themes', + asyncHandler(async (req, res) => { + getThemes(req, res); + }), + ); + + router.get( + '/themes/:id', + asyncHandler(async (req, res) => { + getThemeById(req, res); + }), + ); + + router.post( + '/themes', + asyncHandler(async (req, res) => { + createTheme(req, res); + }), + ); + + router.patch( + '/themes/:id', + asyncHandler(async (req, res) => { + updateTheme(req, res); + }), + ); + + router.delete( + '/themes/:id', + asyncHandler(async (req, res) => { + deleteTheme(req, res); + }), + ); + + router.post( + '/themes/:id/activate', + asyncHandler(async (req, res) => { + activateTheme(req, res); + }), + ); + + router.get( + '/themes/export/:id', + asyncHandler(async (req, res) => { + const { getThemeById: findTheme } = await import('../controller/themeController'); + getThemeById(req, res); + }), + ); + + return router; +} diff --git a/src/__tests__/themeService.test.ts b/src/__tests__/themeService.test.ts new file mode 100644 index 00000000..51006bb1 --- /dev/null +++ b/src/__tests__/themeService.test.ts @@ -0,0 +1,66 @@ +jest.mock('@react-native-async-storage/async-storage', () => { + const store = new Map(); + return { + setItem: jest.fn((k: string, v: string) => { + store.set(k, v); + return Promise.resolve(); + }), + getItem: jest.fn((k: string) => Promise.resolve(store.get(k) ?? null)), + removeItem: jest.fn((k: string) => { + store.delete(k); + return Promise.resolve(); + }), + }; +}); + +import { themeService } from '../services/themeService'; +import { darkTheme } from '../theme/themes'; + +describe('themeService', () => { + it('fetchThemes returns empty array when no themes saved', async () => { + const result = await themeService.fetchThemes(); + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('saveTheme stores a theme record', async () => { + const result = await themeService.saveTheme(darkTheme); + expect(result.success).toBe(true); + expect(result.data?.id).toBe(darkTheme.id); + expect(result.data?.config.colors.primary).toBe(darkTheme.colors.primary); + }); + + it('fetchThemes returns saved themes', async () => { + await themeService.saveTheme(darkTheme); + const result = await themeService.fetchThemes(); + expect(result.success).toBe(true); + expect(result.data?.length).toBe(1); + }); + + it('deleteTheme removes a theme', async () => { + await themeService.saveTheme(darkTheme); + await themeService.deleteTheme(darkTheme.id); + const result = await themeService.fetchThemes(); + expect(result.data?.length).toBe(0); + }); + + it('exportTheme produces valid export data', async () => { + const exported = await themeService.exportTheme(darkTheme); + expect(exported.version).toBe('1.0.0'); + expect(exported.theme.dark).toBeDefined(); + expect(exported.theme.shared.id).toBe(darkTheme.id); + }); + + it('importTheme loads a theme from export data', async () => { + const exported = await themeService.exportTheme(darkTheme); + const result = await themeService.importTheme(exported); + expect(result.success).toBe(true); + expect(result.data?.id).toBe(`${darkTheme.id}-dark`); + }); + + it('syncThemesToRemote saves multiple themes', async () => { + const result = await themeService.syncThemesToRemote([darkTheme]); + expect(result.success).toBe(true); + expect(result.data?.synced).toBe(1); + }); +}); diff --git a/src/services/themeService.ts b/src/services/themeService.ts new file mode 100644 index 00000000..affaf43b --- /dev/null +++ b/src/services/themeService.ts @@ -0,0 +1,200 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Theme, ThemeExportData, ThemeConfig, ThemeVariantPair } from '../theme/types'; + +const API_BASE = '/api/v1/merchant/themes'; +const THEME_API_KEY = 'subtrackr-theme-api-sync'; + +export interface ThemeApiResponse { + success: boolean; + data?: T; + error?: string; + requestId?: string; +} + +interface ThemeApiRecord { + id: string; + name: string; + config: ThemeConfig; + createdAt: string; + updatedAt: string; +} + +const defaultHeaders = { 'Content-Type': 'application/json' }; + +export const themeService = { + async fetchThemes(): Promise> { + try { + const cached = await AsyncStorage.getItem(THEME_API_KEY); + if (cached) { + return { success: true, data: JSON.parse(cached) }; + } + return { success: true, data: [] }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to fetch themes' }; + } + }, + + async saveTheme(theme: Theme): Promise> { + const record: ThemeApiRecord = { + id: theme.id, + name: theme.name, + config: { + colors: { + primary: theme.colors.primary, + secondary: theme.colors.secondary, + accent: theme.colors.accent, + success: theme.colors.success, + warning: theme.colors.warning, + error: theme.colors.error, + }, + fonts: theme.fonts, + logo: theme.logo, + metadata: theme.metadata, + }, + createdAt: theme.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + try { + const cached = await AsyncStorage.getItem(THEME_API_KEY); + const themes: ThemeApiRecord[] = cached ? JSON.parse(cached) : []; + const idx = themes.findIndex((t) => t.id === theme.id); + if (idx >= 0) { + themes[idx] = record; + } else { + themes.push(record); + } + await AsyncStorage.setItem(THEME_API_KEY, JSON.stringify(themes)); + return { success: true, data: record }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to save theme' }; + } + }, + + async saveThemeVariantPair(pair: ThemeVariantPair): Promise> { + const records: ThemeApiRecord[] = [pair.light, pair.dark].map((theme) => ({ + id: theme.id, + name: theme.name, + config: { + colors: { + primary: theme.colors.primary, + secondary: theme.colors.secondary, + accent: theme.colors.accent, + success: theme.colors.success, + warning: theme.colors.warning, + error: theme.colors.error, + background: theme.colors.background, + surface: theme.colors.surface, + text: theme.colors.text, + textSecondary: theme.colors.textSecondary, + }, + fonts: theme.fonts, + logo: theme.logo, + metadata: { + ...theme.metadata, + variantPairId: pair.sharedConfig.id, + variantName: pair.sharedConfig.name, + }, + }, + createdAt: theme.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + + try { + const cached = await AsyncStorage.getItem(THEME_API_KEY); + const themes: ThemeApiRecord[] = cached ? JSON.parse(cached) : []; + for (const record of records) { + const idx = themes.findIndex((t) => t.id === record.id); + if (idx >= 0) { + themes[idx] = record; + } else { + themes.push(record); + } + } + await AsyncStorage.setItem(THEME_API_KEY, JSON.stringify(themes)); + return { success: true, data: records }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to save theme pair' }; + } + }, + + async deleteTheme(id: string): Promise> { + try { + const cached = await AsyncStorage.getItem(THEME_API_KEY); + if (cached) { + const themes: ThemeApiRecord[] = JSON.parse(cached); + const filtered = themes.filter((t) => t.id !== id); + await AsyncStorage.setItem(THEME_API_KEY, JSON.stringify(filtered)); + } + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to delete theme' }; + } + }, + + async syncThemesToRemote(themes: Theme[]): Promise> { + let synced = 0; + for (const theme of themes) { + const result = await this.saveTheme(theme); + if (result.success) synced++; + } + return { success: true, data: { synced } }; + }, + + async exportTheme(theme: Theme): Promise { + const config: ThemeConfig = { + colors: { + primary: theme.colors.primary, + secondary: theme.colors.secondary, + accent: theme.colors.accent, + success: theme.colors.success, + warning: theme.colors.warning, + error: theme.colors.error, + background: theme.colors.background, + surface: theme.colors.surface, + text: theme.colors.text, + textSecondary: theme.colors.textSecondary, + }, + fonts: theme.fonts, + logo: theme.logo, + metadata: theme.metadata, + }; + + return { + version: '1.0.0', + exportedAt: new Date().toISOString(), + theme: { + [theme.mode === 'dark' ? 'dark' : 'light']: config, + shared: { + id: theme.id, + name: theme.name, + fonts: theme.fonts, + logo: theme.logo, + metadata: theme.metadata, + createdAt: theme.createdAt, + updatedAt: theme.updatedAt, + }, + }, + }; + }, + + async importTheme(exportData: ThemeExportData): Promise> { + try { + const { shared } = exportData.theme; + const modeConfig = exportData.theme.light || exportData.theme.dark; + if (!modeConfig) { + return { success: false, error: 'No theme config found in export data' }; + } + const { buildThemeFromConfig } = await import('../theme/customThemeBuilder'); + const theme = buildThemeFromConfig( + modeConfig, + exportData.theme.dark ? 'dark' : 'light', + shared.id || `imported-${Date.now()}`, + shared.name || 'Imported Theme', + ); + return { success: true, data: theme }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Failed to import theme' }; + } + }, +}; diff --git a/src/store/index.ts b/src/store/index.ts index b72c810c..2df7866f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,3 +12,4 @@ export { useTaxStore } from './taxStore'; export { useSupportStore } from './supportStore'; export { useAuthStore } from './authStore'; export { useCancellationStore } from './cancellationStore'; +export { useThemeStore } from '../theme/themeStore'; diff --git a/src/theme/__tests__/accessibility.test.ts b/src/theme/__tests__/accessibility.test.ts new file mode 100644 index 00000000..d6cafddf --- /dev/null +++ b/src/theme/__tests__/accessibility.test.ts @@ -0,0 +1,124 @@ +import { + relativeLuminance, + contrastRatio, + meetsWcagAA, + meetsWcagAAA, + getAccessibilityRating, + isColorReadable, + suggestContrastFix, + lightenToRatio, + darkenToRatio, +} from '../accessibility'; +import { darkTheme, lightTheme, highContrastTheme } from '../themes'; + +describe('relativeLuminance', () => { + it('returns 0 for black', () => { + expect(relativeLuminance('#000000')).toBeCloseTo(0, 2); + }); + + it('returns ~1 for white', () => { + expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 1); + }); + + it('returns 0 for invalid hex', () => { + expect(relativeLuminance('invalid')).toBe(0); + }); +}); + +describe('contrastRatio', () => { + it('returns 21 for black on white', () => { + expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0); + }); + + it('returns 1 for same colors', () => { + expect(contrastRatio('#ff0000', '#ff0000')).toBeCloseTo(1, 0); + }); + + it('returns reasonable ratio for blue on white', () => { + const ratio = contrastRatio('#0000ff', '#ffffff'); + expect(ratio).toBeGreaterThan(5); + expect(ratio).toBeLessThan(10); + }); +}); + +describe('meetsWcagAA', () => { + it('AA normal text requires 4.5:1', () => { + expect(meetsWcagAA(4.5)).toBe(true); + expect(meetsWcagAA(4.49)).toBe(false); + }); + + it('AA large text requires 3:1', () => { + expect(meetsWcagAA(3, true)).toBe(true); + expect(meetsWcagAA(2.99, true)).toBe(false); + }); +}); + +describe('meetsWcagAAA', () => { + it('AAA normal text requires 7:1', () => { + expect(meetsWcagAAA(7)).toBe(true); + expect(meetsWcagAAA(6.99)).toBe(false); + }); + + it('AAA large text requires 4.5:1', () => { + expect(meetsWcagAAA(4.5, true)).toBe(true); + expect(meetsWcagAAA(4.49, true)).toBe(false); + }); +}); + +describe('getAccessibilityRating', () => { + it('dark theme should have contrast info', () => { + const rating = getAccessibilityRating(darkTheme); + expect(rating.contrastRatio).toBeGreaterThan(0); + expect(typeof rating.meetsWcagAA).toBe('boolean'); + expect(typeof rating.meetsWcagAAA).toBe('boolean'); + expect(Array.isArray(rating.issues)).toBe(true); + }); + + it('light theme should meet AA for most checks', () => { + const rating = getAccessibilityRating(lightTheme); + expect(rating.meetsWcagAA).toBeDefined(); + }); + + it('high contrast theme should meet all requirements', () => { + const rating = getAccessibilityRating(highContrastTheme); + expect(rating.contrastRatio).toBeGreaterThanOrEqual(7); + }); +}); + +describe('isColorReadable', () => { + it('black on white is readable', () => { + expect(isColorReadable('#000000', '#ffffff')).toBe(true); + expect(isColorReadable('#000000', '#ffffff', 'AAA')).toBe(true); + }); + + it('light gray on white is not readable', () => { + expect(isColorReadable('#cccccc', '#ffffff')).toBe(false); + }); +}); + +describe('suggestContrastFix', () => { + it('returns same colors if already sufficient', () => { + const result = suggestContrastFix('#000000', '#ffffff'); + expect(result.suggestedForeground).toBe('#000000'); + }); + + it('suggests adjustment for poor contrast', () => { + const result = suggestContrastFix('#cccccc', '#ffffff'); + const newRatio = contrastRatio(result.suggestedForeground, result.suggestedBackground); + expect(newRatio).toBeGreaterThanOrEqual(4.4); + }); +}); + +describe('lightenToRatio', () => { + it('lightens color to meet target ratio', () => { + const result = lightenToRatio('#000000', '#000000', 4.5); + expect(result).toBeDefined(); + }); +}); + +describe('darkenToRatio', () => { + it('darkens color to meet target ratio', () => { + const result = darkenToRatio('#ffffff', '#ffffff', 4.5); + expect(result).toBeDefined(); + }); +}); diff --git a/src/theme/__tests__/cssVariables.test.ts b/src/theme/__tests__/cssVariables.test.ts new file mode 100644 index 00000000..9dca33d4 --- /dev/null +++ b/src/theme/__tests__/cssVariables.test.ts @@ -0,0 +1,147 @@ +import { + flattenColorsToVariables, + flattenFontVariables, + generateCSSVariablesFromTheme, + cssVariablesToString, + generateCSSVariablesDeclaration, + generateThemeStylesheet, +} from '../cssVariables'; +import { darkTheme } from '../themes'; +import type { FontConfig } from '../types'; + +describe('flattenColorsToVariables', () => { + it('converts color map to CSS variables', () => { + const vars = flattenColorsToVariables({ primary: '#ff0000', secondary: '#00ff00' }); + expect(vars['--st-primary']).toBe('#ff0000'); + expect(vars['--st-secondary']).toBe('#00ff00'); + }); + + it('generates rgb variables for valid hex colors', () => { + const vars = flattenColorsToVariables({ primary: '#ff0000' }); + expect(vars['--st-primary-rgb']).toBe('255, 0, 0'); + }); + + it('converts camelCase to kebab-case', () => { + const vars = flattenColorsToVariables({ textSecondary: '#666' }); + expect(vars['--st-text-secondary']).toBe('#666'); + }); + + it('uses custom prefix', () => { + const vars = flattenColorsToVariables({ primary: '#ff0000' }, '--custom-'); + expect(vars['--custom-primary']).toBe('#ff0000'); + }); +}); + +describe('flattenFontVariables', () => { + it('generates font CSS variables', () => { + const fonts: FontConfig = { + family: 'Inter', + sizes: { body: 16, heading: 32 }, + }; + const vars = flattenFontVariables(fonts); + expect(vars['--st-font-family']).toBe('Inter'); + expect(vars['--st-font-size-body']).toBe('16px'); + expect(vars['--st-font-size-heading']).toBe('32px'); + }); +}); + +describe('generateCSSVariablesFromTheme', () => { + it('generates variables from a full theme', () => { + const vars = generateCSSVariablesFromTheme(darkTheme); + expect(vars['--st-primary']).toBe(darkTheme.colors.primary); + expect(vars['--st-background']).toBe(darkTheme.colors.background); + expect(vars['--st-primary-rgb']).toBeDefined(); + }); + + it('includes extended color variables when available', () => { + const themeWithExtended = { + ...darkTheme, + extendedColors: { + ...darkTheme.colors, + primaryLight: '#818cf8', + primaryDark: '#4f46e5', + onPrimary: '#ffffff', + secondaryLight: '#a78bfa', + secondaryDark: '#7c3aed', + onSecondary: '#ffffff', + accentLight: '#22d3ee', + accentDark: '#0891b2', + onAccent: '#ffffff', + successLight: '#6ee7b7', + successDark: '#059669', + onSuccess: '#ffffff', + warningLight: '#fbbf24', + warningDark: '#d97706', + onWarning: '#ffffff', + errorLight: '#fca5a5', + errorDark: '#dc2626', + onError: '#ffffff', + info: '#0ea5e9', + infoLight: '#38bdf8', + infoDark: '#0284c7', + onInfo: '#ffffff', + surfaceVariant: '#334155', + surfaceInverse: '#f8fafc', + textTertiary: '#94a3b8', + textDisabled: '#64748b', + borderLight: '#475569', + divider: '#334155', + scrim: 'rgba(0, 0, 0, 0.5)', + warningBackground: 'rgba(245, 158, 11, 0.16)', + errorBackground: 'rgba(239, 68, 68, 0.16)', + successBackground: 'rgba(16, 185, 129, 0.16)', + infoBackground: 'rgba(14, 165, 233, 0.16)', + }, + }; + const vars = generateCSSVariablesFromTheme(themeWithExtended); + expect(vars['--st-ext-primary-light']).toBe('#818cf8'); + expect(vars['--st-ext-scrim']).toBe('rgba(0, 0, 0, 0.5)'); + }); + + it('includes font variables when fonts are configured', () => { + const themeWithFonts = { + ...darkTheme, + fonts: { family: 'Inter' } as FontConfig, + }; + const vars = generateCSSVariablesFromTheme(themeWithFonts); + expect(vars['--st-font-family']).toBe('Inter'); + }); + + it('includes logo variables when logo is configured', () => { + const themeWithLogo = { + ...darkTheme, + logo: { uri: 'https://example.com/logo.png', width: 200, height: 100 }, + }; + const vars = generateCSSVariablesFromTheme(themeWithLogo); + expect(vars['--st-logo-uri']).toBe('url(https://example.com/logo.png)'); + expect(vars['--st-logo-width']).toBe('200px'); + expect(vars['--st-logo-height']).toBe('100px'); + }); +}); + +describe('cssVariablesToString', () => { + it('formats variables as CSS lines', () => { + const vars = { '--st-primary': '#ff0000', '--st-secondary': '#00ff00' }; + const result = cssVariablesToString(vars); + expect(result).toContain('--st-primary: #ff0000;'); + expect(result).toContain('--st-secondary: #00ff00;'); + }); +}); + +describe('generateCSSVariablesDeclaration', () => { + it('wraps variables in :root selector', () => { + const result = generateCSSVariablesDeclaration(darkTheme); + expect(result).toContain(':root {'); + expect(result).toContain('--st-primary:'); + expect(result).toContain('}'); + }); +}); + +describe('generateThemeStylesheet', () => { + it('generates full stylesheet with theme class', () => { + const result = generateThemeStylesheet(darkTheme); + expect(result).toContain('/* SubTrackr Theme: Dark (dark) */'); + expect(result).toContain(':root {'); + expect(result).toContain(`.theme-${darkTheme.id} {`); + }); +}); diff --git a/src/theme/__tests__/customThemeBuilder.test.ts b/src/theme/__tests__/customThemeBuilder.test.ts new file mode 100644 index 00000000..25460c57 --- /dev/null +++ b/src/theme/__tests__/customThemeBuilder.test.ts @@ -0,0 +1,212 @@ +import { + hexToRgb, + rgbToHex, + blendColor, + lightenColor, + darkenColor, + getContrastTextColor, + generateSemanticPalette, + generateExtendedColors, + generateSurfaceColors, + buildThemeFromConfig, + createThemeVariantPair, + inheritTheme, + generateUniqueThemeId, +} from '../customThemeBuilder'; +import { darkTheme } from '../themes'; +import type { ThemeConfig } from '../types'; + +describe('hexToRgb', () => { + it('converts hex to rgb', () => { + expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 }); + expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 }); + expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('handles shorthand hex', () => { + expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('returns null for invalid hex', () => { + expect(hexToRgb('invalid')).toBeNull(); + expect(hexToRgb('#gggggg')).toBeNull(); + }); +}); + +describe('rgbToHex', () => { + it('converts rgb to hex', () => { + expect(rgbToHex(255, 0, 0)).toBe('#ff0000'); + expect(rgbToHex(0, 255, 0)).toBe('#00ff00'); + }); + + it('clamps values', () => { + expect(rgbToHex(300, -1, 128)).toBe('#ff0080'); + }); +}); + +describe('blendColor', () => { + it('blends two colors', () => { + const result = blendColor('#ff0000', '#0000ff', 0.5); + const rgb = hexToRgb(result); + expect(rgb).not.toBeNull(); + expect(rgb!.r).toBeCloseTo(128, 0); + expect(rgb!.g).toBeCloseTo(0, 0); + expect(rgb!.b).toBeCloseTo(128, 0); + }); + + it('returns first color on invalid input', () => { + expect(blendColor('invalid', '#0000ff', 0.5)).toBe('invalid'); + }); +}); + +describe('lightenColor', () => { + it('lightens a color', () => { + const result = lightenColor('#000000', 50); + expect(hexToRgb(result)).toEqual({ r: 128, g: 128, b: 128 }); + }); +}); + +describe('darkenColor', () => { + it('darkens a color', () => { + const result = darkenColor('#ffffff', 50); + expect(hexToRgb(result)).toEqual({ r: 128, g: 128, b: 128 }); + }); +}); + +describe('getContrastTextColor', () => { + it('returns dark text for light backgrounds', () => { + expect(getContrastTextColor('#ffffff')).toBe('#0f172a'); + }); + + it('returns light text for dark backgrounds', () => { + expect(getContrastTextColor('#000000')).toBe('#f8fafc'); + }); +}); + +describe('generateSemanticPalette', () => { + it('generates semantic colors from primary', () => { + const palette = generateSemanticPalette('#6366f1', 'light'); + expect(palette.success).toBeDefined(); + expect(palette.warning).toBeDefined(); + expect(palette.error).toBeDefined(); + expect(palette.info).toBeDefined(); + }); + + it('handles invalid color gracefully', () => { + const palette = generateSemanticPalette('invalid', 'light'); + expect(palette.success).toBe('#10b981'); + }); +}); + +describe('generateExtendedColors', () => { + it('generates all extended color fields', () => { + const colors = { + primary: '#6366f1', + secondary: '#8b5cf6', + accent: '#06b6d4', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + background: '#0f172a', + surface: '#1e293b', + text: '#f8fafc', + textSecondary: '#cbd5e1', + border: '#334155', + overlay: 'rgba(15, 23, 42, 0.8)', + }; + const extended = generateExtendedColors(colors, 'dark'); + expect(extended.primaryLight).toBeDefined(); + expect(extended.primaryDark).toBeDefined(); + expect(extended.onPrimary).toBeDefined(); + expect(extended.scrim).toBeDefined(); + expect(extended.warningBackground).toContain('rgba'); + }); +}); + +describe('generateSurfaceColors', () => { + it('generates dark surface colors', () => { + const surfaces = generateSurfaceColors('#6366f1', 'dark'); + expect(surfaces.background).toBeDefined(); + expect(surfaces.surface).toBeDefined(); + expect(surfaces.text).toBe('#f8fafc'); + }); + + it('generates light surface colors', () => { + const surfaces = generateSurfaceColors('#6366f1', 'light'); + expect(surfaces.surface).toBe('#ffffff'); + expect(surfaces.text).toBe('#0f172a'); + }); + + it('falls back to default for invalid colors', () => { + const surfaces = generateSurfaceColors('invalid', 'dark'); + expect(surfaces.background).toBe('#0f172a'); + }); +}); + +describe('buildThemeFromConfig', () => { + it('builds a full theme from minimal config', () => { + const config: ThemeConfig = { + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }; + const theme = buildThemeFromConfig(config, 'dark', 'test-id', 'Test Theme'); + expect(theme.id).toBe('test-id-dark'); + expect(theme.mode).toBe('dark'); + expect(theme.colors.primary).toBe('#ff0000'); + expect(theme.colors.secondary).toBe('#00ff00'); + expect(theme.colors.accent).toBe('#0000ff'); + expect(theme.isCustom).toBe(true); + expect(theme.extendedColors).toBeDefined(); + expect(theme.createdAt).toBeDefined(); + }); + + it('uses defaults for missing colors', () => { + const config: ThemeConfig = { + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }; + const theme = buildThemeFromConfig(config, 'dark', 'test', 'Test'); + expect(theme.colors.success).toBeDefined(); + expect(theme.colors.background).toBeDefined(); + }); + + it('includes fonts and logo when provided', () => { + const config: ThemeConfig = { + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + fonts: { family: 'Inter' }, + }; + const theme = buildThemeFromConfig(config, 'light', 'test', 'Test'); + expect(theme.fonts?.family).toBe('Inter'); + }); +}); + +describe('createThemeVariantPair', () => { + it('creates light and dark variants', () => { + const config: ThemeConfig = { + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }; + const pair = createThemeVariantPair(config, 'brand-test', 'Test Brand'); + expect(pair.light.mode).toBe('light'); + expect(pair.dark.mode).toBe('dark'); + expect(pair.sharedConfig.id).toBe('brand-test'); + expect(pair.light.colors.primary).toBe('#ff0000'); + expect(pair.dark.colors.primary).toBe('#ff0000'); + }); +}); + +describe('inheritTheme', () => { + it('inherits from parent with overrides', () => { + const inherited = inheritTheme(darkTheme, { primary: '#ff0000' }); + expect(inherited.parentId).toBe('dark'); + expect(inherited.colors.primary).toBe('#ff0000'); + expect(inherited.colors.secondary).toBe(darkTheme.colors.secondary); + expect(inherited.isCustom).toBe(true); + }); +}); + +describe('generateUniqueThemeId', () => { + it('generates unique ids', () => { + const id1 = generateUniqueThemeId(); + const id2 = generateUniqueThemeId(); + expect(id1).not.toBe(id2); + expect(id1).toContain('custom-'); + }); +}); diff --git a/src/theme/__tests__/themeStore.test.ts b/src/theme/__tests__/themeStore.test.ts index 65b89089..fc3daefa 100644 --- a/src/theme/__tests__/themeStore.test.ts +++ b/src/theme/__tests__/themeStore.test.ts @@ -1,5 +1,6 @@ import { useThemeStore } from '../../theme/themeStore'; import { darkTheme } from '../../theme/themes'; +import type { ThemeConfig, ThemeExportData, ThemeVariantPair } from '../types'; const mockStore = new Map(); @@ -16,7 +17,12 @@ jest.mock('@react-native-async-storage/async-storage', () => ({ })); const reset = () => - useThemeStore.setState({ activeThemeId: darkTheme.id, customThemes: [], theme: darkTheme }); + useThemeStore.setState({ + activeThemeId: darkTheme.id, + customThemes: [], + themeVariantPairs: [], + theme: darkTheme, + }); beforeEach(() => { mockStore.clear(); @@ -35,18 +41,7 @@ describe('themeStore', () => { expect(useThemeStore.getState().theme.mode).toBe('light'); }); - it('toggleMode switches dark → light', () => { - useThemeStore.getState().toggleMode(); - expect(useThemeStore.getState().theme.mode).toBe('light'); - }); - - it('toggleMode switches light → dark', () => { - useThemeStore.getState().setTheme('light'); - useThemeStore.getState().toggleMode(); - expect(useThemeStore.getState().theme.mode).toBe('dark'); - }); - - it('addBrandTheme creates and activates a custom theme', () => { + it('addBrandTheme creates and activates a custom theme with accessibility info', () => { useThemeStore .getState() .addBrandTheme( @@ -58,6 +53,8 @@ describe('themeStore', () => { expect(s.activeThemeId).toBe('brand-x'); expect(s.theme.colors.primary).toBe('#aabbcc'); expect(s.customThemes).toHaveLength(1); + expect(s.theme.accessibility).toBeDefined(); + expect(s.theme.isCustom).toBe(true); }); it('removeCustomTheme falls back to dark', () => { @@ -74,7 +71,7 @@ describe('themeStore', () => { expect(s.activeThemeId).toBe('dark'); }); - it('allThemes returns built-in + custom', () => { + it('allThemes returns built-in + custom + variant pair themes', () => { useThemeStore .getState() .addBrandTheme( @@ -82,7 +79,7 @@ describe('themeStore', () => { 'brand-x', 'Brand X' ); - expect(useThemeStore.getState().allThemes()).toHaveLength(4); + expect(useThemeStore.getState().allThemes().length).toBeGreaterThanOrEqual(4); }); it('setTheme with unknown id falls back to dark', () => { @@ -90,8 +87,123 @@ describe('themeStore', () => { expect(useThemeStore.getState().theme.id).toBe('dark'); }); - it('lightTheme has correct mode', () => { + it('updateCustomTheme modifies theme and recomputes accessibility', () => { + const store = useThemeStore.getState(); + store.addBrandTheme( + { primary: '#aabbcc', secondary: '#112233', accent: '#445566' }, + 'brand-x', + 'Brand X' + ); + useThemeStore.getState().updateCustomTheme('brand-x', { + colors: { primary: '#ff0000', secondary: '#112233', accent: '#445566' }, + }); + const s = useThemeStore.getState(); + expect(s.theme.colors.primary).toBe('#ff0000'); + expect(s.theme.accessibility).toBeDefined(); + }); + + it('startPreview enters preview mode with original theme saved', () => { + useThemeStore.getState().startPreview({ + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }); + const s = useThemeStore.getState(); + expect(s.preview.isPreviewing).toBe(true); + expect(s.preview.originalThemeId).toBe('dark'); + expect(s.preview.previewConfig).toBeDefined(); + }); + + it('updatePreview updates preview config during preview', () => { + useThemeStore.getState().startPreview({ + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }); + useThemeStore.getState().updatePreview({ + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }); + const s = useThemeStore.getState(); + expect(s.preview.previewConfig?.colors?.primary).toBe('#ff0000'); + expect(s.preview.previewConfig?.colors?.secondary).toBe('#00ff00'); + }); + + it('applyPreview creates a custom theme from preview config', () => { + useThemeStore.getState().startPreview({ + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }); + useThemeStore.getState().applyPreview(); + const s = useThemeStore.getState(); + expect(s.preview.isPreviewing).toBe(false); + expect(s.customThemes.length).toBeGreaterThanOrEqual(1); + }); + + it('discardPreview restores original theme', () => { useThemeStore.getState().setTheme('light'); - expect(useThemeStore.getState().theme).toMatchObject({ id: 'light', mode: 'light' }); + useThemeStore.getState().startPreview({ + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }); + useThemeStore.getState().discardPreview(); + const s = useThemeStore.getState(); + expect(s.preview.isPreviewing).toBe(false); + expect(s.activeThemeId).toBe('light'); + }); + + it('exportTheme produces valid export data', () => { + const theme = useThemeStore.getState().theme; + const exported = useThemeStore.getState().exportTheme(theme); + expect(exported.version).toBe('1.0.0'); + expect(exported.exportedAt).toBeDefined(); + expect(exported.theme.shared).toBeDefined(); + }); + + it('importTheme loads a theme from export data', () => { + const exportData: ThemeExportData = { + version: '1.0.0', + exportedAt: new Date().toISOString(), + theme: { + light: { + colors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, + }, + shared: { id: 'imported-test', name: 'Imported Test' }, + }, + }; + useThemeStore.getState().importTheme(exportData); + const s = useThemeStore.getState(); + expect(s.activeThemeId).toContain('imported-test'); + expect(s.theme.colors.primary).toBe('#ff0000'); + expect(s.customThemes.length).toBeGreaterThanOrEqual(1); + }); + + it('addThemeVariantPair stores light/dark pair', () => { + const pair: ThemeVariantPair = { + light: { ...darkTheme, id: 'test-brand-light', mode: 'light', name: 'Test Light' }, + dark: { ...darkTheme, id: 'test-brand-dark', mode: 'dark', name: 'Test Dark' }, + sharedConfig: { id: 'test-brand', name: 'Test Brand' }, + }; + useThemeStore.getState().addThemeVariantPair(pair); + expect(useThemeStore.getState().themeVariantPairs).toHaveLength(1); + }); + + it('removeThemeVariantPair removes pair and falls back if active', () => { + const pair: ThemeVariantPair = { + light: { ...darkTheme, id: 'test-brand-light', mode: 'light', name: 'Test Light' }, + dark: { ...darkTheme, id: 'test-brand-dark', mode: 'dark', name: 'Test Dark' }, + sharedConfig: { id: 'test-brand', name: 'Test Brand' }, + }; + useThemeStore.getState().addThemeVariantPair(pair); + useThemeStore.getState().setTheme('test-brand-light'); + useThemeStore.getState().removeThemeVariantPair('test-brand'); + const s = useThemeStore.getState(); + expect(s.themeVariantPairs).toHaveLength(0); + expect(s.activeThemeId).toBe('dark'); + }); + + it('getVariantPair returns the correct pair', () => { + const pair: ThemeVariantPair = { + light: { ...darkTheme, id: 'test-brand-light', mode: 'light', name: 'Test Light' }, + dark: { ...darkTheme, id: 'test-brand-dark', mode: 'dark', name: 'Test Dark' }, + sharedConfig: { id: 'test-brand', name: 'Test Brand' }, + }; + useThemeStore.getState().addThemeVariantPair(pair); + const found = useThemeStore.getState().getVariantPair('test-brand'); + expect(found).toBeDefined(); + expect(found?.sharedConfig.name).toBe('Test Brand'); }); }); diff --git a/src/theme/__tests__/themes.test.ts b/src/theme/__tests__/themes.test.ts index f140667d..7ec0c256 100644 --- a/src/theme/__tests__/themes.test.ts +++ b/src/theme/__tests__/themes.test.ts @@ -1,4 +1,4 @@ -import { darkTheme, lightTheme, createBrandTheme } from '../../theme/themes'; +import { darkTheme, lightTheme, highContrastTheme, createBrandTheme } from '../../theme/themes'; describe('themes', () => { it('darkTheme has mode dark', () => { @@ -9,6 +9,12 @@ describe('themes', () => { expect(lightTheme.mode).toBe('light'); }); + it('highContrastTheme has high contrast colors', () => { + expect(highContrastTheme.id).toBe('high-contrast'); + expect(highContrastTheme.colors.background).toBe('#000000'); + expect(highContrastTheme.colors.text).toBe('#ffffff'); + }); + it('createBrandTheme overrides brand colors and preserves base', () => { const brand = { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }; const t = createBrandTheme(darkTheme, brand, 'test-brand', 'Test Brand'); @@ -17,8 +23,23 @@ describe('themes', () => { expect(t.colors.primary).toBe('#ff0000'); expect(t.colors.secondary).toBe('#00ff00'); expect(t.colors.accent).toBe('#0000ff'); - // non-brand colors preserved expect(t.colors.background).toBe(darkTheme.colors.background); expect(t.colors.error).toBe(darkTheme.colors.error); }); + + it('createBrandTheme generates extended colors', () => { + const brand = { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }; + const t = createBrandTheme(darkTheme, brand, 'test-brand', 'Test Brand'); + expect(t.extendedColors).toBeDefined(); + expect(t.extendedColors?.onPrimary).toBeDefined(); + expect(t.extendedColors?.primaryLight).toBeDefined(); + }); + + it('createBrandTheme marks theme as custom', () => { + const brand = { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }; + const t = createBrandTheme(darkTheme, brand, 'test-brand', 'Test Brand'); + expect(t.isCustom).toBe(true); + expect(t.createdAt).toBeDefined(); + expect(t.updatedAt).toBeDefined(); + }); }); diff --git a/src/theme/accessibility.ts b/src/theme/accessibility.ts new file mode 100644 index 00000000..e33b4661 --- /dev/null +++ b/src/theme/accessibility.ts @@ -0,0 +1,190 @@ +import type { Theme, ThemeColors, AccessibilityInfo, AccessibilityIssue } from './types'; +import { hexToRgb } from './customThemeBuilder'; + +export function relativeLuminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + + const toLinear = (c: number): number => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + + return 0.2126 * toLinear(rgb.r) + 0.7152 * toLinear(rgb.g) + 0.0722 * toLinear(rgb.b); +} + +export function contrastRatio(foreground: string, background: string): number { + const l1 = relativeLuminance(foreground); + const l2 = relativeLuminance(background); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +export function meetsWcagAA(ratio: number, isLargeText: boolean = false): boolean { + return ratio >= (isLargeText ? 3 : 4.5); +} + +export function meetsWcagAAA(ratio: number, isLargeText: boolean = false): boolean { + return ratio >= (isLargeText ? 4.5 : 7); +} + +export interface ContrastCheckPair { + name: string; + foreground: string; + background: string; + isLargeText?: boolean; +} + +const WCAG_CHECKS: ContrastCheckPair[] = [ + { name: 'text on background', foreground: 'text', background: 'background' }, + { name: 'text on surface', foreground: 'text', background: 'surface' }, + { name: 'textSecondary on background', foreground: 'textSecondary', background: 'background' }, + { name: 'textSecondary on surface', foreground: 'textSecondary', background: 'surface' }, + { name: 'primary on background', foreground: 'primary', background: 'background' }, + { name: 'primary on surface', foreground: 'primary', background: 'surface' }, + { name: 'button text on primary', foreground: 'onPrimary', background: 'primary', isLargeText: true }, +]; + +export function checkContrast( + colors: ThemeColors, + checks?: ContrastCheckPair[], +): { ratio: number; passesAA: boolean; passesAAA: boolean }[] { + const pairs = checks ?? WCAG_CHECKS; + return pairs.map((check) => { + const fg = colors[check.foreground as keyof ThemeColors] || '#000000'; + const bg = colors[check.background as keyof ThemeColors] || '#ffffff'; + const ratio = contrastRatio(fg, bg); + return { + ratio: Math.round(ratio * 100) / 100, + passesAA: meetsWcagAA(ratio, check.isLargeText), + passesAAA: meetsWcagAAA(ratio, check.isLargeText), + }; + }); +} + +export function getAccessibilityRating(theme: Theme): AccessibilityInfo { + const colors = theme.colors; + const issues: AccessibilityIssue[] = []; + + const checks = WCAG_CHECKS.map((check) => { + const fg = colors[check.foreground as keyof ThemeColors]; + const bg = colors[check.background as keyof ThemeColors]; + const ratio = contrastRatio(fg, bg); + return { ...check, ratio, fg, bg }; + }); + + let minRatio = Infinity; + let allPassAA = true; + let allPassAAA = true; + + for (const check of checks) { + minRatio = Math.min(minRatio, check.ratio); + + const passesAA = meetsWcagAA(check.ratio, check.isLargeText); + const passesAAA = meetsWcagAAA(check.ratio, check.isLargeText); + + if (!passesAA) allPassAA = false; + if (!passesAAA) allPassAAA = false; + + const required = check.isLargeText ? 3 : 4.5; + if (!passesAA) { + issues.push({ + type: 'contrast', + element: check.name, + foreground: check.fg, + background: check.bg, + ratio: Math.round(check.ratio * 100) / 100, + requiredRatio: check.isLargeText ? 3 : 4.5, + message: `Insufficient contrast: "${check.foreground}" on "${check.background}" — ${Math.round(check.ratio * 100) / 100}:1 (requires ${required}:1)`, + }); + } + } + + return { + contrastRatio: Math.round(minRatio * 100) / 100, + meetsWcagAA: allPassAA, + meetsWcagAAA: allPassAAA, + issues, + }; +} + +export function suggestContrastFix( + foreground: string, + background: string, +): { suggestedForeground: string; suggestedBackground: string } { + const ratio = contrastRatio(foreground, background); + if (ratio >= 4.5) return { suggestedForeground: foreground, suggestedBackground: background }; + + const fgRgb = hexToRgb(foreground); + const bgRgb = hexToRgb(background); + if (!fgRgb || !bgRgb) return { suggestedForeground: foreground, suggestedBackground: background }; + + const bgLuminance = relativeLuminance(background); + const targetLight = bgLuminance < 0.5 ? '#f8fafc' : '#0f172a'; + + const fgAdjust = bgLuminance < 0.5 + ? lightenToRatio(foreground, background, 4.5) + : darkenToRatio(foreground, background, 4.5); + + return { suggestedForeground: fgAdjust, suggestedBackground: background }; +} + +export function lightenToRatio(foreground: string, background: string, targetRatio: number): string { + const bgLum = relativeLuminance(background); + const targetLum = targetRatio * (bgLum + 0.05) - 0.05; + const clampedLum = Math.min(1, Math.max(0, targetLum)); + const rgb = hexToRgb(foreground); + if (!rgb) return foreground; + + const toSRGB = (c: number): number => { + const linear = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + const adjusted = Math.min(1, linear + (clampedLum - relativeLuminance(foreground))); + return adjusted <= 0.0031308 + ? 12.92 * adjusted + : 1.055 * Math.pow(adjusted, 1 / 2.4) - 0.055; + }; + + const r = Math.round(toSRGB(rgb.r / 255) * 255); + const g = Math.round(toSRGB(rgb.g / 255) * 255); + const b = Math.round(toSRGB(rgb.b / 255) * 255); + + const toHex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +export function darkenToRatio(foreground: string, background: string, targetRatio: number): string { + const bgLum = relativeLuminance(background); + const targetLum = (bgLum + 0.05) / targetRatio - 0.05; + const clampedLum = Math.min(1, Math.max(0, targetLum)); + const rgb = hexToRgb(foreground); + if (!rgb) return foreground; + + const toSRGB = (c: number): number => { + const linear = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + const adjusted = Math.max(0, linear - (relativeLuminance(foreground) - clampedLum)); + return adjusted <= 0.0031308 + ? 12.92 * adjusted + : 1.055 * Math.pow(adjusted, 1 / 2.4) - 0.055; + }; + + const r = Math.round(toSRGB(rgb.r / 255) * 255); + const g = Math.round(toSRGB(rgb.g / 255) * 255); + const b = Math.round(toSRGB(rgb.b / 255) * 255); + + const toHex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +export function isColorReadable( + foreground: string, + background: string, + level?: 'AA' | 'AAA', + isLargeText?: boolean, +): boolean { + const ratio = contrastRatio(foreground, background); + if (level === 'AAA') return meetsWcagAAA(ratio, isLargeText); + return meetsWcagAA(ratio, isLargeText); +} + +export const THEME_CONTRAST_CHECKS = WCAG_CHECKS; diff --git a/src/theme/cssVariables.ts b/src/theme/cssVariables.ts new file mode 100644 index 00000000..d1867b06 --- /dev/null +++ b/src/theme/cssVariables.ts @@ -0,0 +1,111 @@ +import type { Theme, ExtendedThemeColors, FontConfig } from './types'; +import { hexToRgb } from './customThemeBuilder'; + +export interface CSSVariables { + [key: string]: string; +} + +const PREFIX = '--st'; + +export function flattenColorsToVariables( + colors: Record, + prefix: string = PREFIX, +): CSSVariables { + const vars: CSSVariables = {}; + const sep = prefix.endsWith('-') ? '' : '-'; + for (const [key, value] of Object.entries(colors)) { + const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + vars[`${prefix}${sep}${cssKey}`] = value; + const rgb = hexToRgb(value); + if (rgb) { + vars[`${prefix}${sep}${cssKey}-rgb`] = `${rgb.r}, ${rgb.g}, ${rgb.b}`; + } + } + return vars; +} + +export function flattenFontVariables(fonts: FontConfig, prefix: string = PREFIX): CSSVariables { + const vars: CSSVariables = {}; + if (fonts.family) { + vars[`${prefix}-font-family`] = fonts.family; + } + if (fonts.url) { + vars[`${prefix}-font-url`] = fonts.url; + } + if (fonts.sizes) { + if (fonts.sizes.body) vars[`${prefix}-font-size-body`] = `${fonts.sizes.body}px`; + if (fonts.sizes.small) vars[`${prefix}-font-size-small`] = `${fonts.sizes.small}px`; + if (fonts.sizes.large) vars[`${prefix}-font-size-large`] = `${fonts.sizes.large}px`; + if (fonts.sizes.heading) vars[`${prefix}-font-size-heading`] = `${fonts.sizes.heading}px`; + } + return vars; +} + +export function generateCSSVariablesFromTheme(theme: Theme): CSSVariables { + const vars: CSSVariables = {}; + + Object.assign(vars, flattenColorsToVariables(theme.colors as unknown as Record)); + + if (theme.extendedColors) { + Object.assign(vars, flattenColorsToVariables(theme.extendedColors as unknown as Record, `${PREFIX}-ext-`)); + } + + if (theme.fonts) { + Object.assign(vars, flattenFontVariables(theme.fonts)); + } + + if (theme.logo) { + if (theme.logo.uri) vars[`${PREFIX}-logo-uri`] = `url(${theme.logo.uri})`; + if (theme.logo.width) vars[`${PREFIX}-logo-width`] = `${theme.logo.width}px`; + if (theme.logo.height) vars[`${PREFIX}-logo-height`] = `${theme.logo.height}px`; + } + + return vars; +} + +export function cssVariablesToString(vars: CSSVariables): string { + return Object.entries(vars) + .map(([key, value]) => ` ${key}: ${value};`) + .join('\n'); +} + +export function generateCSSVariablesDeclaration(theme: Theme): string { + const vars = generateCSSVariablesFromTheme(theme); + return `:root {\n${cssVariablesToString(vars)}\n}`; +} + +export function generateThemeStylesheet(theme: Theme): string { + const vars = generateCSSVariablesFromTheme(theme); + const lines: string[] = [ + `/* SubTrackr Theme: ${theme.name} (${theme.mode}) */`, + `/* Theme ID: ${theme.id} */`, + `/* Generated: ${new Date().toISOString()} */`, + '', + `:root {`, + ]; + + for (const [key, value] of Object.entries(vars)) { + lines.push(` ${key}: ${value};`); + } + + lines.push('}', ''); + lines.push(`.theme-${theme.id} {`); + + for (const [key, value] of Object.entries(vars)) { + lines.push(` ${key}: ${value};`); + } + + lines.push('}'); + + return lines.join('\n'); +} + +export function buildStyleObjectFromTheme(theme: Theme): Record { + const vars = generateCSSVariablesFromTheme(theme); + const style: Record = {}; + for (const [key, value] of Object.entries(vars)) { + const reactKey = key.replace(PREFIX, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + style[reactKey] = value; + } + return style; +} diff --git a/src/theme/customThemeBuilder.ts b/src/theme/customThemeBuilder.ts new file mode 100644 index 00000000..984df78f --- /dev/null +++ b/src/theme/customThemeBuilder.ts @@ -0,0 +1,264 @@ +import { lightTheme, darkTheme, highContrastTheme } from './themes'; +import type { + Theme, + ThemeMode, + ThemeConfig, + ThemeColors, + ExtendedThemeColors, + FontConfig, + LogoConfig, + ThemeVariantPair, + ThemeSharedConfig, +} from './types'; + +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + if (!hex || !hex.startsWith('#') || hex.length < 4) return null; + const clean = hex.replace('#', ''); + if (clean.length !== 6 && clean.length !== 3) return null; + const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean; + const num = parseInt(full, 16); + if (isNaN(num)) return null; + return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 }; +} + +export function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +export function blendColor(hex1: string, hex2: string, weight: number): string { + const c1 = hexToRgb(hex1); + const c2 = hexToRgb(hex2); + if (!c1 || !c2) return hex1; + const w = Math.max(0, Math.min(1, weight)); + return rgbToHex(c1.r * (1 - w) + c2.r * w, c1.g * (1 - w) + c2.g * w, c1.b * (1 - w) + c2.b * w); +} + +export function lightenColor(hex: string, percent: number): string { + return blendColor(hex, '#ffffff', percent / 100); +} + +export function darkenColor(hex: string, percent: number): string { + return blendColor(hex, '#000000', percent / 100); +} + +export function getContrastTextColor(hex: string): string { + const rgb = hexToRgb(hex); + if (!rgb) return '#ffffff'; + const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + return luminance > 0.5 ? '#0f172a' : '#f8fafc'; +} + +export function generateSemanticPalette(primary: string, mode: ThemeMode): { + success: string; + warning: string; + error: string; + info: string; +} { + const base = hexToRgb(primary); + if (!base) { + return { success: '#10b981', warning: '#f59e0b', error: '#ef4444', info: '#3b82f6' }; + } + const isDark = mode === 'dark'; + const saturation = isDark ? 0.85 : 0.7; + return { + success: rgbToHex( + Math.round(base.r * (1 - saturation) + (isDark ? 16 : 5) * saturation), + Math.round(base.g * (1 - saturation) + (isDark ? 185 : 150) * saturation), + Math.round(base.b * (1 - saturation) + (isDark ? 129 : 105) * saturation), + ), + warning: rgbToHex( + Math.round(base.r * (1 - saturation) + (isDark ? 245 : 217) * saturation), + Math.round(base.g * (1 - saturation) + (isDark ? 158 : 119) * saturation), + Math.round(base.b * (1 - saturation) + (isDark ? 11 : 6) * saturation), + ), + error: rgbToHex( + Math.round(base.r * (1 - saturation) + 239 * saturation), + Math.round(base.g * (1 - saturation) + 68 * saturation), + Math.round(base.b * (1 - saturation) + 68 * saturation), + ), + info: rgbToHex( + Math.round(base.r * (1 - saturation) + 59 * saturation), + Math.round(base.g * (1 - saturation) + 130 * saturation), + Math.round(base.b * (1 - saturation) + 246 * saturation), + ), + }; +} + +export function generateExtendedColors(colors: ThemeColors, mode: ThemeMode): ExtendedThemeColors { + const isDark = mode === 'dark'; + const lightAmt = isDark ? 20 : 15; + const darkAmt = isDark ? 15 : 20; + return { + ...colors, + primaryLight: lightenColor(colors.primary, lightAmt), + primaryDark: darkenColor(colors.primary, darkAmt), + onPrimary: getContrastTextColor(colors.primary), + secondaryLight: lightenColor(colors.secondary, lightAmt), + secondaryDark: darkenColor(colors.secondary, darkAmt), + onSecondary: getContrastTextColor(colors.secondary), + accentLight: lightenColor(colors.accent, lightAmt), + accentDark: darkenColor(colors.accent, darkAmt), + onAccent: getContrastTextColor(colors.accent), + successLight: lightenColor(colors.success, lightAmt), + successDark: darkenColor(colors.success, darkAmt), + onSuccess: getContrastTextColor(colors.success), + warningLight: lightenColor(colors.warning, lightAmt), + warningDark: darkenColor(colors.warning, darkAmt), + onWarning: getContrastTextColor(colors.warning), + errorLight: lightenColor(colors.error, lightAmt), + errorDark: darkenColor(colors.error, darkAmt), + onError: getContrastTextColor(colors.error), + info: colors.accent, + infoLight: lightenColor(colors.accent, lightAmt), + infoDark: darkenColor(colors.accent, darkAmt), + onInfo: getContrastTextColor(colors.accent), + surfaceVariant: isDark ? lightenColor(colors.surface, 10) : darkenColor(colors.surface, 5), + surfaceInverse: isDark ? '#f8fafc' : '#1e293b', + textTertiary: isDark ? '#94a3b8' : '#64748b', + textDisabled: isDark ? '#64748b' : '#cbd5e1', + borderLight: isDark ? lightenColor(colors.border, 10) : '#f1f5f9', + divider: colors.border, + scrim: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.5)', + warningBackground: hexToRgb(colors.warning) + ? `rgba(${hexToRgb(colors.warning)!.r}, ${hexToRgb(colors.warning)!.g}, ${hexToRgb(colors.warning)!.b}, ${isDark ? 0.16 : 0.1})` + : 'rgba(245, 158, 11, 0.16)', + errorBackground: hexToRgb(colors.error) + ? `rgba(${hexToRgb(colors.error)!.r}, ${hexToRgb(colors.error)!.g}, ${hexToRgb(colors.error)!.b}, ${isDark ? 0.16 : 0.1})` + : 'rgba(239, 68, 68, 0.16)', + successBackground: hexToRgb(colors.success) + ? `rgba(${hexToRgb(colors.success)!.r}, ${hexToRgb(colors.success)!.g}, ${hexToRgb(colors.success)!.b}, ${isDark ? 0.16 : 0.1})` + : 'rgba(16, 185, 129, 0.16)', + infoBackground: hexToRgb(colors.accent) + ? `rgba(${hexToRgb(colors.accent)!.r}, ${hexToRgb(colors.accent)!.g}, ${hexToRgb(colors.accent)!.b}, ${isDark ? 0.16 : 0.1})` + : 'rgba(14, 165, 233, 0.16)', + }; +} + +export function generateSurfaceColors(primary: string, mode: ThemeMode): { + background: string; + surface: string; + text: string; + textSecondary: string; + border: string; +} { + const isDark = mode === 'dark'; + const rgb = hexToRgb(primary); + if (!rgb) { + return isDark + ? { background: '#0f172a', surface: '#1e293b', text: '#f8fafc', textSecondary: '#cbd5e1', border: '#334155' } + : { background: '#f8fafc', surface: '#ffffff', text: '#0f172a', textSecondary: '#475569', border: '#e2e8f0' }; + } + const avg = (rgb.r + rgb.g + rgb.b) / 3; + if (isDark) { + const bgBase = Math.max(0, avg * 0.06); + return { + background: `hsl(222, 47%, ${Math.max(4, bgBase)}%)`, + surface: `hsl(222, 43%, ${Math.max(10, bgBase + 8)}%)`, + text: '#f8fafc', + textSecondary: '#cbd5e1', + border: `hsl(222, 30%, ${Math.max(16, bgBase + 14)}%)`, + }; + } + const bgBase = Math.min(97, avg * 0.38 + 60); + return { + background: `hsl(222, 50%, ${bgBase}%)`, + surface: '#ffffff', + text: '#0f172a', + textSecondary: '#475569', + border: `hsl(222, 30%, ${bgBase - 8}%)`, + }; +} + +type ColorDefaults = Record; + +export function buildThemeFromConfig( + config: Partial, + mode: ThemeMode, + baseId: string, + themeName: string, +): Theme { + const colors: ColorDefaults = config.colors || {}; + const primary = colors.primary || '#6366f1'; + const secondary = colors.secondary || '#8b5cf6'; + const accent = colors.accent || (mode === 'dark' ? '#06b6d4' : '#0891b2'); + + const semantic = generateSemanticPalette(primary, mode); + const surfaces = generateSurfaceColors(primary, mode); + + const themeColors: ThemeColors = { + primary, + secondary, + accent, + success: colors.success || semantic.success, + warning: colors.warning || semantic.warning, + error: colors.error || semantic.error, + background: colors.background || surfaces.background, + surface: colors.surface || surfaces.surface, + text: colors.text || surfaces.text, + textSecondary: colors.textSecondary || surfaces.textSecondary, + border: surfaces.border, + overlay: mode === 'dark' ? 'rgba(15, 23, 42, 0.8)' : 'rgba(248, 250, 252, 0.8)', + }; + + const extended = generateExtendedColors(themeColors, mode); + + const id = `${baseId}-${mode}`; + + return { + id, + name: mode === 'dark' ? `${themeName} Dark` : `${themeName} Light`, + mode, + colors: themeColors, + extendedColors: extended, + fonts: config.fonts, + logo: config.logo, + isCustom: true, + metadata: config.metadata, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +export function createThemeVariantPair( + config: ThemeConfig, + id: string, + name: string, + shared?: Partial, +): ThemeVariantPair { + const sharedConfig: ThemeSharedConfig = { + id, + name, + fonts: config.fonts, + logo: config.logo, + metadata: config.metadata, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...shared, + }; + + return { + light: buildThemeFromConfig(config, 'light', id, name), + dark: buildThemeFromConfig(config, 'dark', id, name), + sharedConfig, + }; +} + +export function inheritTheme(parent: Theme, overrides: Partial): Theme { + return { + ...parent, + id: `${parent.id}-inherited`, + name: `${parent.name} (Inherited)`, + colors: { ...parent.colors, ...overrides }, + extendedColors: parent.extendedColors + ? generateExtendedColors({ ...parent.colors, ...overrides }, parent.mode) + : undefined, + parentId: parent.id, + isCustom: true, + updatedAt: new Date().toISOString(), + }; +} + +export function generateUniqueThemeId(): string { + return `custom-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; +} diff --git a/src/theme/index.ts b/src/theme/index.ts index 55fefd45..78be9be8 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,6 +1,47 @@ export { useTheme } from './useTheme'; export { useThemeColors } from '../hooks/useThemeColors'; export { useThemeStore } from './themeStore'; -export { darkTheme, lightTheme, builtInThemes, createBrandTheme } from './themes'; -export type { Theme, ThemeColors, ThemeMode, BrandConfig } from './types'; +export { darkTheme, lightTheme, highContrastTheme, builtInThemes, createBrandTheme } from './themes'; +export { + buildThemeFromConfig, + createThemeVariantPair, + inheritTheme, + generateUniqueThemeId, + hexToRgb, + blendColor, + lightenColor, + darkenColor, + generateExtendedColors, +} from './customThemeBuilder'; +export { + generateCSSVariablesFromTheme, + generateCSSVariablesDeclaration, + generateThemeStylesheet, + cssVariablesToString, +} from './cssVariables'; +export { + contrastRatio, + getAccessibilityRating, + meetsWcagAA, + meetsWcagAAA, + isColorReadable, + suggestContrastFix, +} from './accessibility'; +export type { + Theme, + ThemeColors, + ExtendedThemeColors, + ThemeMode, + BrandConfig, + FontConfig, + LogoConfig, + ThemeConfig, + ThemeExportData, + ThemeVariantPair, + ThemeSharedConfig, + ThemePreviewState, + ThemeInheritance, + AccessibilityInfo, + AccessibilityIssue, +} from './types'; export { ThemeProvider } from '../context/ThemeContext'; diff --git a/src/theme/themeStore.ts b/src/theme/themeStore.ts index 74fd674d..fcb6605f 100644 --- a/src/theme/themeStore.ts +++ b/src/theme/themeStore.ts @@ -1,24 +1,64 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { asyncStorageAdapter } from '../utils/storage'; -import { darkTheme, lightTheme, builtInThemes, createBrandTheme } from './themes'; -import type { Theme, BrandConfig } from './types'; +import { darkTheme, lightTheme, highContrastTheme, builtInThemes, createBrandTheme } from './themes'; +import { buildThemeFromConfig, createThemeVariantPair, generateUniqueThemeId, inheritTheme } from './customThemeBuilder'; +import { getAccessibilityRating } from './accessibility'; +import { themeService } from '../services/themeService'; +import type { + Theme, + ThemeConfig, + BrandConfig, + ThemeMode, + ThemeVariantPair, + ThemeExportData, + ThemePreviewState, + ThemeColors, +} from './types'; + +export type StoreThemeMode = ThemeMode | 'system'; interface ThemeState { activeThemeId: string; customThemes: Theme[]; - // derived — always computed from activeThemeId + customThemes + themeVariantPairs: ThemeVariantPair[]; theme: Theme; + preview: ThemePreviewState; + lastSyncedAt: string | null; + isSyncing: boolean; setTheme: (id: string) => void; - toggleMode: () => void; - addBrandTheme: (brand: BrandConfig, id: string, name: string) => void; + addBrandTheme: (brand: BrandConfig, id: string, name: string) => Theme; + updateCustomTheme: (id: string, config: Partial) => void; removeCustomTheme: (id: string) => void; + + addThemeVariantPair: (pair: ThemeVariantPair) => void; + removeThemeVariantPair: (pairId: string) => void; + + startPreview: (config: Partial) => void; + updatePreview: (config: Partial) => void; + applyPreview: () => void; + discardPreview: () => void; + + exportTheme: (theme: Theme) => ThemeExportData; + importTheme: (data: ThemeExportData) => void; + + syncToApi: () => Promise; + syncFromApi: () => Promise; + allThemes: () => Theme[]; + getThemeById: (id: string) => Theme | undefined; + getVariantPair: (pairId: string) => ThemeVariantPair | undefined; +} + +function fullThemeList(custom: Theme[], variantPairs: ThemeVariantPair[]): Theme[] { + const pairThemes = variantPairs.flatMap((p) => [p.light, p.dark]); + return [...builtInThemes, ...custom, ...pairThemes]; } -function resolveTheme(id: string, custom: Theme[]): Theme { - return [...builtInThemes, ...custom].find((t) => t.id === id) ?? darkTheme; +function resolveTheme(id: string, custom: Theme[], variantPairs: ThemeVariantPair[]): Theme { + const all = fullThemeList(custom, variantPairs); + return all.find((t) => t.id === id) ?? darkTheme; } export const useThemeStore = create()( @@ -26,48 +66,258 @@ export const useThemeStore = create()( (set, get) => ({ activeThemeId: darkTheme.id, customThemes: [], + themeVariantPairs: [], theme: darkTheme, + preview: { isPreviewing: false, previewConfig: null, originalThemeId: null }, + lastSyncedAt: null, + isSyncing: false, setTheme(id) { - const theme = resolveTheme(id, get().customThemes); + const theme = resolveTheme(id, get().customThemes, get().themeVariantPairs); set({ activeThemeId: id, theme }); }, - toggleMode() { - const current = get().theme; - const target = current.mode === 'dark' ? lightTheme : darkTheme; - set({ activeThemeId: target.id, theme: target }); - }, - addBrandTheme(brand, id, name) { const base = get().theme.mode === 'dark' ? darkTheme : lightTheme; const newTheme = createBrandTheme(base, brand, id, name); - set((s) => ({ - customThemes: [...s.customThemes.filter((t) => t.id !== id), newTheme], - activeThemeId: id, - theme: newTheme, - })); + const accessible = getAccessibilityRating(newTheme); + const themeWithA11y = { ...newTheme, accessibility: accessible, isCustom: true }; + + set((s) => { + const customThemes = [...s.customThemes.filter((t) => t.id !== id), themeWithA11y]; + return { customThemes, activeThemeId: id, theme: themeWithA11y }; + }); + return themeWithA11y; + }, + + updateCustomTheme(id, config) { + set((s) => { + const idx = s.customThemes.findIndex((t) => t.id === id); + if (idx === -1) return s; + + const current = s.customThemes[idx]; + const updatedTheme = buildThemeFromConfig( + { + colors: { + primary: config.colors?.primary || current.colors.primary, + secondary: config.colors?.secondary || current.colors.secondary, + accent: config.colors?.accent || current.colors.accent, + success: config.colors?.success || current.colors.success, + warning: config.colors?.warning || current.colors.warning, + error: config.colors?.error || current.colors.error, + background: config.colors?.background || current.colors.background, + surface: config.colors?.surface || current.colors.surface, + text: config.colors?.text || current.colors.text, + textSecondary: config.colors?.textSecondary || current.colors.textSecondary, + }, + fonts: config.fonts || current.fonts, + logo: config.logo || current.logo, + metadata: config.metadata || current.metadata, + }, + current.mode, + id, + current.name.replace(/\s*(Light|Dark)$/, ''), + ); + + const accessible = getAccessibilityRating(updatedTheme); + const themeWithA11y = { ...updatedTheme, accessibility: accessible }; + + const customThemes = [...s.customThemes]; + customThemes[idx] = themeWithA11y; + + const newState: Partial = { customThemes }; + if (s.activeThemeId === id) { + newState.activeThemeId = id; + newState.theme = themeWithA11y; + } + return newState as ThemeState; + }); }, removeCustomTheme(id) { set((s) => { const customThemes = s.customThemes.filter((t) => t.id !== id); const activeThemeId = s.activeThemeId === id ? darkTheme.id : s.activeThemeId; - return { customThemes, activeThemeId, theme: resolveTheme(activeThemeId, customThemes) }; + const theme = resolveTheme(activeThemeId, customThemes, s.themeVariantPairs); + return { customThemes, activeThemeId, theme }; }); }, + addThemeVariantPair(pair) { + set((s) => { + const existing = s.themeVariantPairs.findIndex((p) => p.sharedConfig.id === pair.sharedConfig.id); + const themeVariantPairs = [...s.themeVariantPairs]; + if (existing >= 0) { + themeVariantPairs[existing] = pair; + } else { + themeVariantPairs.push(pair); + } + return { themeVariantPairs }; + }); + }, + + removeThemeVariantPair(pairId) { + set((s) => { + const pair = s.themeVariantPairs.find((p) => p.sharedConfig.id === pairId); + const variantIds = pair ? [pair.light.id, pair.dark.id] : []; + const themeVariantPairs = s.themeVariantPairs.filter((p) => p.sharedConfig.id !== pairId); + const activeThemeId = variantIds.includes(s.activeThemeId) ? darkTheme.id : s.activeThemeId; + const theme = resolveTheme(activeThemeId, s.customThemes, themeVariantPairs); + return { themeVariantPairs, activeThemeId, theme }; + }); + }, + + startPreview(config) { + set({ + preview: { + isPreviewing: true, + previewConfig: config, + originalThemeId: get().activeThemeId, + }, + }); + }, + + updatePreview(config) { + set((s) => { + if (!s.preview.isPreviewing) return s; + return { + preview: { + ...s.preview, + previewConfig: { ...s.preview.previewConfig, ...config } as ThemeConfig, + }, + }; + }); + }, + + applyPreview() { + const { preview } = get(); + if (!preview.isPreviewing || !preview.previewConfig) return; + + const id = generateUniqueThemeId(); + const theme = buildThemeFromConfig(preview.previewConfig, get().theme.mode, id, 'Preview'); + set((s) => ({ + customThemes: [...s.customThemes, theme], + activeThemeId: id, + theme, + preview: { isPreviewing: false, previewConfig: null, originalThemeId: null }, + })); + }, + + discardPreview() { + const { preview } = get(); + if (!preview.isPreviewing) return; + const originalId = preview.originalThemeId || darkTheme.id; + const theme = resolveTheme(originalId, get().customThemes, get().themeVariantPairs); + set({ + activeThemeId: originalId, + theme, + preview: { isPreviewing: false, previewConfig: null, originalThemeId: null }, + }); + }, + + exportTheme(theme) { + return { + version: '1.0.0', + exportedAt: new Date().toISOString(), + theme: { + [theme.mode === 'dark' ? 'dark' : 'light']: { + colors: { ...theme.colors }, + fonts: theme.fonts, + logo: theme.logo, + metadata: theme.metadata, + }, + shared: { + id: theme.id, + name: theme.name, + fonts: theme.fonts, + logo: theme.logo, + metadata: theme.metadata, + createdAt: theme.createdAt, + updatedAt: theme.updatedAt, + }, + }, + }; + }, + + importTheme(data) { + const { shared } = data.theme; + const modeConfig = data.theme.light || data.theme.dark; + if (!modeConfig) return; + + const mode: ThemeMode = data.theme.dark ? 'dark' : 'light'; + const id = shared.id || `imported-${Date.now()}`; + const theme = buildThemeFromConfig(modeConfig, mode, id, shared.name || 'Imported Theme'); + const accessible = getAccessibilityRating(theme); + const themeWithA11y = { ...theme, accessibility: accessible }; + + set((s) => ({ + customThemes: [...s.customThemes.filter((t) => t.id !== id), themeWithA11y], + activeThemeId: id, + theme: themeWithA11y, + })); + }, + + syncToApi: async () => { + set({ isSyncing: true }); + try { + const { customThemes, themeVariantPairs } = get(); + const allCustom = [...customThemes]; + await themeService.syncThemesToRemote(allCustom); + for (const pair of themeVariantPairs) { + await themeService.saveThemeVariantPair(pair); + } + set({ lastSyncedAt: new Date().toISOString(), isSyncing: false }); + } catch { + set({ isSyncing: false }); + } + }, + + syncFromApi: async () => { + set({ isSyncing: true }); + try { + const result = await themeService.fetchThemes(); + if (result.success && result.data) { + const customThemes: Theme[] = []; + for (const record of result.data) { + const theme = buildThemeFromConfig(record.config, 'dark', record.id, record.name); + const accessible = getAccessibilityRating(theme); + customThemes.push({ ...theme, accessibility: accessible }); + } + const activeThemeId = get().activeThemeId; + const theme = resolveTheme(activeThemeId, customThemes, get().themeVariantPairs); + set({ customThemes, theme, lastSyncedAt: new Date().toISOString(), isSyncing: false }); + } else { + set({ isSyncing: false }); + } + } catch { + set({ isSyncing: false }); + } + }, + allThemes() { - return [...builtInThemes, ...get().customThemes]; + return fullThemeList(get().customThemes, get().themeVariantPairs); + }, + + getThemeById(id) { + return fullThemeList(get().customThemes, get().themeVariantPairs).find((t) => t.id === id); + }, + + getVariantPair(pairId) { + return get().themeVariantPairs.find((p) => p.sharedConfig.id === pairId); }, }), { name: 'subtrackr-theme', storage: createJSONStorage(() => asyncStorageAdapter), - partialize: (s) => ({ activeThemeId: s.activeThemeId, customThemes: s.customThemes }), + partialize: (s) => ({ + activeThemeId: s.activeThemeId, + customThemes: s.customThemes, + themeVariantPairs: s.themeVariantPairs, + lastSyncedAt: s.lastSyncedAt, + }), onRehydrateStorage: () => (state) => { if (state) { - state.theme = resolveTheme(state.activeThemeId, state.customThemes); + state.theme = resolveTheme(state.activeThemeId, state.customThemes, state.themeVariantPairs); } }, } diff --git a/src/theme/themes.ts b/src/theme/themes.ts index 99afe5a9..529009d2 100644 --- a/src/theme/themes.ts +++ b/src/theme/themes.ts @@ -1,4 +1,5 @@ -import type { Theme, BrandConfig } from './types'; +import type { Theme, BrandConfig, ExtendedThemeColors } from './types'; +import { generateExtendedColors } from './customThemeBuilder'; export const darkTheme: Theme = { id: 'dark', @@ -40,10 +41,6 @@ export const lightTheme: Theme = { }, }; -/** - * High contrast theme for users who need stronger visual differentiation. - * Uses pure black/white backgrounds with high-saturation accent colors. - */ export const highContrastTheme: Theme = { id: 'high-contrast', name: 'High Contrast', @@ -66,9 +63,8 @@ export const highContrastTheme: Theme = { export const builtInThemes: Theme[] = [darkTheme, lightTheme, highContrastTheme]; -/** Create a brand theme by overriding brand colors on top of a base theme */ export function createBrandTheme(base: Theme, brand: BrandConfig, id: string, name: string): Theme { - return { + const theme: Theme = { ...base, id, name, @@ -78,5 +74,13 @@ export function createBrandTheme(base: Theme, brand: BrandConfig, id: string, na secondary: brand.secondary, accent: brand.accent, }, + fonts: brand.fonts, + logo: brand.logo, + isCustom: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; + + theme.extendedColors = generateExtendedColors(theme.colors, theme.mode); + return theme; } diff --git a/src/theme/types.ts b/src/theme/types.ts index d03834b0..e0693b6f 100644 --- a/src/theme/types.ts +++ b/src/theme/types.ts @@ -1,5 +1,3 @@ -// Theme type definitions - export interface ThemeColors { primary: string; secondary: string; @@ -15,17 +13,163 @@ export interface ThemeColors { overlay: string; } +export interface ExtendedThemeColors extends ThemeColors { + primaryLight: string; + primaryDark: string; + onPrimary: string; + secondaryLight: string; + secondaryDark: string; + onSecondary: string; + accentLight: string; + accentDark: string; + onAccent: string; + successLight: string; + successDark: string; + onSuccess: string; + warningLight: string; + warningDark: string; + onWarning: string; + errorLight: string; + errorDark: string; + onError: string; + info: string; + infoLight: string; + infoDark: string; + onInfo: string; + surfaceVariant: string; + surfaceInverse: string; + textTertiary: string; + textDisabled: string; + borderLight: string; + divider: string; + scrim: string; + warningBackground: string; + errorBackground: string; + successBackground: string; + infoBackground: string; +} + export type ThemeMode = 'light' | 'dark'; +export interface FontConfig { + family?: string; + url?: string; + weights?: { + light?: number; + normal?: number; + medium?: number; + semibold?: number; + bold?: number; + }; + sizes?: { + small?: number; + body?: number; + large?: number; + heading?: number; + }; +} + +export interface LogoConfig { + uri?: string; + darkUri?: string; + width?: number; + height?: number; + altText?: string; +} + +export interface AccessibilityInfo { + contrastRatio: number; + meetsWcagAA: boolean; + meetsWcagAAA: boolean; + issues: AccessibilityIssue[]; +} + +export interface AccessibilityIssue { + type: 'contrast' | 'touch-target' | 'font-size'; + element: string; + foreground: string; + background: string; + ratio: number; + requiredRatio: number; + message: string; +} + export interface Theme { id: string; name: string; mode: ThemeMode; colors: ThemeColors; + extendedColors?: ExtendedThemeColors; + fonts?: FontConfig; + logo?: LogoConfig; + isCustom?: boolean; + parentId?: string; + accessibility?: AccessibilityInfo; + metadata?: Record; + createdAt?: string; + updatedAt?: string; +} + +export interface ThemeVariantPair { + light: Theme; + dark: Theme; + sharedConfig: ThemeSharedConfig; +} + +export interface ThemeSharedConfig { + id: string; + name: string; + fonts?: FontConfig; + logo?: LogoConfig; + metadata?: Record; + createdAt?: string; + updatedAt?: string; } export interface BrandConfig { primary: string; secondary: string; accent: string; + fonts?: FontConfig; + logo?: LogoConfig; +} + +export interface ThemeConfig { + colors: { + primary: string; + secondary?: string; + accent?: string; + success?: string; + warning?: string; + error?: string; + background?: string; + surface?: string; + text?: string; + textSecondary?: string; + }; + fonts?: FontConfig; + logo?: LogoConfig; + metadata?: Record; +} + +export interface ThemeExportData { + version: string; + exportedAt: string; + theme: { + light?: ThemeConfig; + dark?: ThemeConfig; + shared: ThemeSharedConfig; + }; +} + +export interface ThemeInheritance { + parentId: string; + overrides: Partial; + extendedOverrides?: Partial; +} + +export interface ThemePreviewState { + isPreviewing: boolean; + previewConfig: Partial | null; + originalThemeId: string | null; }