roadkit
Version:
Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export
583 lines (490 loc) • 17.1 kB
text/typescript
/**
* Theme Preview System for roadkit CLI
*
* This module provides visual theme previews in the terminal,
* allowing users to see theme colors and styles before selection.
*/
import type { Theme, ThemeMode, ColorPalette } from '../themes/types';
import { ColorUtils } from '../themes/utils';
import { themeRegistry } from '../themes/registry';
/**
* ANSI color codes for terminal styling
*/
const ANSI = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
italic: '\x1b[3m',
underline: '\x1b[4m',
// Foreground colors
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
// Background colors
bgBlack: '\x1b[40m',
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgBlue: '\x1b[44m',
bgMagenta: '\x1b[45m',
bgCyan: '\x1b[46m',
bgWhite: '\x1b[47m',
bgGray: '\x1b[100m',
} as const;
/**
* Terminal styling utilities
*/
class TerminalStyle {
/**
* Convert HSL color to closest ANSI color
*
* @param hsl - HSL color string
* @returns ANSI color code
*/
static hslToAnsi(hsl: string): string {
try {
const color = ColorUtils.parseHSL(hsl);
const rgb = ColorUtils.hslToRgb(color);
// Simple color mapping based on RGB values
const { r, g, b } = rgb;
// Calculate which primary color is dominant
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
// If very dark, use black
if (max < 50) return ANSI.black;
// If very light, use white
if (min > 200) return ANSI.white;
// If low saturation, use gray
if (max - min < 50) return ANSI.gray;
// Determine dominant color
if (r === max) {
if (g > b) return ANSI.yellow; // Red + Green = Yellow
return ANSI.red;
} else if (g === max) {
if (b > r) return ANSI.cyan; // Green + Blue = Cyan
return ANSI.green;
} else {
if (r > g) return ANSI.magenta; // Blue + Red = Magenta
return ANSI.blue;
}
} catch {
return ANSI.white; // Fallback
}
}
/**
* Get background ANSI color from HSL
*
* @param hsl - HSL color string
* @returns ANSI background color code
*/
static hslToBgAnsi(hsl: string): string {
const fg = this.hslToAnsi(hsl);
// Map foreground colors to background colors
const bgMap: Record<string, string> = {
[ANSI.black]: ANSI.bgBlack,
[ANSI.red]: ANSI.bgRed,
[ANSI.green]: ANSI.bgGreen,
[ANSI.yellow]: ANSI.bgYellow,
[ANSI.blue]: ANSI.bgBlue,
[ANSI.magenta]: ANSI.bgMagenta,
[ANSI.cyan]: ANSI.bgCyan,
[ANSI.white]: ANSI.bgWhite,
[ANSI.gray]: ANSI.bgGray,
};
return bgMap[fg] || ANSI.bgBlack;
}
/**
* Create styled text
*
* @param text - Text to style
* @param style - Style codes
* @returns Styled text
*/
static style(text: string, ...styles: string[]): string {
return `${styles.join('')}${text}${ANSI.reset}`;
}
/**
* Create colored text with background
*
* @param text - Text to style
* @param fgColor - Foreground HSL color
* @param bgColor - Background HSL color
* @returns Styled text
*/
static colorText(text: string, fgColor: string, bgColor?: string): string {
const fg = this.hslToAnsi(fgColor);
const bg = bgColor ? this.hslToBgAnsi(bgColor) : '';
return this.style(text, fg, bg);
}
}
/**
* Theme Preview Options
*/
interface PreviewOptions {
/** Include color palette preview */
showColors?: boolean;
/** Include component previews */
showComponents?: boolean;
/** Include accessibility information */
showAccessibility?: boolean;
/** Show both light and dark modes */
showBothModes?: boolean;
/** Use compact format */
compact?: boolean;
/** Terminal width for formatting */
width?: number;
}
/**
* Theme Preview Generator
*
* Creates visual representations of themes in the terminal
* using ANSI colors and Unicode characters.
*/
export class ThemePreview {
private options: Required<PreviewOptions>;
constructor(options: PreviewOptions = {}) {
this.options = {
showColors: true,
showComponents: true,
showAccessibility: true,
showBothModes: false,
compact: false,
width: 80,
...options,
};
}
/**
* Generate theme preview
*
* @param theme - Theme to preview
* @param mode - Specific mode to preview (optional)
* @returns Preview string
*/
generatePreview(theme: Theme, mode?: ThemeMode): string {
const lines: string[] = [];
// Theme header
lines.push(this.generateHeader(theme));
lines.push('');
// Show specified mode or both modes
const modes = mode ? [mode] : this.options.showBothModes ? ['light', 'dark'] as const : ['light'] as const;
for (let i = 0; i < modes.length; i++) {
const currentMode = modes[i];
const config = theme[currentMode];
if (modes.length > 1) {
lines.push(this.generateModeHeader(currentMode));
lines.push('');
}
// Color palette
if (this.options.showColors) {
lines.push(...this.generateColorPalette(config.palette, currentMode));
lines.push('');
}
// Component previews
if (this.options.showComponents) {
lines.push(...this.generateComponentPreviews(config.palette));
lines.push('');
}
// Add spacing between modes
if (i < modes.length - 1) {
lines.push('');
}
}
// Accessibility information
if (this.options.showAccessibility) {
lines.push(...this.generateAccessibilityInfo(theme));
}
return lines.join('\n');
}
/**
* Generate theme header
*
* @param theme - Theme configuration
* @returns Header lines
*/
private generateHeader(theme: Theme): string {
const title = TerminalStyle.style(`${theme.name} Theme`, ANSI.bold, ANSI.underline);
const subtitle = TerminalStyle.style(
`${theme.colorScheme} • ${theme.style} • v${theme.meta.version}`,
ANSI.dim
);
return `${title}\n${subtitle}`;
}
/**
* Generate mode header
*
* @param mode - Theme mode
* @returns Mode header
*/
private generateModeHeader(mode: ThemeMode): string {
const modeIcon = mode === 'light' ? '☀️ ' : '🌙 ';
const modeName = mode.charAt(0).toUpperCase() + mode.slice(1);
return TerminalStyle.style(`${modeIcon}${modeName} Mode`, ANSI.bold);
}
/**
* Generate color palette preview
*
* @param palette - Color palette
* @param mode - Theme mode
* @returns Palette preview lines
*/
private generateColorPalette(palette: ColorPalette, mode: ThemeMode): string[] {
const lines: string[] = [];
if (!this.options.compact) {
lines.push(TerminalStyle.style('Color Palette', ANSI.bold));
lines.push('');
}
// Group colors by category
const categories = {
'Core Colors': ['background', 'foreground', 'border', 'input', 'ring'],
'Brand Colors': ['primary', 'primary-foreground', 'secondary', 'secondary-foreground'],
'Surface Colors': ['card', 'card-foreground', 'popover', 'popover-foreground'],
'Semantic Colors': ['muted', 'muted-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground'],
'Chart Colors': ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'],
};
for (const [category, colorNames] of Object.entries(categories)) {
const categoryColors = colorNames
.map(name => palette[name as keyof ColorPalette])
.filter(Boolean);
if (categoryColors.length === 0) continue;
if (!this.options.compact) {
lines.push(TerminalStyle.style(category, ANSI.bold, ANSI.cyan));
}
for (const color of categoryColors) {
if (!color) continue;
const colorPreview = this.generateColorSwatch(color.value);
const colorName = TerminalStyle.style(color.name.padEnd(20), ANSI.bold);
const colorValue = TerminalStyle.style(color.value, ANSI.dim);
lines.push(` ${colorPreview} ${colorName} ${colorValue}`);
}
if (!this.options.compact) {
lines.push('');
}
}
return lines;
}
/**
* Generate color swatch
*
* @param hsl - HSL color value
* @returns Color swatch string
*/
private generateColorSwatch(hsl: string): string {
const bgColor = TerminalStyle.hslToBgAnsi(hsl);
return TerminalStyle.style('███', bgColor);
}
/**
* Generate component previews
*
* @param palette - Color palette
* @returns Component preview lines
*/
private generateComponentPreviews(palette: ColorPalette): string[] {
const lines: string[] = [];
if (!this.options.compact) {
lines.push(TerminalStyle.style('Component Previews', ANSI.bold));
lines.push('');
}
// Button previews
lines.push(this.generateButtonPreview(palette));
lines.push('');
// Card preview
lines.push(this.generateCardPreview(palette));
lines.push('');
// Input preview
lines.push(this.generateInputPreview(palette));
return lines;
}
/**
* Generate button preview
*
* @param palette - Color palette
* @returns Button preview string
*/
private generateButtonPreview(palette: ColorPalette): string {
const primaryBtn = TerminalStyle.colorText(' Primary ', palette['primary-foreground'].value, palette.primary.value);
const secondaryBtn = TerminalStyle.colorText(' Secondary ', palette['secondary-foreground'].value, palette.secondary.value);
const destructiveBtn = TerminalStyle.colorText(' Destructive ', palette['destructive-foreground'].value, palette.destructive.value);
return `Buttons: ${primaryBtn} ${secondaryBtn} ${destructiveBtn}`;
}
/**
* Generate card preview
*
* @param palette - Color palette
* @returns Card preview string
*/
private generateCardPreview(palette: ColorPalette): string {
const cardBorder = '┌─────────────────┐';
const cardContent = TerminalStyle.colorText('│ Card Content │', palette['card-foreground'].value, palette.card.value);
const cardBottom = '└─────────────────┘';
return `Card:\n${cardBorder}\n${cardContent}\n${cardBottom}`;
}
/**
* Generate input preview
*
* @param palette - Color palette
* @returns Input preview string
*/
private generateInputPreview(palette: ColorPalette): string {
const inputField = TerminalStyle.colorText(' Enter text... ', palette.foreground.value, palette.input.value);
const border = TerminalStyle.style('┌─────────────────┐', TerminalStyle.hslToAnsi(palette.border.value));
const bottomBorder = TerminalStyle.style('└─────────────────┘', TerminalStyle.hslToAnsi(palette.border.value));
return `Input:\n${border}\n│${inputField}│\n${bottomBorder}`;
}
/**
* Generate accessibility information
*
* @param theme - Theme configuration
* @returns Accessibility info lines
*/
private generateAccessibilityInfo(theme: Theme): string[] {
const lines: string[] = [];
lines.push(TerminalStyle.style('Accessibility', ANSI.bold));
lines.push('');
const validation = themeRegistry.validate(theme);
// Overall compliance
const wcagAA = validation.accessibility.wcagAA
? TerminalStyle.style('✓ WCAG AA', ANSI.green)
: TerminalStyle.style('✗ WCAG AA', ANSI.red);
const wcagAAA = validation.accessibility.wcagAAA
? TerminalStyle.style('✓ WCAG AAA', ANSI.green)
: TerminalStyle.style('✗ WCAG AAA', ANSI.red);
lines.push(`Compliance: ${wcagAA} • ${wcagAAA}`);
// Contrast issues
if (validation.accessibility.contrastIssues.length > 0) {
lines.push('');
lines.push(TerminalStyle.style('Contrast Issues:', ANSI.bold, ANSI.yellow));
for (const issue of validation.accessibility.contrastIssues.slice(0, 5)) { // Show max 5
const ratio = issue.ratio.toFixed(1);
const required = issue.required.toFixed(1);
const level = TerminalStyle.style(issue.level, ANSI.red);
lines.push(` • ${issue.pair[0]}/${issue.pair[1]}: ${ratio}:1 (need ${required}:1 for ${level})`);
}
if (validation.accessibility.contrastIssues.length > 5) {
const remaining = validation.accessibility.contrastIssues.length - 5;
lines.push(TerminalStyle.style(` ... and ${remaining} more issues`, ANSI.dim));
}
}
return lines;
}
/**
* Generate theme comparison
*
* @param themes - Themes to compare
* @returns Comparison string
*/
generateComparison(themes: Theme[]): string {
const lines: string[] = [];
lines.push(TerminalStyle.style('Theme Comparison', ANSI.bold, ANSI.underline));
lines.push('');
// Create table header
const nameCol = 'Name'.padEnd(15);
const colorCol = 'Colors'.padEnd(10);
const accessCol = 'WCAG'.padEnd(8);
const tagsCol = 'Tags';
lines.push(TerminalStyle.style(`${nameCol} ${colorCol} ${accessCol} ${tagsCol}`, ANSI.bold));
lines.push('─'.repeat(this.options.width));
// Add theme rows
for (const theme of themes) {
const validation = themeRegistry.validate(theme);
const name = theme.name.substring(0, 14).padEnd(15);
const colors = theme.colorScheme.substring(0, 9).padEnd(10);
const wcag = validation.accessibility.wcagAA
? TerminalStyle.style('AA ✓'.padEnd(8), ANSI.green)
: TerminalStyle.style('AA ✗'.padEnd(8), ANSI.red);
const tags = theme.meta.tags.slice(0, 3).join(', ');
lines.push(`${name} ${colors} ${wcag} ${tags}`);
}
return lines.join('\n');
}
/**
* Generate theme list
*
* @param themes - Themes to list
* @returns Theme list string
*/
generateList(themes: Theme[]): string {
const lines: string[] = [];
for (let i = 0; i < themes.length; i++) {
const theme = themes[i];
const validation = themeRegistry.validate(theme);
const number = TerminalStyle.style(`${i + 1}.`.padEnd(4), ANSI.dim);
const name = TerminalStyle.style(theme.name, ANSI.bold);
const scheme = TerminalStyle.style(`(${theme.colorScheme})`, ANSI.cyan);
const wcag = validation.accessibility.wcagAA
? TerminalStyle.style(' ✓', ANSI.green)
: TerminalStyle.style(' ✗', ANSI.red);
lines.push(`${number} ${name} ${scheme}${wcag}`);
if (!this.options.compact) {
const description = TerminalStyle.style(` ${theme.meta.description}`, ANSI.dim);
lines.push(description);
}
}
return lines.join('\n');
}
}
/**
* Convenience functions
*/
/**
* Preview a theme by ID
*
* @param themeId - Theme identifier
* @param options - Preview options
* @returns Preview string
*/
export function previewTheme(themeId: string, options?: PreviewOptions): string {
const theme = themeRegistry.get(themeId);
if (!theme) {
return TerminalStyle.style(`Theme '${themeId}' not found`, ANSI.red);
}
const preview = new ThemePreview(options);
return preview.generatePreview(theme);
}
/**
* List all available themes
*
* @param options - Preview options
* @returns Theme list string
*/
export function listThemes(options?: PreviewOptions): string {
const themes = themeRegistry.getAll();
const preview = new ThemePreview(options);
return preview.generateList(themes);
}
/**
* Compare multiple themes
*
* @param themeIds - Theme identifiers to compare
* @param options - Preview options
* @returns Comparison string
*/
export function compareThemes(themeIds: string[], options?: PreviewOptions): string {
const themes = themeIds
.map(id => themeRegistry.get(id))
.filter(Boolean) as Theme[];
const preview = new ThemePreview(options);
return preview.generateComparison(themes);
}
/**
* Search and preview themes
*
* @param query - Search query
* @param options - Preview options
* @returns Search results with previews
*/
export function searchAndPreviewThemes(query: string, options?: PreviewOptions): string {
const themes = themeRegistry.search(query);
if (themes.length === 0) {
return TerminalStyle.style(`No themes found matching '${query}'`, ANSI.yellow);
}
const preview = new ThemePreview(options);
const header = TerminalStyle.style(`Found ${themes.length} theme(s) matching '${query}':`, ANSI.bold);
const list = preview.generateList(themes);
return `${header}\n\n${list}`;
}