Skip to content

Commit 0e213bc

Browse files
committed
Add terminal usability command
1 parent e6a56d0 commit 0e213bc

8 files changed

Lines changed: 514 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ Early history is grouped by day to keep the file readable while still covering t
66

77
## 2026
88

9+
### 2026-05-15
10+
11+
- Reorganized the xterm.js terminal preview modules around terminal-view naming to avoid confusion with the xterm export format.
12+
- Split terminal preview command builders into a dedicated commands area.
13+
- Added a live `usability` command that reports WCAG-based text contrast for the current terminal palette.
14+
- Updated terminal help output with grouped tools and example commands, including colored command names and short descriptions.
15+
16+
### 2026-05-14
17+
18+
- Migrated the terminal preview window to xterm.js while preserving the classic 4bit terminal proportions and font behavior.
19+
- Added an interactive terminal preview prompt with commands for color matrix, `git diff`, `git status`, `ls`, `ls -al`, `clear`, and help output.
20+
- Kept terminal preview content stable during slider changes by updating the theme without restoring the boot transcript.
21+
- Fixed terminal view dimensions while the page and font are loading.
22+
23+
### 2026-05-13
24+
25+
- Added export support for Windows Terminal, Termite, ConEmu, KiTTY, and macOS Terminal.
26+
- Updated GNOME Terminal export support for the newer format without keeping the old fallback.
27+
- Stabilized the ConEmu snapshot date used in generated output.
28+
29+
### 2026-05-12
30+
31+
- Restyled About page feature cards to resemble terminal previews.
32+
933
### 2026-05-11
1034

