FE_Dev / server /lib /styles /theme-variables.ts
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
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();