Skip to content

Commit ac30d2d

Browse files
Fix light mode serial console (#3127)
Colours in light mode aren't super saturated. Can possibly revisit and add different ones per mode but doesn't seem super important. I'd have to review other peoples light themes to see how they use colour but keep the contrast high. ![CleanShot 2026-03-16 at 15 51 36](https://github.com/user-attachments/assets/a6ff294f-394f-42a1-81a1-18689e62e3c0) #3087 --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent b43b995 commit ac30d2d

6 files changed

Lines changed: 234 additions & 38 deletions

File tree

app/components/Terminal.tsx

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,33 @@ import { AttachAddon } from './AttachAddon'
1717

1818
const ScrollButton = classed.button`ml-4 flex h-8 w-8 items-center justify-center rounded-md border border-secondary hover:bg-hover`
1919

20-
function getOptions(): ITerminalOptions {
20+
function getTheme(): ITerminalOptions['theme'] {
2121
const style = getComputedStyle(document.body)
22+
return {
23+
background: style.getPropertyValue('--surface-default'),
24+
foreground: style.getPropertyValue('--content-default'),
25+
black: style.getPropertyValue('--surface-default'),
26+
brightBlack: style.getPropertyValue('--content-quinary'),
27+
white: style.getPropertyValue('--content-default'),
28+
brightWhite: style.getPropertyValue('--content-secondary'),
29+
blue: style.getPropertyValue('--content-info-secondary'),
30+
brightBlue: style.getPropertyValue('--content-info'),
31+
green: style.getPropertyValue('--content-success-secondary'),
32+
brightGreen: style.getPropertyValue('--content-success'),
33+
red: style.getPropertyValue('--content-error-secondary'),
34+
brightRed: style.getPropertyValue('--content-error'),
35+
yellow: style.getPropertyValue('--content-notice-secondary'),
36+
brightYellow: style.getPropertyValue('--content-notice'),
37+
cyan: style.getPropertyValue('--content-accent-secondary'),
38+
brightCyan: style.getPropertyValue('--content-accent'),
39+
magenta: style.getPropertyValue('--content-accent-alt-secondary'),
40+
brightMagenta: style.getPropertyValue('--content-accent-alt'),
41+
cursor: style.getPropertyValue('--content-default'),
42+
cursorAccent: style.getPropertyValue('--surface-default'),
43+
}
44+
}
45+
46+
function getOptions(): ITerminalOptions {
2247
return {
2348
// it is not easy to figure out what the exact behavior is when scrollback
2449
// is not defined because it seems to be used in a bunch of places in the
@@ -36,24 +61,7 @@ function getOptions(): ITerminalOptions {
3661
fullscreenWin: true,
3762
refreshWin: true,
3863
},
39-
theme: {
40-
background: style.getPropertyValue('--surface-default'),
41-
foreground: style.getPropertyValue('--content-default'),
42-
black: style.getPropertyValue('--surface-default'),
43-
brightBlack: style.getPropertyValue('--content-quinary'),
44-
white: style.getPropertyValue('--content-default'),
45-
brightWhite: style.getPropertyValue('--content-secondary'),
46-
blue: style.getPropertyValue('--base-blue-500'),
47-
brightBlue: style.getPropertyValue('--base-blue-900'),
48-
green: style.getPropertyValue('--content-success'),
49-
brightGreen: style.getPropertyValue('--content-success-secondary'),
50-
red: style.getPropertyValue('--content-error'),
51-
brightRed: style.getPropertyValue('--content-error-secondary'),
52-
yellow: style.getPropertyValue('--content-notice'),
53-
brightYellow: style.getPropertyValue('--content-notice-secondary'),
54-
cursor: style.getPropertyValue('--content-default'),
55-
cursorAccent: style.getPropertyValue('--surface-default'),
56-
},
64+
theme: getTheme(),
5765
}
5866
}
5967

@@ -94,7 +102,20 @@ export function Terminal({ ws }: TerminalProps) {
94102
}
95103

96104
window.addEventListener('resize', resize)
105+
106+
// Update terminal colors when the theme changes. getComputedStyle in
107+
// getTheme() forces a synchronous style recalc, so the CSS custom
108+
// properties already reflect the new theme by the time we read them.
109+
const observer = new MutationObserver(() => {
110+
newTerm.options.theme = getTheme()
111+
})
112+
observer.observe(document.documentElement, {
113+
attributes: true,
114+
attributeFilter: ['data-theme'],
115+
})
116+
97117
return () => {
118+
observer.disconnect()
98119
newTerm.dispose()
99120
window.removeEventListener('resize', resize)
100121
}

app/msw-mock-api.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,17 @@ const randomStatus = () => {
5454

5555
const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms))
5656

57-
async function streamString(socket: WebSocket, s: string, delayMs = 50) {
58-
for (const c of s) {
59-
socket.send(c)
60-
await sleep(delayMs)
57+
/** Stream boot log line-by-line with realistic timing */
58+
async function streamBootLog(socket: WebSocket, text: string) {
59+
for (const line of text.split('\n')) {
60+
socket.send(line + '\r\n')
61+
if (line === '' || line.startsWith('Welcome to') || line.includes('login:')) {
62+
await sleep(200)
63+
} else if (line.startsWith('[ OK ]') || line.startsWith(' Starting')) {
64+
await sleep(30)
65+
} else {
66+
await sleep(15)
67+
}
6168
}
6269
}
6370

@@ -66,6 +73,7 @@ export async function startMockAPI() {
6673
const { handlers } = await import('../mock-api/msw/handlers')
6774
const { http, HttpResponse, ws } = await import('msw')
6875
const { setupWorker } = await import('msw/browser')
76+
const serialConsoleText = (await import('../mock-api/serial-console.txt?raw')).default
6977

7078
// defined in here because it depends on the dynamic import
7179
const interceptAll = http.all('/v1/*', async () => {
@@ -102,7 +110,7 @@ export async function startMockAPI() {
102110
client.send(event.data.toString() === '13' ? '\r\n' : event.data)
103111
})
104112
await sleep(1000) // make sure everything is ready first (especially a problem in CI)
105-
await streamString(client.socket, 'Wake up Neo...')
113+
await streamBootLog(client.socket, serialConsoleText)
106114
})
107115
).start({
108116
quiet: true, // don't log successfully handled requests

app/pages/project/instances/SerialConsolePage.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ type SkeletonProps = {
178178

179179
function SerialSkeleton({ children, animate }: SkeletonProps) {
180180
return (
181-
<div className="relative h-full shrink grow overflow-hidden">
181+
<div className="bg-default relative h-full shrink grow overflow-hidden">
182182
<div className="h-full space-y-2 overflow-hidden">
183183
{[...Array(200)].map((_e, i) => (
184184
<div
@@ -193,13 +193,15 @@ function SerialSkeleton({ children, animate }: SkeletonProps) {
193193
))}
194194
</div>
195195

196+
{/* gradient uses the surface-default token so it works in both themes */}
196197
<div
197198
className="absolute bottom-0 h-full w-full"
198199
style={{
199-
background: 'linear-gradient(180deg, rgba(8, 15, 17, 0) 0%, #080F11 100%)',
200+
background:
201+
'linear-gradient(180deg, transparent 0%, var(--surface-default) 100%)',
200202
}}
201203
/>
202-
<div className="bg-raise! shadow-modal absolute top-1/2 left-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-lg p-12">
204+
<div className="bg-raise shadow-modal absolute top-1/2 left-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-lg p-12">
203205
{children}
204206
</div>
205207
</div>
@@ -221,7 +223,7 @@ const CannotConnect = ({ instance }: { instance: Instance }) => (
221223
<span>The instance is </span>
222224
<InstanceStateBadge className="ml-1.5" state={instance.runState} />
223225
</p>
224-
<p className="text-default mt-2 text-center text-balance">
226+
<p className="text-default text-sans-md mt-2 text-center text-balance">
225227
{isStarting(instance)
226228
? 'Waiting for the instance to start before connecting.'
227229
: 'You can only connect to the serial console on a running instance.'}

mock-api/serial-console.txt

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
Terminal theme test:
2+
red green yellow blue magenta cyan white
3+
bright-red bright-green bright-yellow bright-blue bright-magenta bright-cyan bright-white bright-black
4+
5+
Booting `Debian GNU/Linux'
6+
7+
Loading Linux 6.12.38+deb13-amd64 ...
8+
Loading initial ramdisk ...
9+
EFI stub: Loaded initrd from LINUX_EFI_INITRD_MEDIA_GUID device path
10+
[ 0.000000] Linux version 6.12.38+deb13-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.38-1 (2025-07-16)
11+
[ 0.000000] Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.38+deb13-amd64 root=PARTUUID=3fa1e012-eadc-4f00-b183-2619d1d2321b ro console=tty0 console=ttyS0,115200n8
12+
[ 0.000000] BIOS-provided physical RAM map:
13+
[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009ffff] usable
14+
[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000bea59fff] usable
15+
[ 0.000000] BIOS-e820: [mem 0x00000000bea5a000-0x00000000bed59fff] reserved
16+
[ 0.000000] BIOS-e820: [mem 0x0000000100000000-0x000000023fffffff] usable
17+
[ 0.000000] NX (Execute Disable) protection: active
18+
[ 0.000000] DMI: Oxide OxVM, BIOS v0.8 The Aftermath 30, 3185 YOLD
19+
[ 0.000000] tsc: Detected 1996.096 MHz processor
20+
[ 0.103404] percpu: Embedded 66 pages/cpu s233472 r8192 d28672 u1048576
21+
[ 0.116419] Built 1 zonelists, mobility grouping on. Total pages: 2064887
22+
[ 0.118081] Policy zone: Normal
23+
[ 0.118583] Kernel command line: BOOT_IMAGE=/boot/vmlinuz-6.12.38+deb13-amd64 root=PARTUUID=3fa1e012-eadc-4f00-b183-2619d1d2321b ro console=tty0 console=ttyS0,115200n8
24+
[ 0.151479] Memory: 8016340K/8259548K available (18432K kernel code, 4484K rwdata, 12340K rodata, 3892K init, 7620K bss, 243208K reserved, 0K cma-reserved)
25+
[ 0.295826] rcu: Hierarchical SRCU implementation.
26+
[ 0.350553] smpboot: CPU0: AMD EPYC-Rome Processor (family: 0x17, model: 0x31, stepping: 0x0)
27+
[ 0.620003] smp: Brought up 1 node, 2 CPUs
28+
[ 0.673460] pci 0000:00:10.0: [01de:0000] type 00 class 0x010802 conventional PCI endpoint
29+
[ 0.789199] MPTCP token hash table entries: 8192 (order: 5, 196608 bytes, linear)
30+
[ 0.841359] Key type asymmetric registered
31+
[ 1.113771] input: AT Translated Set 2 keyboard as /devices/platform/i8042/serio0/input/input0
32+
[ 1.565161] nvme nvme0: pci function 0000:00:10.0
33+
[ 1.849427] tsc: Refined TSC clocksource calibration: 1996.214 MHz
34+
[ 1.935044] EXT4-fs (nvme0n1p1): mounted filesystem dced5a54-4fb7-4dda-abd6-4ea8f50bfe92 ro with ordered data mode. Quota mode: none.
35+
[ 2.403361] nvme0n1: p1 p14 p15
36+
GROWROOT: CHANGED: partition=1 start=262144 old: size=6027264 end=6289407 new: size=20709343 end=20971486
37+
[ 2.850589] systemd[1]: Inserted module 'autofs4'
38+
[ 2.915923] systemd[1]: systemd 257.7-1 running in system mode (+PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 +PWQUALITY +ZSTD +BPF_FRAMEWORK +XKBCOMMON +SYSVINIT)
39+
[ 2.919095] systemd[1]: Detected virtualization bhyve.
40+
[ 2.920733] systemd[1]: Detected architecture x86-64.
41+
42+
Welcome to Debian GNU/Linux 13 (trixie)!
43+
44+
[ 2.941482] systemd[1]: Hostname set to <oxide-instance>.
45+
[ OK ] Created slice system-getty.slice - Slice /system/getty.
46+
[ OK ] Created slice system-modprobe.slice - Slice /system/modprobe.
47+
[ OK ] Created slice system-serial\x2dget…slice - Slice /system/serial-getty.
48+
[ OK ] Created slice user.slice - User and Session Slice.
49+
[ OK ] Started systemd-ask-password-conso…equests to Console Directory Watch.
50+
[ OK ] Reached target paths.target - Path Units.
51+
[ OK ] Reached target remote-fs.target - Remote File Systems.
52+
[ OK ] Reached target slices.target - Slice Units.
53+
[ OK ] Reached target swap.target - Swaps.
54+
[ OK ] Listening on systemd-journald.socket - Journal Sockets.
55+
[ OK ] Listening on systemd-networkd.socket - Network Service Netlink Socket.
56+
[ OK ] Listening on systemd-udevd-control.socket - udev Control Socket.
57+
[ OK ] Listening on systemd-udevd-kernel.socket - udev Kernel Socket.
58+
Mounting dev-hugepages.mount - Huge Pages File System...
59+
Mounting dev-mqueue.mount - POSIX Message Queue File System...
60+
Starting kmod-static-nodes.service…eate List of Static Device Nodes...
61+
Starting modprobe@configfs.service - Load Kernel Module configfs...
62+
Starting modprobe@drm.service - Load Kernel Module drm...
63+
Starting systemd-journald.service - Journal Service...
64+
Starting systemd-modules-load.service - Load Kernel Modules...
65+
[ OK ] Mounted dev-hugepages.mount - Huge Pages File System.
66+
[ OK ] Mounted dev-mqueue.mount - POSIX Message Queue File System.
67+
[ OK ] Mounted tmp.mount - Temporary Directory /tmp.
68+
[ OK ] Finished kmod-static-nodes.service…Create List of Static Device Nodes.
69+
[ OK ] Finished modprobe@configfs.service - Load Kernel Module configfs.
70+
[ OK ] Finished modprobe@drm.service - Load Kernel Module drm.
71+
[ OK ] Started systemd-journald.service - Journal Service.
72+
[ OK ] Finished modprobe@efi_pstore.service - Load Kernel Module efi_pstore.
73+
[ OK ] Finished modprobe@fuse.service - Load Kernel Module fuse.
74+
[ OK ] Finished systemd-fsck-root.service - File System Check on Root Device.
75+
[ OK ] Finished systemd-modules-load.service - Load Kernel Modules.
76+
[ 4.288341] EXT4-fs (nvme0n1p1): re-mounted dced5a54-4fb7-4dda-abd6-4ea8f50bfe92 r/w.
77+
[ OK ] Finished systemd-remount-fs.servic…mount Root and Kernel File Systems.
78+
[ OK ] Finished systemd-sysctl.service - Apply Kernel Variables.
79+
Starting cloud-init-main.service - Cloud-init: Single Process...
80+
Starting systemd-growfs-root.service - Grow Root File System...
81+
Starting systemd-random-seed.service - Load/Save OS Random Seed...
82+
[ 4.602163] EXT4-fs (nvme0n1p1): resized filesystem to 2588667
83+
[ OK ] Finished systemd-random-seed.service - Load/Save OS Random Seed.
84+
[ OK ] Finished systemd-growfs-root.service - Grow Root File System.
85+
[ OK ] Finished systemd-sysusers.service - Create System Users.
86+
Starting systemd-resolved.service - Network Name Resolution...
87+
Starting systemd-timesyncd.service - Network Time Synchronization...
88+
[ OK ] Started systemd-timesyncd.service - Network Time Synchronization.
89+
[ OK ] Reached target time-set.target - System Time Set.
90+
[ OK ] Started systemd-udevd.service - Ru…anager for Device Events and Files.
91+
[ OK ] Started systemd-resolved.service - Network Name Resolution.
92+
[ OK ] Found device dev-ttyS0.device - /dev/ttyS0.
93+
[ OK ] Reached target local-fs.target - Local File Systems.
94+
Starting apparmor.service - Load AppArmor profiles...
95+
Starting systemd-tmpfiles-setup.se…ate System Files and Directories...
96+
[ OK ] Finished systemd-tmpfiles-setup.se…reate System Files and Directories.
97+
[ OK ] Started cloud-init-main.service - Cloud-init: Single Process.
98+
Starting cloud-init-local.service …-init: Local Stage (pre-network)...
99+
[ OK ] Finished cloud-init-local.service …ud-init: Local Stage (pre-network).
100+
[ OK ] Reached target network-pre.target - Preparation for Network.
101+
Starting systemd-networkd.service - Network Configuration...
102+
[ OK ] Started systemd-networkd.service - Network Configuration.
103+
[ OK ] Reached target network.target - Network.
104+
[ OK ] Finished systemd-networkd-wait-onl… Wait for Network to be Configured.
105+
Starting cloud-init-network.service - Cloud-init: Network Stage...
106+
[ OK ] Finished cloud-init-network.service - Cloud-init: Network Stage.
107+
[ OK ] Reached target network-online.target - Network is Online.
108+
[ OK ] Reached target sysinit.target - System Initialization.
109+
[ OK ] Started apt-daily.timer - Daily apt download activities.
110+
[ OK ] Started dpkg-db-backup.timer - Daily dpkg database backup timer.
111+
[ OK ] Started fstrim.timer - Discard unused filesystem blocks once a week.
112+
[ OK ] Started systemd-tmpfiles-clean.tim…y Cleanup of Temporary Directories.
113+
[ OK ] Reached target timers.target - Timer Units.
114+
[ OK ] Listening on dbus.socket - D-Bus System Message Bus Socket.
115+
[ OK ] Reached target sockets.target - Socket Units.
116+
[ OK ] Reached target basic.target - Basic System.
117+
Starting cloud-config.service - Cloud-init: Config Stage...
118+
Starting dbus.service - D-Bus System Message Bus...
119+
Starting ssh.service - OpenBSD Secure Shell server...
120+
Starting systemd-logind.service - User Login Management...
121+
Starting systemd-user-sessions.service - Permit User Sessions...
122+
[ OK ] Started dbus.service - D-Bus System Message Bus.
123+
[ OK ] Finished systemd-user-sessions.service - Permit User Sessions.
124+
[ OK ] Started getty@tty1.service - Getty on tty1.
125+
[ OK ] Started serial-getty@ttyS0.service - Serial Getty on ttyS0.
126+
[ OK ] Reached target getty.target - Login Prompts.
127+
[ OK ] Started ssh.service - OpenBSD Secure Shell server.
128+
[ OK ] Started systemd-logind.service - User Login Management.
129+
[ OK ] Finished cloud-config.service - Cloud-init: Config Stage.
130+
[ OK ] Finished grub-common.service - Record successful boot for GRUB.
131+
[ OK ] Started unattended-upgrades.service - Unattended Upgrades Shutdown.
132+
[ OK ] Reached target multi-user.target - Multi-User System.
133+
[ OK ] Reached target graphical.target - Graphical Interface.
134+
Starting cloud-final.service - Cloud-init: Final Stage...
135+
[ OK ] Finished cloud-final.service - Cloud-init: Final Stage.
136+
[ OK ] Reached target cloud-init.target - Cloud-init target.
137+
[ OK ] Started polkit.service - Authorization Manager.
138+
139+
Debian GNU/Linux 13 oxide-instance ttyS0
140+
141+
oxide-instance login:

test/e2e/instance-serial.e2e.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,15 @@ test('serial console for existing instance', async ({ page }) => {
4949

5050
async function testSerialConsole(page: Page) {
5151
const xterm = page.getByRole('application')
52+
const input = page.getByRole('textbox', { name: 'Terminal input' })
5253

53-
// MSW mocks a message. use first() because there are multiple copies on screen
54-
await expect(xterm.getByText('Wake up Neo...').first()).toBeVisible()
54+
// Wait for the boot log to finish so typed input does not interleave with it.
55+
await expect(xterm).toContainText('oxide-instance login:', { timeout: 15_000 })
5556

56-
// we need to do this for our keypresses to land
57-
await page.locator('.xterm-helper-textarea').focus()
57+
await input.focus()
58+
await expect(input).toBeFocused()
5859

59-
await xterm.pressSequentially('abc')
60-
await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible()
61-
await xterm.press('Enter')
62-
await xterm.pressSequentially('def')
63-
await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible()
64-
await expect(xterm.getByText('def').first()).toBeVisible()
60+
await input.press('Enter')
61+
await input.pressSequentially('def')
62+
await expect(xterm).toContainText('def')
6563
}

test/e2e/theme.e2e.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@
77
*/
88
import { expect, test } from './utils'
99

10+
test('Serial console terminal updates colors on theme change', async ({ page }) => {
11+
await page.goto('/projects/mock-project/instances/db1/serial-console')
12+
13+
const xterm = page.getByRole('application')
14+
await expect(xterm).toContainText('oxide-instance login:', { timeout: 15_000 })
15+
16+
// xterm.js sets background-color inline on the .xterm-viewport element
17+
const viewport = page.locator('.xterm-viewport')
18+
const getBg = () => viewport.evaluate((el) => getComputedStyle(el).backgroundColor)
19+
20+
const darkBg = await getBg()
21+
22+
// switch to light via the user menu
23+
await page.getByRole('button', { name: 'User menu' }).click()
24+
await page.getByRole('menuitem', { name: 'Theme' }).click()
25+
await page.getByRole('menuitemradio', { name: 'Light' }).click()
26+
27+
const lightBg = await getBg()
28+
expect(lightBg).not.toEqual(darkBg)
29+
30+
// switch back to dark (menu is still open)
31+
await page.getByRole('menuitemradio', { name: 'Dark' }).click()
32+
33+
expect(await getBg()).toEqual(darkBg)
34+
})
35+
1036
test('Theme picker changes data-theme on <html>', async ({ page }) => {
1137
// default is light in Playwright, but don't rely on that
1238
await page.emulateMedia({ colorScheme: 'light' })

0 commit comments

Comments
 (0)