Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/images/terminal-default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/images/terminal-macos.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions apps/page/src/components/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';

import { getTerminalWindowChrome } from './terminal';

describe('getTerminalWindowChrome', () => {
test('uses macOS chrome when userAgentData reports macOS', () => {
const chrome = getTerminalWindowChrome({
userAgentData: {
platform: 'macOS'
}
});

expect(chrome.buttonPosition).toBe('left');
expect(chrome.titleBarBackgroundColor).toBe('#ececec');
expect(chrome.buttons.map((button) => button.backgroundColor)).toEqual(['#ff5f57', '#febc2e', '#28c840']);
});

test('falls back to navigator.platform for macOS detection', () => {
const chrome = getTerminalWindowChrome({
platform: 'MacIntel'
});

expect(chrome.buttonPosition).toBe('left');
});

test('keeps non-macOS chrome on the right', () => {
const chrome = getTerminalWindowChrome({
platform: 'Win32',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
});

expect(chrome.buttonPosition).toBe('right');
expect(chrome.titleBarBackgroundColor).toBe('#333');
expect(chrome.buttons.map((button) => button.label)).toEqual(['_', '□', '×']);
});

test('falls back to the user agent string for macOS detection', () => {
const chrome = getTerminalWindowChrome({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)'
});

expect(chrome.buttonPosition).toBe('left');
});
});
148 changes: 140 additions & 8 deletions apps/page/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const styles = {
borderRadius: '3px',
width: '20px',
height: '20px',
cursor: 'pointer'
cursor: 'default'
},
terminal: {
fontFamily: 'monospace',
Expand All @@ -65,12 +65,102 @@ type TerminalProps = {
backgroundColor?: string;
};

type NavigatorLike = {
platform?: string;
userAgent?: string;
userAgentData?: {
platform?: string;
};
};

type TerminalWindowButton = {
backgroundColor: string;
color: string;
label: string;
borderRadius?: string;
fontSize?: string;
height?: string;
width?: string;
};

type TerminalWindowChrome = {
titleBarBackgroundColor: string;
titleBarColor: string;
buttonPosition: 'left' | 'right';
buttons: readonly TerminalWindowButton[];
};

const DEFAULT_WINDOW_CHROME = {
titleBarBackgroundColor: '#333',
titleBarColor: '#fff',
buttonPosition: 'right',
buttons: [
{ backgroundColor: '#555', color: '#fff', label: '_' },
{ backgroundColor: '#555', color: '#fff', label: '□' },
{ backgroundColor: '#555', color: '#fff', label: '×' }
]
} as const satisfies TerminalWindowChrome;

const MACOS_WINDOW_CHROME = {
titleBarBackgroundColor: '#ececec',
titleBarColor: '#444',
buttonPosition: 'left',
buttons: [
{
backgroundColor: '#ff5f57',
borderRadius: '999px',
color: 'transparent',
fontSize: '0',
height: '12px',
label: '●',
width: '12px'
},
{
backgroundColor: '#febc2e',
borderRadius: '999px',
color: 'transparent',
fontSize: '0',
height: '12px',
label: '●',
width: '12px'
},
{
backgroundColor: '#28c840',
borderRadius: '999px',
color: 'transparent',
fontSize: '0',
height: '12px',
label: '●',
width: '12px'
}
]
} as const satisfies TerminalWindowChrome;

const isMacOs = (navigatorLike?: NavigatorLike) => {
const userAgentDataPlatform = navigatorLike?.userAgentData?.platform?.toLowerCase();
if (userAgentDataPlatform?.includes('mac')) {
return true;
}

const platform = navigatorLike?.platform?.toLowerCase();
if (platform?.includes('mac')) {
return true;
}

const userAgent = navigatorLike?.userAgent?.toLowerCase();
return Boolean(userAgent?.includes('macintosh') || userAgent?.includes('mac os x'));
};

export const getTerminalWindowChrome = (navigatorLike?: NavigatorLike): TerminalWindowChrome =>
isMacOs(navigatorLike) ? MACOS_WINDOW_CHROME : DEFAULT_WINDOW_CHROME;

const Terminal = ({ title, terminalRef: externalRef, fontColor = '#0f0', backgroundColor = '#000' }: TerminalProps) => {
const internalRef = useRef<HTMLDivElement | null>(null);
const terminalRef = externalRef ?? internalRef;
const dimensionsRef = useRef<{ rows: number; cols: number } | null>(null);
const initializedRef = useRef(false);
const charSizeRef = useRef<{ width: number; height: number } | null>(null);
const windowChrome = getTerminalWindowChrome(typeof navigator === 'undefined' ? undefined : navigator);

// Use useLayoutEffect to measure and build rows synchronously before paint
useLayoutEffect(() => {
Expand Down Expand Up @@ -244,16 +334,58 @@ const Terminal = ({ title, terminalRef: externalRef, fontColor = '#0f0', backgro
}),
[fontColor, backgroundColor]
);
const titleBarStyle = useMemo(
() => ({
...styles.titleBar,
backgroundColor: windowChrome.titleBarBackgroundColor,
color: windowChrome.titleBarColor,
justifyContent: windowChrome.buttonPosition === 'left' ? 'flex-start' : 'flex-end',
position: 'relative'
}),
[windowChrome]
);
const titleStyle = useMemo(
() => ({
...styles.title,
alignItems: 'center',
color: windowChrome.titleBarColor,
display: 'flex',
justifyContent: 'center',
left: 0,
pointerEvents: 'none',
position: 'absolute',
right: 0,
top: 0,
bottom: 0
}),
[windowChrome]
);
const renderWindowButtons = () => (
<div style={styles.buttons}>
{windowChrome.buttons.map((button, index) => (
<span
key={index}
style={{
...styles.button,
backgroundColor: button.backgroundColor,
borderRadius: button.borderRadius ?? styles.button.borderRadius,
color: button.color,
fontSize: button.fontSize ?? 'inherit',
height: button.height ?? styles.button.height,
width: button.width ?? styles.button.width
}}
>
{button.label}
</span>
))}
</div>
);

return (
<div style={styles.window}>
<div style={styles.titleBar} aria-hidden="true">
<span style={styles.title}>{title}</span>
<div style={styles.buttons}>
<span style={styles.button}>_</span>
<span style={styles.button}>□</span>
<span style={styles.button}>×</span>
</div>
<div style={titleBarStyle} aria-hidden="true">
{renderWindowButtons()}
<span style={titleStyle}>{title}</span>
</div>
<div
ref={terminalRef}
Expand Down