1135
- Added a dedicated SEO-focused About page for 4bit with project history, supported terminal links, source references, and an explanation of the color-generation model.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { joinTerminalLines, styledText } from '../ansi-terminal-sequence';
2+
3+
const MIN_TEXT_CONTRAST = 4.5;
4+
const WARN_TEXT_CONTRAST = 3;
5+
6+
const ANSI_COLOR_CHECKS = [
7+
['black', 'black', [30]],
8+
['red', 'red', [31]],
9+
['green', 'green', [32]],
10+
['yellow', 'yellow', [33]],
11+
['blue', 'blue', [34]],
12+
['magenta', 'magenta', [35]],
13+
['cyan', 'cyan', [36]],
14+
['white', 'white', [37]],
15+
['bright black', 'brightBlack', [90]],
16+
['bright red', 'brightRed', [91]],
17+
['bright green', 'brightGreen', [92]],
18+
['bright yellow', 'brightYellow', [93]],
19+
['bright blue', 'brightBlue', [94]],
20+
['bright magenta', 'brightMagenta', [95]],
21+
['bright cyan', 'brightCyan', [96]],
22+
['bright white', 'brightWhite', [97]],
23+
];
24+
25+
const CELL_WIDTH = 36;
26+
const LABEL_WIDTH = 16;
27+
const RATIO_WIDTH = 6;
28+
29+
function rgbChannels(color) {
30+
return color?.rgb?.().array?.() ?? null;
31+
}
32+
33+
function linearChannel(value) {
34+
const normalized = value / 255;
35+
return normalized <= 0.03928
36+
? normalized / 12.92
37+
: ((normalized + 0.055) / 1.055) ** 2.4;
38+
}
39+
40+
function relativeLuminance(color) {
41+
const channels = rgbChannels(color);
42+
43+
if (!channels) {
44+
return null;
45+
}
46+
47+
const [red, green, blue] = channels.map(linearChannel);
48+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
49+
}
50+
51+
export function contrastRatio(foreground, background) {
52+
const foregroundLuminance = relativeLuminance(foreground);
53+
const backgroundLuminance = relativeLuminance(background);
54+
55+
if (foregroundLuminance === null || backgroundLuminance === null) {
56+
return null;
57+
}
58+
59+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
60+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
61+
return (lighter + 0.05) / (darker + 0.05);
62+
}
63+
64+
function contrastStatus(ratio, threshold = MIN_TEXT_CONTRAST) {
65+
if (ratio === null) {
66+
return 'N/A';
67+
}
68+
69+
if (ratio >= threshold) {
70+
return 'PASS';
71+
}
72+
73+
if (ratio >= WARN_TEXT_CONTRAST) {
74+
return 'WARN';
75+
}
76+
77+
return 'FAIL';
78+
}
79+
80+
function styledStatus(status) {
81+
if (status === 'PASS') {
82+
return styledText(status, [32]);
83+
}
84+
85+
if (status === 'WARN') {
86+
return styledText(status, [33]);
87+
}
88+
89+
if (status === 'FAIL') {
90+
return styledText(status, [31]);
91+
}
92+
93+
return status;
94+
}
95+
96+
function formatRatio(ratio) {
97+
return ratio === null ? 'n/a'.padStart(6) : `${ratio.toFixed(1)}:1`.padStart(6);
98+
}
99+
100+
function visiblePadding(value, width) {
101+
return ' '.repeat(Math.max(0, width - value.length));
102+
}
103+
104+
function formatCell(label, ratio, threshold = MIN_TEXT_CONTRAST, displayLabel = label) {
105+
const status = contrastStatus(ratio, threshold);
106+
const value = formatRatio(ratio);
107+
const cell = `${displayLabel}${visiblePadding(label, LABEL_WIDTH)} ${value} ${styledStatus(status)}`;
108+
const visibleCell = `${label}${visiblePadding(label, LABEL_WIDTH)} ${value} ${status}`;
109+
return `${cell}${visiblePadding(visibleCell, CELL_WIDTH)}`;
110+
}
111+
112+
function formatRow(leftCheck, rightCheck) {
113+
const left = leftCheck ? formatCell(...leftCheck) : ' '.repeat(CELL_WIDTH);
114+
115+
if (!rightCheck) {
116+
return `| ${left} | ${' '.repeat(CELL_WIDTH)} |`;
117+
}
118+
119+
return `| ${left} | ${formatCell(...rightCheck)} |`;
120+
}
121+
122+
function pairChecks(checks) {
123+
const foreground = checks[0];
124+
const colorChecks = checks.slice(1);
125+
const midpoint = colorChecks.length / 2;
126+
const leftColumn = [foreground, ...colorChecks.slice(0, midpoint)];
127+
const rightColumn = [null, ...colorChecks.slice(midpoint)];
128+
129+
return leftColumn.map((check, index) => [check, rightColumn[index]]);
130+
}
131+
132+
function tableBorder() {
133+
return `+${'-'.repeat(CELL_WIDTH + 2)}+${'-'.repeat(CELL_WIDTH + 2)}+`;
134+
}
135+
136+
function tableHeader() {
137+
const header = `${'check'.padEnd(LABEL_WIDTH)} ${'ratio'.padStart(RATIO_WIDTH)} result`;
138+
return `| ${header}${visiblePadding(header, CELL_WIDTH)} | ${header}${visiblePadding(header, CELL_WIDTH)} |`;
139+
}
140+
141+
function tableRows(checks) {
142+
return [
143+
tableBorder(),
144+
tableHeader(),
145+
tableBorder(),
146+
...pairChecks(checks).map(([leftCheck, rightCheck]) => formatRow(leftCheck, rightCheck)),
147+
tableBorder(),
148+
];
149+
}
150+
151+
function buildChecks(colors) {
152+
const background = colors?.background;
153+
154+
return [
155+
[
156+
'foreground',
157+
contrastRatio(colors?.foreground, background),
158+
MIN_TEXT_CONTRAST,
159+
styledText('foreground', [39]),
160+
],
161+
...ANSI_COLOR_CHECKS.map(([label, colorName, style]) => [
162+
label,
163+
contrastRatio(colors?.[colorName], background),
164+
MIN_TEXT_CONTRAST,
165+
styledText(label, style),
166+
]),
167+
];
168+
}
169+
170+
export function buildUsabilityPreviewCommand(colors) {
171+
const checks = buildChecks(colors);
172+
173+
return joinTerminalLines([
174+
'',
175+
'Checks if terminal text colors stay readable on the background.',
176+
`Uses ${styledText('WCAG', [1])} 2.x contrast: ${styledStatus('PASS')} >= 4.5:1, ${styledStatus('WARN')} >= 3.0:1, ${styledStatus('FAIL')} < 3.0:1.`,
177+
'',
178+
...tableRows(checks),
179+
]);
180+
}

