diff --git a/.github/images/terminal-default.png b/.github/images/terminal-default.png new file mode 100644 index 00000000..eb7f35c4 Binary files /dev/null and b/.github/images/terminal-default.png differ diff --git a/.github/images/terminal-macos.png b/.github/images/terminal-macos.png new file mode 100644 index 00000000..84009b81 Binary files /dev/null and b/.github/images/terminal-macos.png differ diff --git a/apps/page/src/components/terminal.test.ts b/apps/page/src/components/terminal.test.ts new file mode 100644 index 00000000..dab0cdaa --- /dev/null +++ b/apps/page/src/components/terminal.test.ts @@ -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'); + }); +}); diff --git a/apps/page/src/components/terminal.tsx b/apps/page/src/components/terminal.tsx index 1cb7fbdb..f8b0e21c 100644 --- a/apps/page/src/components/terminal.tsx +++ b/apps/page/src/components/terminal.tsx @@ -45,7 +45,7 @@ const styles = { borderRadius: '3px', width: '20px', height: '20px', - cursor: 'pointer' + cursor: 'default' }, terminal: { fontFamily: 'monospace', @@ -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(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(() => { @@ -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 = () => ( +
+ {windowChrome.buttons.map((button, index) => ( + + {button.label} + + ))} +
+ ); return (
-