File size: 9,574 Bytes
68f7925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
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();