src/application/terminal-preview/terminal-preview-shell.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ import {
66
buildLsAllPreviewCommand,
77
buildLsPreviewCommand,
88
} from './commands/build-ls-preview-command';
9+
import { buildUsabilityPreviewCommand } from './commands/build-usability-preview-command';
910
import { joinTerminalLines, styledText } from './ansi-terminal-sequence';
1011

1112
export const TERMINAL_PREVIEW_CLEAR_COMMAND = { type: 'clear' };
1213

14+
function dynamicCommandOutput(content) {
15+
return {
16+
type: 'dynamic',
17+
content,
18+
};
19+
}
20+
1321
export function renderTerminalPreviewPrompt({ command = null } = {}) {
1422
const prompt = terminalPreviewPromptCommand();
1523
let out = '';
@@ -32,17 +40,21 @@ export function renderTerminalPreviewHelpLine() {
3240

3341
function renderAvailableCommands() {
3442
return joinTerminalLines([
35-
'Available commands:',
36-
' clear',
37-
' colors',
38-
' git diff',
39-
' git status',
40-
' ls',
41-
' ls -al',
43+
'',
44+
'Tools:',
45+
` ${styledText('clear', [35])} Clear the terminal screen.`,
46+
` ${styledText('colors', [35])} Show the ANSI color matrix.`,
47+
` ${styledText('usability', [35])} Check WCAG-based text contrast.`,
48+
'',
49+
'Examples:',
50+
` ${styledText('git diff', [36])} Show a colored git diff sample.`,
51+
` ${styledText('git status', [36])} Show a colored git status sample.`,
52+
` ${styledText('ls', [36])} Show a compact directory listing.`,
53+
` ${styledText('ls -al', [36])} Show a detailed directory listing.`,
4254
]);
4355
}
4456

45-
export function runTerminalPreviewCommand(command) {
57+
export function runTerminalPreviewCommand(command, context = {}) {
4658
const normalizedCommand = command.trim();
4759

4860
if (normalizedCommand === '') {
@@ -73,6 +85,10 @@ export function runTerminalPreviewCommand(command) {
7385
return buildGitStatusPreviewCommand();
7486
}
7587

88+
if (normalizedCommand === 'usability' || normalizedCommand === 'wcag') {
89+
return dynamicCommandOutput(buildUsabilityPreviewCommand(context.colors));
90+
}
91+
7692
if (normalizedCommand === 'help') {
7793
return renderAvailableCommands();
7894
}

src/presentation/editor-page/components/TerminalDisplay.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ export default {
2424
},
2525
mounted() {
2626
this.previewSequence = buildTerminalPreviewSequence();
27+
this.currentColors = null;
2728
this.terminalPreview = createTerminalView(this.$refs.terminalElement, {
2829
prompt: renderTerminalPreviewPrompt(),
29-
runCommand: runTerminalPreviewCommand,
30+
runCommand: (command) => runTerminalPreviewCommand(command, { colors: this.currentColors }),
3031
});
3132
this.stopThemeWatcher = watch(
3233
() => this.calculatedSchemeStore.calculatedScheme,
@@ -42,7 +43,9 @@ export default {
4243
},
4344
methods: {
4445
renderTerminalPreview(colors) {
46+
this.currentColors = colors;
4547
this.terminalPreview?.render(this.previewSequence, terminalViewThemeFromScheme(colors));
48+
this.terminalPreview?.refreshDynamicCommand();
4649
},
4750
},
4851
};

0 commit comments

Comments
 (0)