Spaces:
Sleeping
Sleeping
| 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(); | |