bktide
Version:
Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users
465 lines • 13.5 kB
JavaScript
/**
* Enhanced Visual Design System for bktide CLI
*
* This module implements a comprehensive color and typography system
* that enhances information hierarchy and accessibility.
*/
import chalk from 'chalk';
import { getSymbols } from './symbols.js';
import { terminalLink } from '../utils/terminal-links.js';
function isTTY() {
return Boolean(process.stdout.isTTY);
}
function colorEnabled() {
if (process.env.NO_COLOR)
return false;
const mode = process.env.BKTIDE_COLOR_MODE || 'auto';
if (mode === 'never')
return false;
if (mode === 'always')
return true;
return isTTY();
}
/**
* Semantic color system for different information types
* Using colorblind-safe palette
*/
export const SEMANTIC_COLORS = {
// Status colors (colorblind-safe)
success: (s) => colorEnabled() ? chalk.blue(s) : s,
error: (s) => colorEnabled() ? chalk.rgb(255, 140, 0)(s) : s,
warning: (s) => colorEnabled() ? chalk.yellow(s) : s,
info: (s) => colorEnabled() ? chalk.cyan(s) : s,
// Typography emphasis levels
heading: (s) => colorEnabled() ? chalk.bold.underline(s) : `== ${s} ==`,
subheading: (s) => colorEnabled() ? chalk.bold(s) : `** ${s} **`,
label: (s) => colorEnabled() ? chalk.bold(s) : s.toUpperCase(),
// Data type highlighting
identifier: (s) => colorEnabled() ? chalk.cyan(s) : s,
count: (s) => colorEnabled() ? chalk.magenta(s) : s,
url: (s, label) => terminalLink(s, label),
// De-emphasis (auxiliary information)
dim: (s) => colorEnabled() ? chalk.dim(s) : s,
muted: (s) => colorEnabled() ? chalk.gray(s) : s,
tip: (s) => colorEnabled() ? chalk.dim(s) : `(${s})`,
help: (s) => colorEnabled() ? chalk.dim.italic(s) : `[${s}]`,
// Special formatting
highlight: (s) => colorEnabled() ? chalk.magenta(s) : s,
code: (s) => colorEnabled() ? chalk.bgGray.white(` ${s} `) : `\`${s}\``,
};
/**
* Build status specific theming
* Matches conventions from GitHub Actions, CircleCI, Jenkins
*/
export const BUILD_STATUS_THEME = {
// Success states
PASSED: {
color: SEMANTIC_COLORS.success,
symbol: '✓',
ascii: '[OK]',
},
// Failure states
FAILED: {
color: SEMANTIC_COLORS.error,
symbol: '✖',
ascii: '[FAIL]',
},
FAILING: {
color: (s) => colorEnabled() ? chalk.rgb(255, 165, 0)(s) : s,
symbol: '⚠',
ascii: '[FAILING]',
},
// Warning states
BLOCKED: {
color: SEMANTIC_COLORS.warning,
symbol: '⚠',
ascii: '[BLOCKED]',
},
CANCELED: {
color: SEMANTIC_COLORS.warning,
symbol: '⊘',
ascii: '[CANCEL]',
},
CANCELING: {
color: SEMANTIC_COLORS.warning,
symbol: '⊘',
ascii: '[...]',
},
// Active states
RUNNING: {
color: SEMANTIC_COLORS.info,
symbol: '↻',
ascii: '[RUN]',
},
SCHEDULED: {
color: SEMANTIC_COLORS.info,
symbol: '⏱',
ascii: '[QUEUE]',
},
// Inactive states
SKIPPED: {
color: SEMANTIC_COLORS.muted,
symbol: '−',
ascii: '[SKIP]',
},
NOT_RUN: {
color: SEMANTIC_COLORS.muted,
symbol: '○',
ascii: '[--]',
},
};
/**
* Format a build status with appropriate color and symbol
*/
export function formatBuildStatus(status, options) {
// Normalize status to uppercase for theme lookup
const normalizedStatus = status.toUpperCase();
const theme = BUILD_STATUS_THEME[normalizedStatus];
if (!theme) {
return SEMANTIC_COLORS.muted(status);
}
const useSymbol = options?.useSymbol ?? true;
const ascii = options?.ascii ?? false;
if (useSymbol) {
const symbol = ascii ? theme.ascii : theme.symbol;
// Keep original casing for display
return `${symbol} ${theme.color(status)}`;
}
// Keep original casing for display
return theme.color(status);
}
/**
* Tip display styles for different contexts
*/
export var TipStyle;
(function (TipStyle) {
TipStyle["GROUPED"] = "grouped";
TipStyle["INDIVIDUAL"] = "individual";
TipStyle["ACTIONS"] = "actions";
TipStyle["FIXES"] = "fixes";
TipStyle["BOX"] = "box"; // Fancy box with arrows (wide terminals)
})(TipStyle || (TipStyle = {}));
/**
* Format tips with consistent styling based on context
*/
export function formatTips(tips, style = TipStyle.GROUPED, includeTurnOff = true) {
if (tips.length === 0)
return '';
// Add the turn-off tip if not already included
const allTips = [...tips];
const turnOffMessage = 'Use --no-tips to hide these hints';
if (includeTurnOff && !tips.some(tip => tip.includes('--no-tips'))) {
allTips.push(turnOffMessage);
}
switch (style) {
case TipStyle.GROUPED:
return formatGroupedTips(allTips);
case TipStyle.INDIVIDUAL:
return formatIndividualTips(allTips);
case TipStyle.ACTIONS:
return formatActionTips(allTips);
case TipStyle.FIXES:
return formatFixTips(allTips);
case TipStyle.BOX:
return formatTipBox(allTips); // Use existing function
default:
return formatGroupedTips(allTips);
}
}
function formatGroupedTips(tips) {
const lines = [];
lines.push(SEMANTIC_COLORS.dim('Tips:'));
tips.forEach(tip => {
lines.push(SEMANTIC_COLORS.dim(` → ${tip}`));
});
return lines.join('\n');
}
function formatIndividualTips(tips) {
return tips
.map(tip => SEMANTIC_COLORS.dim(`→ ${tip}`))
.join('\n');
}
function formatActionTips(tips) {
const lines = [];
lines.push(SEMANTIC_COLORS.dim('Next steps:'));
tips.forEach(tip => {
lines.push(SEMANTIC_COLORS.dim(` → ${tip}`));
});
return lines.join('\n');
}
function formatFixTips(tips) {
const lines = [];
lines.push(SEMANTIC_COLORS.subheading('To fix this:'));
tips.forEach((tip, i) => {
lines.push(` ${i + 1}. ${tip}`);
});
return lines.join('\n');
}
/**
* Create a formatted tip box for better visual separation
* Only used in wide terminals
*/
export function formatTipBox(tips, width) {
const termWidth = width || process.stdout.columns || 80;
// Only use fancy box in wide terminals
if (termWidth < 80 || !colorEnabled()) {
return formatGroupedTips(tips);
}
const lines = [];
const boxWidth = Math.min(termWidth - 4, 60);
const border = '─'.repeat(boxWidth - 10);
lines.push(SEMANTIC_COLORS.dim(`┌─ Tips ${border}`));
tips.forEach(tip => {
lines.push(SEMANTIC_COLORS.dim(`│ → ${tip}`));
});
lines.push(SEMANTIC_COLORS.dim(`└${'─'.repeat(boxWidth - 1)}`));
return lines.join('\n');
}
/**
* Format an error message with consistent styling
*/
export function formatError(error, options) {
const lines = [];
const symbols = getSymbols();
// Error header
lines.push(`${SEMANTIC_COLORS.error(symbols.error)} ${chalk.bold('Error')}`);
lines.push('');
// Error message
const message = typeof error === 'string' ? error : error.message;
lines.push(message);
// Suggestions if provided
if (options?.suggestions && options.suggestions.length > 0) {
lines.push('');
lines.push(formatTips(options.suggestions, TipStyle.FIXES));
}
// Help command
if (options?.showHelp && options?.helpCommand) {
lines.push('');
lines.push(SEMANTIC_COLORS.help(`Need help? Run: ${options.helpCommand}`));
}
return lines.join('\n');
}
/**
* Format a success message (subtle, not redundant)
*/
export function formatSuccess(message, count) {
const symbols = getSymbols();
if (count !== undefined) {
return `${SEMANTIC_COLORS.success(symbols.success)} ${message} ${SEMANTIC_COLORS.count(count.toString())}`;
}
return `${SEMANTIC_COLORS.success(symbols.success)} ${message}`;
}
/**
* Format empty state messages
*/
export function formatEmptyState(message, suggestions) {
const lines = [];
lines.push(SEMANTIC_COLORS.dim(message));
if (suggestions && suggestions.length > 0) {
lines.push('');
suggestions.forEach(s => {
// Make commands stand out from the dimmed text
const formatted = s.replace(/--\w+[^ ]*/g, match => chalk.reset(match));
lines.push(SEMANTIC_COLORS.dim(formatted));
});
}
return lines.join('\n');
}
/**
* Legacy color exports for backward compatibility
* @deprecated Use SEMANTIC_COLORS instead
*/
export const COLORS = {
error: SEMANTIC_COLORS.error,
warn: SEMANTIC_COLORS.warning,
success: SEMANTIC_COLORS.success,
info: SEMANTIC_COLORS.info,
muted: SEMANTIC_COLORS.muted,
highlight: SEMANTIC_COLORS.highlight,
dim: SEMANTIC_COLORS.dim,
};
// Export symbols from the symbols module
export const SYMBOLS = getSymbols();
export function shouldDecorate(format) {
const f = (format || '').toLowerCase();
return f !== 'json' && f !== 'alfred' && colorEnabled();
}
/**
* Icon Display Modes
*/
export var IconMode;
(function (IconMode) {
IconMode["EMOJI"] = "emoji";
IconMode["UTF8"] = "utf8";
IconMode["ASCII"] = "ascii"; // ASCII-only fallback
})(IconMode || (IconMode = {}));
/**
* Build and Job State Icons
* Each has emoji, UTF-8, and ASCII alternatives
*/
export const STATE_ICONS = {
PASSED: {
emoji: '✅',
utf8: '✓', // U+2713 Check mark
ascii: '[OK]'
},
FAILED: {
emoji: '❌',
utf8: '✗', // U+2717 Ballot X
ascii: '[FAIL]'
},
RUNNING: {
emoji: '🔄',
utf8: '↻', // U+21BB Clockwise arrow
ascii: '[RUN]'
},
BLOCKED: {
emoji: '⏸️',
utf8: '‖', // U+2016 Double vertical line
ascii: '[BLOCK]'
},
CANCELED: {
emoji: '🚫',
utf8: '⊘', // U+2298 Circled division slash
ascii: '[CANCEL]'
},
SCHEDULED: {
emoji: '📅',
utf8: '⏰', // U+23F0 Alarm clock
ascii: '[SCHED]'
},
SKIPPED: {
emoji: '⏭️',
utf8: '»', // U+00BB Right-pointing double angle
ascii: '[SKIP]'
},
UNKNOWN: {
emoji: '❓',
utf8: '?', // Regular question mark
ascii: '[?]'
}
};
/**
* Annotation Style Icons
*/
export const ANNOTATION_ICONS = {
ERROR: {
emoji: '❌',
utf8: '✗', // U+2717 Ballot X
ascii: '[ERR]'
},
WARNING: {
emoji: '⚠️',
utf8: '⚠', // U+26A0 Warning sign (without emoji variant)
ascii: '[WARN]'
},
INFO: {
emoji: 'ℹ️',
utf8: 'ℹ', // U+2139 Information source (no circle)
ascii: '[INFO]'
},
SUCCESS: {
emoji: '✅',
utf8: '✓', // U+2713 Check mark
ascii: '[OK]'
},
DEFAULT: {
emoji: '📝',
utf8: '◆', // U+25C6 Black diamond
ascii: '[NOTE]'
}
};
/**
* Progress and Debug Icons
*/
export const PROGRESS_ICONS = {
TIMING: {
emoji: '⏱️',
utf8: '⧗', // U+29D7 Black hourglass
ascii: '[TIME]'
},
STARTING: {
emoji: '🕒',
utf8: '◷', // U+25F7 White circle with upper right quadrant
ascii: '[>>>]'
},
RETRY: {
emoji: '🔄',
utf8: '↻', // U+21BB Clockwise arrow
ascii: '[RETRY]'
},
SUCCESS_LOG: {
emoji: '✅',
utf8: '✓', // U+2713 Check mark
ascii: '[✓]'
},
BLOCKED_MESSAGE: {
emoji: '🚫',
utf8: '⊘', // U+2298 Circled division slash
ascii: '[BLOCKED]'
},
PARALLEL: {
emoji: '📊',
utf8: '═', // U+2550 Box drawings double horizontal
ascii: '[||]'
}
};
/**
* Get current icon mode based on environment and flags
*/
export function getIconMode() {
// Check command-line flags first
if (process.argv.includes('--ascii')) {
return IconMode.ASCII;
}
if (process.argv.includes('--emoji')) {
return IconMode.EMOJI;
}
// Check environment variables
if (process.env.BKTIDE_ASCII === '1') {
return IconMode.ASCII;
}
if (process.env.BKTIDE_EMOJI === '1') {
return IconMode.EMOJI;
}
// Default to UTF-8 symbols (clean, universal, works in most modern terminals)
// ASCII is only used if explicitly requested via flag or env var
return IconMode.UTF8;
}
/**
* Helper to get icon based on current mode
*/
export function getIcon(iconDef) {
const mode = getIconMode();
switch (mode) {
case IconMode.ASCII:
return iconDef.ascii;
case IconMode.UTF8:
return iconDef.utf8;
default:
return iconDef.emoji;
}
}
/**
* Get state icon for build/job states
*/
export function getStateIcon(state) {
const upperState = state.toUpperCase().replace('CANCELING', 'CANCELED');
const iconDef = STATE_ICONS[upperState] || STATE_ICONS.UNKNOWN;
return getIcon(iconDef);
}
/**
* Get annotation style icon
*/
export function getAnnotationIcon(style) {
const upperStyle = style.toUpperCase();
const iconDef = ANNOTATION_ICONS[upperStyle] || ANNOTATION_ICONS.DEFAULT;
return getIcon(iconDef);
}
/**
* Get progress/debug icon
*/
export function getProgressIcon(type) {
return getIcon(PROGRESS_ICONS[type]);
}
//# sourceMappingURL=theme.js.map