import type { ThemeExtractionResult } from '@/schema/theme-extraction'; export interface ThemeVariables { extractedVars: string; fallbackVars: string; phase5Vars: string; legacyMappingVars: string; } export class ThemeVariableGenerator { /** * 背景色に対して適切なコントラスト比を持つテキスト色を生成(WCAG AA準拠) */ private generateAdaptiveTextColor(bgColor: string, theme: ThemeExtractionResult): string { const luminance = this.getLuminance(bgColor); // primary_text_colorとinverse_text_colorのコントラスト比を計算 const contrastWithPrimary = this.getContrastRatio(bgColor, theme.colors.primary_text_color); const contrastWithInverse = this.getContrastRatio(bgColor, theme.colors.inverse_text_color); const WCAG_AA_THRESHOLD = 4.5; // WCAG AA基準 console.log( `[ThemeVariables] テキスト色判定: 背景=${bgColor} (明度: ${luminance.toFixed(2)}) | ` + `primary_text_color=${theme.colors.primary_text_color} (コントラスト: ${contrastWithPrimary.toFixed(2)}) | ` + `inverse_text_color=${theme.colors.inverse_text_color} (コントラスト: ${contrastWithInverse.toFixed(2)})`, ); // 両方がWCAG AA基準を満たす場合、より高いコントラストを選択 if (contrastWithPrimary >= WCAG_AA_THRESHOLD && contrastWithInverse >= WCAG_AA_THRESHOLD) { const selected = contrastWithPrimary > contrastWithInverse ? theme.colors.primary_text_color : theme.colors.inverse_text_color; console.log( `[ThemeVariables] ✅ 両方AA基準クリア → より高いコントラストを選択: ${selected} (${contrastWithPrimary > contrastWithInverse ? contrastWithPrimary.toFixed(2) : contrastWithInverse.toFixed(2)})`, ); return selected; } // primary_text_colorのみがWCAG AA基準を満たす場合 if (contrastWithPrimary >= WCAG_AA_THRESHOLD) { console.log( `[ThemeVariables] ✅ primary_text_colorのみAA基準クリア → primary_text_color使用 (コントラスト: ${contrastWithPrimary.toFixed(2)})`, ); return theme.colors.primary_text_color; } // inverse_text_colorのみがWCAG AA基準を満たす場合 if (contrastWithInverse >= WCAG_AA_THRESHOLD) { console.log( `[ThemeVariables] ✅ inverse_text_colorのみAA基準クリア → inverse_text_color使用 (コントラスト: ${contrastWithInverse.toFixed(2)})`, ); return theme.colors.inverse_text_color; } // どちらもWCAG AA基準を満たさない場合、フォールバック(黒または白) const fallbackColor = luminance > 0.5 ? '#000000' : '#FFFFFF'; console.warn(`[ThemeVariables] ⚠️ どちらもAA基準未達 → フォールバック使用: ${fallbackColor} (背景明度: ${luminance.toFixed(2)})`); return fallbackColor; } /** * 相対輝度計算(WCAG 2.1準拠) */ private getLuminance(hex: string): number { try { const rgb = this.hexToRgb(hex); if (!rgb) return 0; const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } catch (error) { console.warn('[ThemeVariables] 輝度計算エラー:', error); return 0; } } /** * HEXをRGBに変換 */ private hexToRgb(hex: string): { r: number; g: number; b: number } | null { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16), } : null; } /** * 2色間のコントラスト比を計算(WCAG 2.1準拠) * @returns コントラスト比(1〜21の範囲) */ private getContrastRatio(color1: string, color2: string): number { try { const lum1 = this.getLuminance(color1); const lum2 = this.getLuminance(color2); const lighter = Math.max(lum1, lum2); const darker = Math.min(lum1, lum2); return (lighter + 0.05) / (darker + 0.05); } catch (error) { console.warn('[ThemeVariables] コントラスト比計算エラー:', error); return 1; // エラー時は最低値を返す } } generateExtractedVariables(theme: ThemeExtractionResult): string { return ` /* === 抽出されたテーマ変数 === */ --extracted-primary-color: ${theme.colors.primary_color}; --extracted-secondary-color: ${theme.colors.secondary_color}; --extracted-accent-color: ${theme.colors.accent_color}; --extracted-success-semantic-color: ${theme.colors.success_semantic_color}; --extracted-warning-semantic-color: ${theme.colors.warning_semantic_color}; --extracted-error-semantic-color: ${theme.colors.error_semantic_color}; --extracted-info-semantic-color: ${theme.colors.info_semantic_color}; --extracted-primary-background-color: ${theme.colors.primary_background_color}; --extracted-secondary-background-color: ${theme.colors.secondary_background_color}; --extracted-tertiary-background-color: ${theme.colors.tertiary_background_color}; --extracted-overlay-background-color: ${theme.colors.overlay_background_color}; --extracted-primary-text-color: ${theme.colors.primary_text_color}; --extracted-secondary-text-color: ${theme.colors.secondary_text_color}; --extracted-disabled-text-color: ${theme.colors.disabled_text_color}; --extracted-inverse-text-color: ${theme.colors.inverse_text_color}; /* カテゴリータグ用の適応的テキスト色 */ --extracted-category-tag-text: ${this.generateAdaptiveTextColor(theme.colors.accent_color, theme)};`; } generateFallbackVariables(theme: ThemeExtractionResult): string { // tertiary_background用の適応的テキスト色を生成 const tertiaryTextColor = this.generateAdaptiveTextColor(theme.colors.tertiary_background_color, theme); return ` /* === フォールバック付きテーマ変数 === */ --theme-primary-color: var(--extracted-primary-color, #5a6c7d); --theme-secondary-color: var(--extracted-secondary-color, #34495e); --theme-accent-color: var(--extracted-accent-color, #f59e0b); --theme-category-tag-bg: var(--extracted-accent-color, #f59e0b); --theme-category-tag-text: var(--extracted-category-tag-text, white); /* フォールバック: 白(#f59e0b背景用) */ --theme-success-color: var(--extracted-success-semantic-color, #28a745); --theme-warning-color: var(--extracted-warning-semantic-color, #f59e0b); --theme-error-color: var(--extracted-error-semantic-color, #dc3545); --theme-info-color: var(--extracted-info-semantic-color, #17a2b8); --theme-primary-bg: var(--extracted-primary-background-color, white); --theme-secondary-bg: var(--extracted-secondary-background-color, #f8f9fa); --theme-tertiary-bg: var(--extracted-tertiary-background-color, #222222); --theme-overlay-bg: var(--extracted-overlay-background-color, rgba(0,0,0,0.5)); --theme-primary-text: var(--extracted-primary-text-color, #222222); --theme-secondary-text: var(--extracted-secondary-text-color, #555555); --theme-disabled-text: var(--extracted-disabled-text-color, #999999); --theme-inverse-text: var(--extracted-inverse-text-color, white); --theme-tertiary-text: ${tertiaryTextColor}; /* 適応的テキスト色(明度判定済み) */ --theme-primary-font: var(--extracted-primary-font, "YuGothic", "Yu Gothic Medium", "Yu Gothic", "Hiragino Sans", "Hiragino Kaku Gothic ProN", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Meiryo", sans-serif);`; } generatePhase5Variables(): string { return ` /* === Phase 5: 基本CSS変数化 === */ --primary-color: var(--theme-primary-color); --text-primary: var(--theme-primary-text); --text-secondary: var(--theme-secondary-text); --background-primary: var(--theme-primary-bg); --background-secondary: var(--theme-secondary-bg); --background-tertiary: var(--theme-tertiary-bg); --border-primary: var(--theme-secondary-bg); --border-secondary: var(--theme-secondary-bg); --accent-warning: var(--theme-warning-color);`; } generateLegacyMappingVariables(): string { return ` /* === 既存色の変数化(後方互換性確保) === */ --legacy-primary-text: var(--theme-primary-text); --legacy-accent: var(--theme-primary-color); --legacy-border: var(--theme-secondary-text); --legacy-light-bg: var(--theme-secondary-bg); --legacy-secondary-text: var(--theme-secondary-text); --section-white-bg: var(--theme-primary-bg); --section-gray-bg: var(--theme-secondary-bg); --section-dark-bg: var(--theme-tertiary-bg);`; } generateCompleteThemeVariables(theme: ThemeExtractionResult): ThemeVariables { const extractedVars = this.generateExtractedVariables(theme); const fallbackVars = this.generateFallbackVariables(theme); const phase5Vars = this.generatePhase5Variables(); const legacyMappingVars = this.generateLegacyMappingVariables(); return { extractedVars, fallbackVars, phase5Vars, legacyMappingVars, }; } generateThemeCSS(theme: ThemeExtractionResult): string { const variables = this.generateCompleteThemeVariables(theme); return `:root {${variables.extractedVars} ${variables.fallbackVars} ${variables.phase5Vars} ${variables.legacyMappingVars} }`; } } export const themeVariableGenerator = new ThemeVariableGenerator();