feat: add Unsplash search hook and API proxy routes

- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce.
- Created Unsplash API client functions for searching photos and triggering downloads.
- Added proxy routes for Unsplash API to handle search requests and background image uploads.
- Introduced accessibility utilities for WCAG compliance checks and motion preference management.
- Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
catlog22
2026-02-08 20:01:28 +08:00
parent 87daccdc48
commit 166211dcd4
52 changed files with 5798 additions and 142 deletions

View File

@@ -0,0 +1,336 @@
// ========================================
// Accessibility Utilities
// ========================================
// WCAG 2.1 contrast checking and motion preference management
// for the theme system. Operates on HSL 'H S% L%' format strings.
// ========== Types ==========
/** User preference for animation behavior */
export type MotionPreference = 'system' | 'reduce' | 'enable';
/** Result of evaluating one critical color pair against WCAG thresholds */
export interface ContrastResult {
/** Foreground CSS variable name (e.g. '--text') */
fgVar: string;
/** Background CSS variable name (e.g. '--bg') */
bgVar: string;
/** Computed contrast ratio (e.g. 4.52) */
ratio: number;
/** Required minimum contrast ratio for this pair */
required: number;
/** Whether the pair passes WCAG AA */
passed: boolean;
}
/** A single suggested fix to improve contrast, with visual distance metric */
export interface FixSuggestion {
/** Which variable to adjust ('fg' or 'bg') */
target: 'fg' | 'bg';
/** Original HSL value */
original: string;
/** Suggested replacement HSL value */
suggested: string;
/** Resulting contrast ratio after applying the fix */
resultRatio: number;
/** Visual distance from original (lower = less visible change) */
distance: number;
}
// ========== Critical Color Pairs ==========
/**
* Whitelist of critical UI color pairs to check for WCAG AA compliance.
* Each entry: [foreground variable, background variable, required ratio]
*
* - 4.5:1 for normal text (WCAG AA)
* - 3.0:1 for large text / UI components (WCAG AA)
*/
export const CRITICAL_COLOR_PAIRS: ReadonlyArray<[string, string, number]> = [
// Text on backgrounds (4.5:1 - normal text)
['--text', '--bg', 4.5],
['--text', '--surface', 4.5],
['--text-secondary', '--bg', 4.5],
['--text-secondary', '--surface', 4.5],
['--muted-text', '--muted', 4.5],
['--muted-text', '--bg', 4.5],
// Tertiary/disabled text (3:1 - large text threshold)
['--text-tertiary', '--bg', 3.0],
['--text-disabled', '--bg', 3.0],
// Accent/interactive on backgrounds (3:1 - UI component threshold)
['--accent', '--bg', 3.0],
['--accent', '--surface', 3.0],
// Semantic text on semantic light backgrounds (4.5:1)
['--success-text', '--success-light', 4.5],
['--warning-text', '--warning-light', 4.5],
['--error-text', '--error-light', 4.5],
['--info-text', '--info-light', 4.5],
// Foreground on primary/destructive buttons (4.5:1)
['--primary-foreground', '--primary', 4.5],
['--destructive-foreground', '--destructive', 4.5],
// Text on muted surface (4.5:1)
['--text', '--muted', 4.5],
];
// ========== HSL Parsing and Color Conversion ==========
/**
* Parse 'H S% L%' format HSL string into numeric [h, s, l] values.
* h in degrees (0-360), s and l as fractions (0-1).
*/
function parseHSL(hslString: string): [number, number, number] | null {
const trimmed = hslString.trim();
// Match patterns: "220 60% 65%" or "220 60% 65"
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
if (!match) return null;
const h = parseFloat(match[1]);
const s = parseFloat(match[2]) / 100;
const l = parseFloat(match[3]) / 100;
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
return [h, s, l];
}
/**
* Convert HSL values to linear sRGB [R, G, B] in 0-1 range.
* Then apply sRGB linearization per WCAG 2.1 spec.
*/
function hslToLinearRGB(h: number, s: number, l: number): [number, number, number] {
// HSL to sRGB conversion
const c = (1 - Math.abs(2 * l - 1)) * s;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = l - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) { r1 = c; g1 = x; b1 = 0; }
else if (hPrime < 2) { r1 = x; g1 = c; b1 = 0; }
else if (hPrime < 3) { r1 = 0; g1 = c; b1 = x; }
else if (hPrime < 4) { r1 = 0; g1 = x; b1 = c; }
else if (hPrime < 5) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
const rSRGB = r1 + m;
const gSRGB = g1 + m;
const bSRGB = b1 + m;
// sRGB linearization per WCAG 2.1
const linearize = (v: number): number => {
if (v <= 0.04045) return v / 12.92;
return Math.pow((v + 0.055) / 1.055, 2.4);
};
return [linearize(rSRGB), linearize(gSRGB), linearize(bSRGB)];
}
/**
* Parse 'H S% L%' format HSL string, convert to sRGB,
* compute WCAG 2.1 relative luminance.
*
* Formula: L = 0.2126*R + 0.7152*G + 0.0722*B
* with sRGB linearization applied.
*
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
* @returns Relative luminance (0-1), or -1 if parsing fails
*/
export function hslToRelativeLuminance(hslString: string): number {
const parsed = parseHSL(hslString);
if (!parsed) return -1;
const [h, s, l] = parsed;
const [rLin, gLin, bLin] = hslToLinearRGB(h, s, l);
// Round to 4-decimal precision to avoid floating-point edge cases
return Math.round((0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin) * 10000) / 10000;
}
/**
* Compute WCAG contrast ratio from two relative luminance values.
* Returns ratio in format X:1 (just the X number).
*
* @param l1 - Relative luminance of first color
* @param l2 - Relative luminance of second color
* @returns Contrast ratio (always >= 1)
*/
export function getContrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
}
// ========== Theme Contrast Checking ==========
/**
* Evaluate all critical color pairs in a generated theme against WCAG AA thresholds.
* Only checks pairs where both variables exist in the provided vars map.
*
* @param vars - Record of CSS variable names to HSL values (e.g. from generateThemeFromHue)
* @returns Array of ContrastResult for each evaluated pair
*/
export function checkThemeContrast(vars: Record<string, string>): ContrastResult[] {
const results: ContrastResult[] = [];
for (const [fgVar, bgVar, required] of CRITICAL_COLOR_PAIRS) {
const fgValue = vars[fgVar];
const bgValue = vars[bgVar];
if (!fgValue || !bgValue) continue;
const fgLum = hslToRelativeLuminance(fgValue);
const bgLum = hslToRelativeLuminance(bgValue);
if (fgLum < 0 || bgLum < 0) continue;
const ratio = getContrastRatio(fgLum, bgLum);
// Use 0.01 tolerance buffer for borderline cases
const passed = ratio >= (required - 0.01);
results.push({ fgVar, bgVar, ratio, required, passed });
}
return results;
}
// ========== Contrast Fix Generation ==========
/**
* Reconstruct 'H S% L%' string from components.
*/
function toHSLString(h: number, s: number, l: number): string {
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
}
/**
* Generate 2-3 lightness-adjusted alternatives that achieve target contrast ratio.
* Preserves hue and saturation, only adjusts lightness.
* Sorted by minimal visual change (distance).
*
* @param fgVar - Foreground CSS variable name
* @param bgVar - Background CSS variable name
* @param currentVars - Current theme variable values
* @param targetRatio - Target contrast ratio to achieve
* @returns Array of 2-3 FixSuggestion sorted by distance (ascending)
*/
export function generateContrastFix(
fgVar: string,
bgVar: string,
currentVars: Record<string, string>,
targetRatio: number
): FixSuggestion[] {
const fgValue = currentVars[fgVar];
const bgValue = currentVars[bgVar];
if (!fgValue || !bgValue) return [];
const fgParsed = parseHSL(fgValue);
const bgParsed = parseHSL(bgValue);
if (!fgParsed || !bgParsed) return [];
const suggestions: FixSuggestion[] = [];
// Strategy 1: Adjust foreground lightness (darken or lighten)
const bgLum = hslToRelativeLuminance(bgValue);
const fgSuggestions = findLightnessForContrast(
fgParsed[0], fgParsed[1], fgParsed[2], bgLum, targetRatio
);
for (const newL of fgSuggestions) {
const suggested = toHSLString(fgParsed[0], fgParsed[1] * 100, newL * 100);
const newFgLum = hslToRelativeLuminance(suggested);
if (newFgLum < 0) continue;
const resultRatio = getContrastRatio(newFgLum, bgLum);
if (resultRatio >= targetRatio - 0.01) {
suggestions.push({
target: 'fg',
original: fgValue,
suggested,
resultRatio,
distance: Math.abs(newL - fgParsed[2]),
});
}
}
// Strategy 2: Adjust background lightness
const fgLum = hslToRelativeLuminance(fgValue);
const bgSuggestions = findLightnessForContrast(
bgParsed[0], bgParsed[1], bgParsed[2], fgLum, targetRatio
);
for (const newL of bgSuggestions) {
const suggested = toHSLString(bgParsed[0], bgParsed[1] * 100, newL * 100);
const newBgLum = hslToRelativeLuminance(suggested);
if (newBgLum < 0) continue;
const resultRatio = getContrastRatio(fgLum, newBgLum);
if (resultRatio >= targetRatio - 0.01) {
suggestions.push({
target: 'bg',
original: bgValue,
suggested,
resultRatio,
distance: Math.abs(newL - bgParsed[2]),
});
}
}
// Sort by distance (minimal visual change first) and take up to 3
suggestions.sort((a, b) => a.distance - b.distance);
return suggestions.slice(0, 3);
}
/**
* Find lightness values that achieve target contrast against a reference luminance.
* Searches in both lighter and darker directions from current lightness.
* Returns up to 2 candidates (one lighter, one darker if found).
*/
function findLightnessForContrast(
h: number,
s: number,
currentL: number,
refLum: number,
targetRatio: number
): number[] {
const candidates: number[] = [];
const step = 0.01;
// Search darker direction (decreasing lightness)
for (let l = currentL - step; l >= 0; l -= step) {
const hsl = toHSLString(h, s * 100, l * 100);
const lum = hslToRelativeLuminance(hsl);
if (lum < 0) continue;
const ratio = getContrastRatio(lum, refLum);
if (ratio >= targetRatio) {
candidates.push(l);
break;
}
}
// Search lighter direction (increasing lightness)
for (let l = currentL + step; l <= 1; l += step) {
const hsl = toHSLString(h, s * 100, l * 100);
const lum = hslToRelativeLuminance(hsl);
if (lum < 0) continue;
const ratio = getContrastRatio(lum, refLum);
if (ratio >= targetRatio) {
candidates.push(l);
break;
}
}
return candidates;
}
// ========== Motion Preference ==========
/**
* Resolve user preference to actual reduced-motion boolean.
* 'system' checks matchMedia, 'reduce' returns true, 'enable' returns false.
*
* @param pref - User's motion preference setting
* @returns true if motion should be reduced, false otherwise
*/
export function resolveMotionPreference(pref: MotionPreference): boolean {
if (pref === 'reduce') return true;
if (pref === 'enable') return false;
// 'system' - check OS preference
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

View File

@@ -3239,7 +3239,7 @@ function buildCcwMcpServerConfig(config: {
if (config.enabledTools && config.enabledTools.length > 0) {
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
} else {
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question';
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
}
if (config.projectRoot) {
@@ -3352,7 +3352,7 @@ export async function installCcwMcp(
projectPath?: string
): Promise<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfig({
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
});
if (scope === 'project' && projectPath) {
@@ -3853,7 +3853,7 @@ export interface CodexLensGpuListResponse {
}
/**
* Model info
* Model info (normalized from CLI output)
*/
export interface CodexLensModel {
profile: string;
@@ -3863,10 +3863,26 @@ export interface CodexLensModel {
size?: string;
installed: boolean;
cache_path?: string;
/** Original HuggingFace model name */
model_name?: string;
/** Model description */
description?: string;
/** Use case description */
use_case?: string;
/** Embedding dimensions */
dimensions?: number;
/** Whether this model is recommended */
recommended?: boolean;
/** Model source: 'predefined' | 'discovered' */
source?: string;
/** Estimated size in MB */
estimated_size_mb?: number;
/** Actual size in MB (when installed) */
actual_size_mb?: number | null;
}
/**
* Model list response
* Model list response (normalized)
*/
export interface CodexLensModelsResponse {
success: boolean;
@@ -4067,9 +4083,43 @@ export async function uninstallCodexLens(): Promise<CodexLensUninstallResponse>
/**
* Fetch CodexLens models list
* Normalizes the CLI response format to match the frontend interface.
* CLI returns: { success, result: { models: [{ model_name, estimated_size_mb, ... }] } }
* Frontend expects: { success, models: [{ name, size, type, backend, ... }] }
*/
export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> {
return fetchApi<CodexLensModelsResponse>('/api/codexlens/models');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = await fetchApi<any>('/api/codexlens/models');
// Handle nested result structure from CLI
const rawModels = raw?.result?.models ?? raw?.models ?? [];
const models: CodexLensModel[] = rawModels.map((m: Record<string, unknown>) => ({
profile: (m.profile as string) || '',
name: (m.model_name as string) || (m.name as string) || (m.profile as string) || '',
type: (m.type as 'embedding' | 'reranker') || 'embedding',
backend: (m.source as string) || 'fastembed',
size: m.installed && m.actual_size_mb
? `${(m.actual_size_mb as number).toFixed(0)} MB`
: m.estimated_size_mb
? `~${m.estimated_size_mb} MB`
: undefined,
installed: (m.installed as boolean) ?? false,
cache_path: m.cache_path as string | undefined,
model_name: m.model_name as string | undefined,
description: m.description as string | undefined,
use_case: m.use_case as string | undefined,
dimensions: m.dimensions as number | undefined,
recommended: m.recommended as boolean | undefined,
source: m.source as string | undefined,
estimated_size_mb: m.estimated_size_mb as number | undefined,
actual_size_mb: m.actual_size_mb as number | null | undefined,
}));
return {
success: raw?.success ?? true,
models,
};
}
/**
@@ -4240,6 +4290,17 @@ export interface CodexLensSymbolSearchResponse {
error?: string;
}
/**
* CodexLens file search response (returns file paths only)
*/
export interface CodexLensFileSearchResponse {
success: boolean;
query?: string;
count?: number;
files: string[];
error?: string;
}
/**
* Perform content search using CodexLens
*/
@@ -4257,7 +4318,7 @@ export async function searchCodexLens(params: CodexLensSearchParams): Promise<Co
/**
* Perform file search using CodexLens
*/
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensSearchResponse> {
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensFileSearchResponse> {
const queryParams = new URLSearchParams();
queryParams.append('query', params.query);
if (params.limit) queryParams.append('limit', String(params.limit));
@@ -4265,7 +4326,7 @@ export async function searchFilesCodexLens(params: CodexLensSearchParams): Promi
if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length));
if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count));
return fetchApi<CodexLensSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
return fetchApi<CodexLensFileSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
}
/**
@@ -4279,6 +4340,84 @@ export async function searchSymbolCodexLens(params: Pick<CodexLensSearchParams,
return fetchApi<CodexLensSymbolSearchResponse>(`/api/codexlens/symbol?${queryParams.toString()}`);
}
// ========== CodexLens LSP / Semantic Search API ==========
/**
* CodexLens LSP status response
*/
export interface CodexLensLspStatusResponse {
available: boolean;
semantic_available: boolean;
vector_index: boolean;
project_count?: number;
embeddings?: Record<string, unknown>;
modes?: string[];
strategies?: string[];
error?: string;
}
/**
* CodexLens semantic search params (Python API)
*/
export type CodexLensSemanticSearchMode = 'fusion' | 'vector' | 'structural';
export type CodexLensFusionStrategy = 'rrf' | 'staged' | 'binary' | 'hybrid' | 'dense_rerank';
export interface CodexLensSemanticSearchParams {
query: string;
path?: string;
mode?: CodexLensSemanticSearchMode;
fusion_strategy?: CodexLensFusionStrategy;
vector_weight?: number;
structural_weight?: number;
keyword_weight?: number;
kind_filter?: string[];
limit?: number;
include_match_reason?: boolean;
}
/**
* CodexLens semantic search result
*/
export interface CodexLensSemanticSearchResult {
name?: string;
kind?: string;
file_path?: string;
score?: number;
match_reason?: string;
range?: { start_line: number; end_line: number };
[key: string]: unknown;
}
/**
* CodexLens semantic search response
*/
export interface CodexLensSemanticSearchResponse {
success: boolean;
results?: CodexLensSemanticSearchResult[];
query?: string;
mode?: string;
fusion_strategy?: string;
count?: number;
error?: string;
}
/**
* Fetch CodexLens LSP status
*/
export async function fetchCodexLensLspStatus(): Promise<CodexLensLspStatusResponse> {
return fetchApi<CodexLensLspStatusResponse>('/api/codexlens/lsp/status');
}
/**
* Perform semantic search using CodexLens Python API
*/
export async function semanticSearchCodexLens(params: CodexLensSemanticSearchParams): Promise<CodexLensSemanticSearchResponse> {
return fetchApi<CodexLensSemanticSearchResponse>('/api/codexlens/lsp/search', {
method: 'POST',
body: JSON.stringify(params),
});
}
// ========== CodexLens Index Management API ==========
/**

View File

@@ -10,6 +10,126 @@
* @module colorGenerator
*/
import type { StyleTier } from '../types/store';
// ========== Style Tier System ==========
/** Per-tier adjustment factors for saturation, lightness, and contrast */
export interface StyleTierCoefficients {
saturationScale: number;
lightnessOffset: { light: number; dark: number };
contrastBoost: number;
}
/**
* Style tier coefficient definitions.
* - soft: reduced saturation, lighter feel, lower contrast
* - standard: identity transform (no change)
* - high-contrast: boosted saturation, sharper text/background separation
*/
export const STYLE_TIER_COEFFICIENTS: Record<StyleTier, StyleTierCoefficients> = {
soft: {
saturationScale: 0.6,
lightnessOffset: { light: 5, dark: -3 },
contrastBoost: 0.9,
},
standard: {
saturationScale: 1.0,
lightnessOffset: { light: 0, dark: 0 },
contrastBoost: 1.0,
},
'high-contrast': {
saturationScale: 1.3,
lightnessOffset: { light: -5, dark: 3 },
contrastBoost: 1.2,
},
};
/**
* Parse 'H S% L%' format HSL string into numeric components.
* H in degrees (0-360), S and L as percentages (0-100).
*
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
* @returns Parsed components or null if parsing fails
*/
export function parseHSL(hslString: string): { h: number; s: number; l: number } | null {
const trimmed = hslString.trim();
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
if (!match) return null;
const h = parseFloat(match[1]);
const s = parseFloat(match[2]);
const l = parseFloat(match[3]);
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
return { h, s, l };
}
/**
* Format numeric HSL values back to 'H S% L%' string.
* Values are clamped to valid ranges.
*
* @param h - Hue in degrees (0-360)
* @param s - Saturation as percentage (0-100)
* @param l - Lightness as percentage (0-100)
* @returns Formatted HSL string
*/
export function formatHSL(h: number, s: number, l: number): string {
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
}
/**
* Apply style tier coefficients to a set of CSS variable values.
* Adjusts saturation and lightness per tier, preserving hue.
*
* Processing pipeline per variable:
* 1. Scale saturation: s * saturationScale
* 2. Apply contrast boost: stretch lightness from midpoint (50%)
* 3. Apply lightness offset (mode-specific)
* 4. Clamp to valid ranges
*
* Standard tier is an identity transform (returns input unchanged).
*
* @param vars - Record of CSS variable names to HSL values in 'H S% L%' format
* @param tier - Style tier to apply
* @param mode - Current theme mode
* @returns Modified CSS variables record
*/
export function applyStyleTier(
vars: Record<string, string>,
tier: StyleTier,
mode: 'light' | 'dark'
): Record<string, string> {
if (tier === 'standard') return vars;
const coeffs = STYLE_TIER_COEFFICIENTS[tier];
const offset = mode === 'light' ? coeffs.lightnessOffset.light : coeffs.lightnessOffset.dark;
const result: Record<string, string> = {};
for (const [varName, value] of Object.entries(vars)) {
const parsed = parseHSL(value);
if (!parsed) {
result[varName] = value;
continue;
}
// 1. Apply saturation scaling
let s = parsed.s * coeffs.saturationScale;
s = Math.max(0, Math.min(100, s));
// 2. Apply contrast boost (stretch lightness from midpoint)
let l = 50 + (parsed.l - 50) * coeffs.contrastBoost;
// 3. Apply lightness offset
l = l + offset;
l = Math.max(0, Math.min(100, l));
result[varName] = formatHSL(parsed.h, s, l);
}
return result;
}
/**
* Generate a complete theme from a single hue value
*

View File

@@ -3,6 +3,8 @@
* Defines available color schemes and theme modes for the CCW application
*/
import type { ThemeSlot, ThemeSlotId, BackgroundEffects, BackgroundConfig } from '../types/store';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type ThemeMode = 'light' | 'dark';
export type ThemeId = `${ThemeMode}-${ColorScheme}`;
@@ -112,3 +114,62 @@ export const DEFAULT_THEME: Theme = {
mode: 'light',
name: '经典蓝 · 浅色'
};
// ========== Background Defaults ==========
export const DEFAULT_BACKGROUND_EFFECTS: BackgroundEffects = {
blur: 0,
darkenOpacity: 0,
saturation: 100,
enableFrostedGlass: false,
enableGrain: false,
enableVignette: false,
};
export const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
mode: 'gradient-only',
imageUrl: null,
attribution: null,
effects: DEFAULT_BACKGROUND_EFFECTS,
};
// ========== Theme Slot Constants ==========
/** Maximum number of theme slots a user can have */
export const THEME_SLOT_LIMIT = 3;
/** Default theme slot with preset values */
export const DEFAULT_SLOT: ThemeSlot = {
id: 'default',
name: 'Default',
colorScheme: 'blue',
customHue: null,
isCustomTheme: false,
gradientLevel: 'standard',
enableHoverGlow: true,
enableBackgroundAnimation: false,
styleTier: 'standard',
isDefault: true,
};
/**
* Factory function to create a new empty theme slot with default values.
*
* @param id - Slot identifier
* @param name - Display name for the slot
* @returns A new ThemeSlot with default theme values
*/
export function createEmptySlot(id: ThemeSlotId, name: string): ThemeSlot {
return {
id,
name,
colorScheme: 'blue',
customHue: null,
isCustomTheme: false,
gradientLevel: 'standard',
enableHoverGlow: true,
enableBackgroundAnimation: false,
styleTier: 'standard',
isDefault: false,
};
}

View File

@@ -0,0 +1,327 @@
/**
* Theme Sharing Module
* Encodes/decodes theme configurations as compact base64url strings
* for copy-paste sharing between users.
*
* Format: 'ccw{version}:{base64url_payload}'
* Payload uses short field names for compactness:
* v=version, c=colorScheme, h=customHue, t=styleTier,
* g=gradientLevel, w=enableHoverGlow, a=enableBackgroundAnimation
* bm=backgroundMode, bi=backgroundImageUrl, bp=photographerName,
* bu=photographerUrl, bpu=photoUrl, be=backgroundEffects
*
* @module themeShare
*/
import type { ThemeSlot, BackgroundConfig, BackgroundEffects } from '../types/store';
import type { ColorScheme, GradientLevel, StyleTier, BackgroundMode } from '../types/store';
import { DEFAULT_BACKGROUND_EFFECTS } from './theme';
// ========== Constants ==========
/** Current share format version. Bump when payload schema changes. */
export const SHARE_VERSION = 2;
/** Maximum encoded string length accepted for import */
const MAX_ENCODED_LENGTH = 800;
/** Version prefix pattern: 'ccw' followed by version number and colon */
const PREFIX_PATTERN = /^ccw(\d+):(.+)$/;
// ========== Types ==========
/** Serializable theme state for encoding/decoding */
export interface ThemeSharePayload {
version: number;
colorScheme: ColorScheme;
customHue: number | null;
styleTier: StyleTier;
gradientLevel: GradientLevel;
enableHoverGlow: boolean;
enableBackgroundAnimation: boolean;
backgroundConfig?: BackgroundConfig;
}
/** Compact wire format using short keys for smaller base64 output */
interface CompactPayload {
v: number;
c: string;
h: number | null;
t: string;
g: string;
w: boolean;
a: boolean;
// v2 background fields (optional)
bm?: string;
bi?: string;
bp?: string;
bu?: string;
bpu?: string;
be?: CompactEffects;
}
/** Compact background effects */
interface CompactEffects {
b: number; // blur
d: number; // darkenOpacity
s: number; // saturation
f: boolean; // enableFrostedGlass
g: boolean; // enableGrain
v: boolean; // enableVignette
}
/** Result of decoding and validating an import string */
export type ImportResult =
| { ok: true; payload: ThemeSharePayload; warning?: string }
| { ok: false; error: string };
/** Version compatibility check result */
export interface VersionCheckResult {
compatible: boolean;
warning?: string;
}
// ========== Validation Constants ==========
const VALID_COLOR_SCHEMES: readonly string[] = ['blue', 'green', 'orange', 'purple'];
const VALID_STYLE_TIERS: readonly string[] = ['soft', 'standard', 'high-contrast'];
const VALID_GRADIENT_LEVELS: readonly string[] = ['off', 'standard', 'enhanced'];
const VALID_BACKGROUND_MODES: readonly string[] = ['gradient-only', 'image-only', 'image-gradient'];
// ========== Encoding ==========
/**
* Encode a theme slot into a compact URL-safe base64 string with version prefix.
*
* Output format: 'ccw2:{base64url}'
* The base64url payload contains JSON with short keys for compactness.
* Background fields are only included when mode != gradient-only.
*
* @param slot - Theme slot to encode
* @returns Encoded theme string (typically under 300 characters)
*/
export function encodeTheme(slot: ThemeSlot): string {
const compact: CompactPayload = {
v: SHARE_VERSION,
c: slot.colorScheme,
h: slot.customHue,
t: slot.styleTier,
g: slot.gradientLevel,
w: slot.enableHoverGlow,
a: slot.enableBackgroundAnimation,
};
// Only include background fields when mode != gradient-only
const bg = slot.backgroundConfig;
if (bg && bg.mode !== 'gradient-only') {
compact.bm = bg.mode;
if (bg.imageUrl) compact.bi = bg.imageUrl;
if (bg.attribution) {
compact.bp = bg.attribution.photographerName;
compact.bu = bg.attribution.photographerUrl;
compact.bpu = bg.attribution.photoUrl;
}
compact.be = {
b: bg.effects.blur,
d: bg.effects.darkenOpacity,
s: bg.effects.saturation,
f: bg.effects.enableFrostedGlass,
g: bg.effects.enableGrain,
v: bg.effects.enableVignette,
};
}
const json = JSON.stringify(compact);
// Use TextEncoder for consistent UTF-8 handling
const encoder = new TextEncoder();
const bytes = encoder.encode(json);
// Convert bytes to binary string for btoa
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
// Base64 encode, then make URL-safe
const base64 = btoa(binary);
const base64url = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `ccw${SHARE_VERSION}:${base64url}`;
}
// ========== Decoding ==========
/**
* Decode and validate a theme share string.
* Checks version compatibility and validates all field types/ranges.
* Invalid input never causes side effects.
* Handles both v1 (no background) and v2 (with background) payloads.
*
* @param encoded - The encoded theme string to decode
* @returns ImportResult with decoded payload on success, or error message on failure
*/
export function decodeTheme(encoded: string): ImportResult {
// Guard: reject empty input
if (!encoded || typeof encoded !== 'string') {
return { ok: false, error: 'empty_input' };
}
const trimmed = encoded.trim();
// Guard: reject strings exceeding max length
if (trimmed.length > MAX_ENCODED_LENGTH) {
return { ok: false, error: 'too_long' };
}
// Extract version and payload from prefix
const prefixMatch = trimmed.match(PREFIX_PATTERN);
if (!prefixMatch) {
return { ok: false, error: 'invalid_format' };
}
const prefixVersion = parseInt(prefixMatch[1], 10);
const base64url = prefixMatch[2];
// Restore standard base64 from URL-safe variant
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const remainder = base64.length % 4;
if (remainder === 2) base64 += '==';
else if (remainder === 3) base64 += '=';
// Decode base64 to bytes
let json: string;
try {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Use TextDecoder for consistent UTF-8 handling
const decoder = new TextDecoder();
json = decoder.decode(bytes);
} catch {
return { ok: false, error: 'decode_failed' };
}
// Parse JSON
let compact: unknown;
try {
compact = JSON.parse(json);
} catch {
return { ok: false, error: 'parse_failed' };
}
// Validate object shape
if (!compact || typeof compact !== 'object' || Array.isArray(compact)) {
return { ok: false, error: 'invalid_payload' };
}
const obj = compact as Record<string, unknown>;
// Extract and validate version
const payloadVersion = typeof obj.v === 'number' ? obj.v : prefixVersion;
// Check version compatibility
const versionCheck = isVersionCompatible(payloadVersion);
if (!versionCheck.compatible) {
return { ok: false, error: 'incompatible_version' };
}
// Validate colorScheme
if (typeof obj.c !== 'string' || !VALID_COLOR_SCHEMES.includes(obj.c)) {
return { ok: false, error: 'invalid_field' };
}
// Validate customHue
if (obj.h !== null && (typeof obj.h !== 'number' || !isFinite(obj.h))) {
return { ok: false, error: 'invalid_field' };
}
// Validate styleTier
if (typeof obj.t !== 'string' || !VALID_STYLE_TIERS.includes(obj.t)) {
return { ok: false, error: 'invalid_field' };
}
// Validate gradientLevel
if (typeof obj.g !== 'string' || !VALID_GRADIENT_LEVELS.includes(obj.g)) {
return { ok: false, error: 'invalid_field' };
}
// Validate booleans
if (typeof obj.w !== 'boolean' || typeof obj.a !== 'boolean') {
return { ok: false, error: 'invalid_field' };
}
const payload: ThemeSharePayload = {
version: payloadVersion,
colorScheme: obj.c as ColorScheme,
customHue: obj.h as number | null,
styleTier: obj.t as StyleTier,
gradientLevel: obj.g as GradientLevel,
enableHoverGlow: obj.w,
enableBackgroundAnimation: obj.a,
};
// Decode v2 background fields (optional — v1 payloads simply lack them)
if (typeof obj.bm === 'string' && VALID_BACKGROUND_MODES.includes(obj.bm)) {
const effects: BackgroundEffects = { ...DEFAULT_BACKGROUND_EFFECTS };
// Parse compact effects
if (obj.be && typeof obj.be === 'object' && !Array.isArray(obj.be)) {
const be = obj.be as Record<string, unknown>;
if (typeof be.b === 'number') effects.blur = be.b;
if (typeof be.d === 'number') effects.darkenOpacity = be.d;
if (typeof be.s === 'number') effects.saturation = be.s;
if (typeof be.f === 'boolean') effects.enableFrostedGlass = be.f;
if (typeof be.g === 'boolean') effects.enableGrain = be.g;
if (typeof be.v === 'boolean') effects.enableVignette = be.v;
}
payload.backgroundConfig = {
mode: obj.bm as BackgroundMode,
imageUrl: typeof obj.bi === 'string' ? obj.bi : null,
attribution: (typeof obj.bp === 'string' && typeof obj.bu === 'string' && typeof obj.bpu === 'string')
? { photographerName: obj.bp, photographerUrl: obj.bu, photoUrl: obj.bpu }
: null,
effects,
};
}
return {
ok: true,
payload,
warning: versionCheck.warning,
};
}
// ========== Version Compatibility ==========
/**
* Check if a payload version is within +/-2 of the current SHARE_VERSION.
* Versions outside range are incompatible. Versions within range but
* not equal get a warning that accuracy may vary.
*
* @param payloadVersion - Version number from the decoded payload
* @returns Compatibility result with optional warning
*/
export function isVersionCompatible(payloadVersion: number): VersionCheckResult {
const diff = Math.abs(payloadVersion - SHARE_VERSION);
if (diff > 2) {
return { compatible: false };
}
if (diff > 0) {
return {
compatible: true,
warning: 'version_mismatch',
};
}
return { compatible: true };
}

View File

@@ -0,0 +1,102 @@
/**
* Unsplash API Client
* Frontend functions to search Unsplash via the backend proxy.
*/
export interface UnsplashPhoto {
id: string;
thumbUrl: string;
smallUrl: string;
regularUrl: string;
photographer: string;
photographerUrl: string;
photoUrl: string;
blurHash: string | null;
downloadLocation: string;
}
export interface UnsplashSearchResult {
photos: UnsplashPhoto[];
total: number;
totalPages: number;
}
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Search Unsplash photos via backend proxy.
*/
export async function searchUnsplash(
query: string,
page = 1,
perPage = 20
): Promise<UnsplashSearchResult> {
const params = new URLSearchParams({
query,
page: String(page),
per_page: String(perPage),
});
const response = await fetch(`/api/unsplash/search?${params}`, {
credentials: 'same-origin',
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Unsplash search failed: ${response.status}`);
}
return response.json();
}
/**
* Upload a local image as background.
* Sends raw binary to avoid base64 overhead.
*/
export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> {
const headers: Record<string, string> = {
'Content-Type': file.type,
'X-Filename': file.name,
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch('/api/background/upload', {
method: 'POST',
headers,
credentials: 'same-origin',
body: file,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Upload failed: ${response.status}`);
}
return response.json();
}
/**
* Trigger Unsplash download event (required by API guidelines).
*/
export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
await fetch('/api/unsplash/download', {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ downloadLocation }),
});
}