Files
windmill/frontend/src/lib/components/LogViewer.svelte
T
Ruben Fiszel e80fee86b3 feat: record and replay raw app sessions step by step (#10318)
* feat: record and replay raw app sessions step by step

* fix: address review findings on raw app session recorder

* fix: stamp replay target before pruning the snapshot clone

* fix: redact step metadata, lock down replayed frames, fix control pre-state

* feat: add a checkpoint timeline to the app recording player

* fix: parser-based replay CSP, fold label clicks, drop stale frame indices

* fix: scrub redacted attributes, keep scroll, neutralize replay navigation

* fix: bound replay payloads, strip namespaced nav links, keep control pre-frames

* fix: strip SMIL navigation, redact metadata sources, capture pre-edit on beforeinput

* fix: redact template content, drop shadow templates, make replays inert

* test: pin snapshot redaction and replay sanitization with DOM tests

* fix: allow-list no-record attributes and cover a marked document root

* fix: classify input types positively so pickers get pre-change frames

* fix: one step per control interaction and bound step metadata

* fix: keep button inputs recordable and coalesce only continuous controls

* fix: no frames for coalesced repeats and drop inline styles when redacting

* fix: fold only the label's own click and keep marked stylesheets out

* fix: keep label-forwarded and radio-group pre-frames, fold submitter clicks

* fix: bound key pre-frames to their gesture and clear ancestor pointer frames

* fix: age-bound pre-frames and treat a radio group as one target

* fix: consume pre-frames per interaction and coalesce on the browser repeat flag

* fix: spend only the pre-frame a step actually used

* fix: settle a step from its successor's pre-state and drop stale pointer frames

* fix: bound remote frame payloads and snapshot stylesheets as rendered

* fix: let a control change spend its own frame and dedupe Enter activations

* fix: record Escape on controls and drop disabled stylesheets

* feat: collapse the replay step list by default behind a toggle

* fix: neutralize disabled sheets in place and fold Enter submissions

* fix: withhold redacted control state, fold key repeats, validate remote metadata

* fix: drop noscript markup and fold implicit form submissions

* fix: mask a select whose chosen option is redacted

* fix: mask redacted select choices before the clone diverges

* fix: run clone-paired passes before removals and fold only Enter submissions

* feat: record a raw app demo from the publish flow instead of the viewer

* fix: wait for in-flight runnable jobs before settling a step

* feat: record from the editor menu and replay publicly at /replay

* feat: export the app recording player and its loader for the hub

* feat: publish from folders only, drop iframe sharing

* fix: observe runnable responses where they land and mount the hub recording route

* fix: respect the app's sandbox opt-in when recording a session

* fix: let stop wait for the runnable the last step is still running

* fix: filter redacted class/id to styled tokens and gate publish on admin

* fix: drop marked sheets from the token vocabulary and bound the replay error

* test: pin the remote app-recording validator

* fix: carry in-flight runnables across a reload and fold held keys into one step

* fix: bind runnable responses off the request and honor base in the replay handoff

* fix: close the settling step when a new fill starts and always re-read stylesheets

* fix: empty the no-record marker so it carries nothing of its own

* fix: decode css escapes so utility classes survive redaction

* fix: read keyDriven from the frame the change starts from

* docs: condense recorder comments to the invariant each protects

* fix: rewrite only real url() tokens and accept leading css escapes

* feat: play flow, script and pipeline recordings on the public /replay page (#10327)

* feat: play flow, script and pipeline recordings on the public /replay page

* fix: render a recorded approval result inert while replaying

* fix: bound an asset sample's cell product and validate recording headers

* fix: make a replayed approval step inert and bound nested recording structures

* fix: stop recorded markup from fetching and bound flow/script render trees

* fix: gate recorded markdown at its renderer and close remaining render-budget gaps

* fix: replace per-key render caps with one structural budget per recorded value

* fix: bound component fan-out and text alongside the structural budget

* fix: make component fan-out cumulative and cap the parsed data-test checklist

* fix: bound the whole recording, graph contents, metadata strings and timer bursts

* fix: keep the published loader path, charge object keys, refuse huge serialized fan-out

* fix: cap flat maps a renderer turns into rows (args, schema properties)

* fix: refuse structure hidden past the depth ceiling and bound errored samples

* fix: count array-shaped argument collections against the row cap

* feat: paint canvas pixels into the snapshot

* fix: budget canvas encoding per snapshot and bound the unknown-kind error

* fix: cap flow graph overlay fan-out and condense budget comments

* docs: teach the raw-app prompt about data-wm-no-record
2026-07-26 11:51:59 +02:00

673 lines
19 KiB
Svelte

<script lang="ts" module>
import { untrack } from 'svelte'
const s3LogPrefixes = [
'[windmill] Previous logs have been saved to object storage at logs/',
'[windmill] Previous logs have been saved to disk at logs/',
'[windmill] No object storage set in instance settings. Previous logs have been saved to disk at logs/'
]
// Only search within first N characters to avoid expensive full-content scans
const S3_LOG_SEARCH_LIMIT = 2000
</script>
<script lang="ts">
import { ClipboardCopy, Download, Expand, Loader2, Timer, Cpu } from 'lucide-svelte'
import { Button, Drawer, DrawerContent } from './common'
import { copyToClipboard } from '$lib/utils'
import { base } from '$lib/base'
import { withExternalDomain } from '$lib/externalDomain'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { workspaceStore } from '$lib/stores'
import { AnsiUp } from 'ansi_up'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
import { JobService } from '$lib/gen'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { isReplaying } from './recording/offlineReplay.svelte'
import Tooltip from './Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import QueuePosition from './QueuePosition.svelte'
interface Props {
content: string | undefined
isLoading: boolean
duration?: number | undefined
mem?: number | undefined
wrapperClass?: string
jobId?: string | undefined
tag: string | undefined
small?: boolean
drawerOpen?: boolean
noMaxH?: boolean
noAutoScroll?: boolean
download?: boolean
customEmptyMessage?: string
tagLabel?: string
noPadding?: boolean
navigationId?: string
/** Called once after we resolve a WM_LOGS_SKIPPED sentinel by fetching the
* full job. Use this to write the real logs back into the source of truth
* (e.g. flowStateStore) so subsequent mounts don't refetch. */
onLogsResolved?: (logs: string) => void
}
let {
content,
isLoading,
duration = undefined,
mem = undefined,
wrapperClass = '',
jobId = undefined,
tag,
small = false,
drawerOpen = $bindable(false),
noMaxH = false,
noAutoScroll = false,
download = true,
customEmptyMessage = 'No logs are available yet',
tagLabel = undefined,
noPadding = false,
navigationId = undefined,
onLogsResolved
}: Props = $props()
// @ts-ignore
const ansi_up = $state(new AnsiUp())
ansi_up.use_classes = true
let scroll = $state(true)
let preEl: HTMLElement | null = $state(null)
// let downloadStartUrl: string | undefined = undefined
let LOG_INC = 10000
let LOG_LIMIT = $state(LOG_INC)
let lastJobId = $state(untrack(() => jobId))
let loadedFromObjectStore = $state('')
// A replayed job only exists inside the recording: its logs are whatever was
// captured, so every path that would go ask the backend for more has to stay
// shut. `jobId` still comes through for the reset-on-change bookkeeping.
let replaying = $derived(isReplaying())
// `content` is the WM_LOGS_SKIPPED sentinel when the job was fetched with
// no_logs=true. If an older in-memory value accidentally has real bytes
// concatenated after the sentinel, treat that as skipped too and refetch.
let isLogsSkipped = $derived((content ?? '').startsWith(WM_LOGS_SKIPPED))
let resolvedSkippedLogs: string | undefined = $state(undefined)
let fetchedSkippedJobId: string | undefined = $state(undefined)
let effectiveContent = $derived(isLogsSkipped ? resolvedSkippedLogs : content)
let resolvingSkippedLogs = $derived(
isLogsSkipped && !!jobId && !replaying && resolvedSkippedLogs === undefined
)
$effect(() => {
if (!isLogsSkipped || !jobId || replaying || fetchedSkippedJobId === jobId) {
return
}
const id = jobId
fetchedSkippedJobId = id
untrack(() => {
JobService.getJob({ workspace: $workspaceStore ?? '', id })
.then((j) => {
if (fetchedSkippedJobId === id) {
const logs = (j as { logs?: string })['logs'] ?? ''
resolvedSkippedLogs = logs
onLogsResolved?.(logs)
}
})
.catch((e) => console.error('Failed to resolve skipped logs', e))
})
})
function findPrefixInfo(
truncateContent: string
): { prefixIndex: number; position: number } | undefined {
// Quick check - [windmill] is rare, so bail early in the common case
// This is much faster than doing 3 long string indexOf searches
const windmillPos = truncateContent.indexOf('[windmill]')
if (windmillPos === -1 || windmillPos >= S3_LOG_SEARCH_LIMIT) {
return undefined
}
// Found [windmill] marker, now determine which specific prefix matches
for (let i = 0; i < s3LogPrefixes.length; i++) {
const prefix = s3LogPrefixes[i]
if (
truncateContent.length >= windmillPos + prefix.length &&
truncateContent.startsWith(prefix, windmillPos)
) {
return { prefixIndex: i, position: windmillPos }
}
}
return undefined
}
function findStartUrl(
truncateContent: string,
prefixInfo: { prefixIndex: number; position: number } | undefined
) {
if (!prefixInfo) {
return undefined
}
const { prefixIndex, position } = prefixInfo
const prefix = s3LogPrefixes[prefixIndex]
const startOfPath = position + prefix.length
const endOfLine = truncateContent.indexOf('\n', startOfPath)
return truncateContent.substring(startOfPath, endOfLine === -1 ? undefined : endOfLine)
}
function splitAtWindmillLine(
content: string,
prefixInfo: { prefixIndex: number; position: number }
): { before: string; after: string } {
const { prefixIndex, position } = prefixInfo
const prefix = s3LogPrefixes[prefixIndex]
// Find line boundaries
const lineStart = position > 0 && content[position - 1] === '\n' ? position - 1 : position
const pathStart = position + prefix.length
const lineEnd = content.indexOf('\n', pathStart)
if (lineEnd === -1) {
// [windmill] line is at the end
return { before: content.substring(0, lineStart), after: '' }
}
return {
before: content.substring(0, lineStart),
after: content.substring(lineEnd)
}
}
function tooltipText(prefixInfo: { prefixIndex: number; position: number } | undefined) {
if (prefixInfo == undefined) {
return 'No path/file detected to download from'
} else if (prefixInfo.prefixIndex == 0) {
return 'Download the previous logs from the instance configured object store'
} else if (prefixInfo.prefixIndex == 1) {
return 'Attempt to download the logs from disk. Assume there is a shared disk between the workers and the server at /tmp/windmill/logs. Upgrade to EE to use an object store such as S3 instead of a shared volume.'
} else if (prefixInfo.prefixIndex == 2) {
return 'Attempt to download the logs from disk. Assume there is a shared disk between the workers and the server at /tmp/windmill/logs. Since you are on EE, you can alternatively use an object store such as S3 configured in the instance settings instead of a shared volume..'
}
}
function truncateContent(
jobContent: string | undefined,
loadedFromObjectStore: string,
limit: number
) {
let content = loadedFromObjectStore + (jobContent ?? '')
if (content.length > limit) {
return content.substring(content.length - limit)
}
return content
}
export function scrollToBottom() {
scroll && setTimeout(() => preEl?.scroll({ top: preEl?.scrollHeight, behavior: 'smooth' }), 100)
}
let logViewer: Drawer | undefined = $state()
async function getStoreLogs() {
if (downloadStartUrl) {
scroll = false
let res = (await JobService.getLogFileFromStore({
workspace: $workspaceStore ?? '',
path: downloadStartUrl
})) as string
LOG_LIMIT += Math.min(LOG_INC, res.length)
loadedFromObjectStore = res + loadedFromObjectStore
let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
LOG_LIMIT -= newC.indexOf('\n') + 1
} else {
console.error('No file detected to download from')
}
}
function showMoreTruncate(len: number) {
scroll = false
LOG_LIMIT += LOG_INC
let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
let newlineIndex = newC.indexOf('\n') + 1
if (newlineIndex < LOG_INC / 2) {
LOG_LIMIT -= newlineIndex
}
}
$effect.pre(() => {
if (jobId !== lastJobId) {
lastJobId = jobId
loadedFromObjectStore = ''
LOG_LIMIT = LOG_INC
scroll = true
resolvedSkippedLogs = undefined
fetchedSkippedJobId = undefined
}
})
let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`))
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
let truncatedContent = $derived(
truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
)
// The "Show more..." button behind this reads the rest of the logs out of the
// instance's object store, which a replay has no access to; leaving `prefixInfo`
// unset renders the `[windmill]` line as the plain log line it is.
let prefixInfo = $derived(replaying ? undefined : findPrefixInfo(truncatedContent))
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
$effect.pre(() => {
truncatedContent && scrollToBottom()
})
// When [windmill] line is NOT at start, split into before/after to render button inline
let splitHtml = $derived.by(() => {
if (prefixInfo == undefined || prefixInfo.position === 0) {
return undefined
}
const { before, after } = splitAtWindmillLine(truncatedContent, prefixInfo)
return {
before: ansi_up.ansi_to_html(before),
after: ansi_up.ansi_to_html(after)
}
})
// Only compute html when splitHtml won't be used (avoids wasteful ansi_to_html call)
let html = $derived.by(() => {
if (splitHtml) {
// splitHtml is active - skip expensive computation
return ''
}
if (prefixInfo == undefined) {
// No [windmill] line - return full content
return ansi_up.ansi_to_html(truncatedContent)
}
// [windmill] at start - strip the line and return the rest
const { after } = splitAtWindmillLine(truncatedContent, prefixInfo)
return ansi_up.ansi_to_html(after)
})
</script>
<Drawer bind:this={logViewer} bind:open={drawerOpen} size="900px">
<DrawerContent title="Expanded Logs" on:close={logViewer.closeDrawer}>
{#snippet actions()}
{#if jobId && download}
{#if shouldDownloadViaClient()}
<Button
on:click={() => downloadViaClient(logsApiPath, downloadName)}
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{:else}
<Button
href={downloadHref}
download={downloadName}
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{/if}
{/if}
<Button
on:click={() => copyToClipboard(effectiveContent)}
color="light"
size="xs"
startIcon={{
icon: ClipboardCopy
}}
>
Copy to clipboard
</Button>
{/snippet}
<div>
<pre
class="bg-surface-secondary text-primary text-xs w-full p-2 whitespace-pre-wrap border rounded-md"
>{#if resolvingSkippedLogs}<Loader2
class="animate-spin"
size={14}
/>{:else if effectiveContent}{@const len =
(effectiveContent?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if splitHtml}{@html splitHtml.before}<button
onclick={getStoreLogs}
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
>{@html splitHtml.after}{:else if downloadStartUrl}<button onclick={getStoreLogs}
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
><br
/>{@html html}{:else if len > LOG_LIMIT}(truncated to the last {LOG_LIMIT} characters)...<br
/><button onclick={() => showMoreTruncate(len)}>Show more..</button><br
/>{@html html}{:else}{@html html}{/if}{:else if isLoading}Waiting for job to start...{:else}No logs are available yet{/if}</pre
>
</div>
</DrawerContent>
</Drawer>
<div class="w-full h-full {wrapperClass}">
<div class="w-full h-full relative">
<div
class="w-full h-full bg-surface-secondary flex flex-col {noMaxH ? '' : 'max-h-screen'}"
data-nav-id={navigationId}
>
<div
class="flex gap-2 ml-2 {small ? 'py-1' : 'py-2'} border-b overflow-x-auto overflow-y-hidden"
>
{#if isLoading}
<div class="flex gap-2 items-center">
<Loader2 class="animate-spin" />
{#if tag}
<div class="flex flex-row items-center gap-1">
<div class="text-secondary text-2xs">{tagLabel ?? 'tag'}: {tag}</div>
<NoWorkerWithTagWarning {tagLabel} {tag} />
</div>
{/if}
{#if jobId && !replaying}
<QueuePosition {jobId} />
{/if}
</div>
{:else if duration}
<span
class={twMerge(
'flex items-center gap-1 text-secondary dark:text-gray-400',
small ? '!text-2xs' : '!text-xs'
)}
title="Duration"
>
<Timer size={small ? 10 : 12} />
{duration}ms
</span>
{/if}
{#if mem}
<span
class={twMerge(
'flex items-center gap-1 text-secondary dark:text-gray-400',
small ? '!text-2xs' : '!text-xs'
)}
title="Memory peak"
>
<Cpu size={small ? 10 : 12} />
{(mem / 1024).toPrecision(4)}MB
</span>
{/if}
<div class="flex gap-2 justify-end flex-1">
{#if jobId && download}
<div class="flex items-center">
{#if shouldDownloadViaClient()}
<button
class="text-primary pb-0.5"
onclick={() => downloadViaClient(logsApiPath, downloadName)}
><Download size="14" />
</button>
{:else}
<a
class="text-primary pb-0.5"
target="_blank"
href={downloadHref}
download={downloadName}
><Download size="14" />
</a>
{/if}
</div>
{/if}
<button onclick={logViewer.openDrawer}><Expand size="12" /></button>
{#if !noAutoScroll}
<label
class="pr-2 text-2xs flex gap-2 font-normal text-primary items-center whitespace-nowrap"
>
auto-scroll
<input class="windmillapp" type="checkbox" bind:checked={scroll} />
</label>
{/if}
</div>
</div>
<pre
bind:this={preEl}
class={twMerge(
'whitespace-pre break-words w-full flex-1 overflow-auto',
small ? '!text-2xs' : '!text-xs',
noPadding ? '' : 'p-2'
)}
>{#if resolvingSkippedLogs}<span class="flex p-2"
><Loader2 class="animate-spin" size={14} /></span
>{:else if effectiveContent}{@const len =
(effectiveContent?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if splitHtml}<span>{@html splitHtml.before}</span
><button onclick={getStoreLogs}
>Show more... &nbsp;<Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
><span>{@html splitHtml.after}</span>{:else if downloadStartUrl}<button
onclick={getStoreLogs}
>Show more... &nbsp;<Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
><br /><span>{@html html}</span>{:else if len > LOG_LIMIT}<button
onclick={() => showMoreTruncate(len)}>Show more..</button
>&nbsp;({LOG_LIMIT}/{len} chars)<br /><span>{@html html}</span>{:else}<span
>{@html html}</span
>{/if}{:else if !isLoading}<span>{customEmptyMessage}</span>{/if}</pre
>
</div>
</div>
</div>
<style global>
/* Foreground colors */
.ansi-black-fg {
color: rgb(0, 0, 0);
}
.ansi-red-fg {
color: rgb(187, 0, 0);
}
.ansi-green-fg {
color: rgb(0, 187, 0);
}
.ansi-yellow-fg {
color: rgb(187, 187, 0);
}
.ansi-blue-fg {
color: rgb(0, 0, 187);
}
.ansi-magenta-fg {
color: rgb(187, 0, 187);
}
.ansi-cyan-fg {
color: rgb(0, 187, 187);
}
.ansi-white-fg {
color: rgb(255, 255, 255);
}
.ansi-bright-black-fg {
color: rgb(85, 85, 85);
}
.ansi-bright-red-fg {
color: rgb(255, 85, 85);
}
.ansi-bright-green-fg {
color: rgb(0, 255, 0);
}
.ansi-bright-yellow-fg {
color: rgb(255, 255, 85);
}
.ansi-bright-blue-fg {
color: rgb(85, 85, 255);
}
.ansi-bright-magenta-fg {
color: rgb(255, 85, 255);
}
.ansi-bright-cyan-fg {
color: rgb(85, 255, 255);
}
.ansi-bright-white-fg {
color: rgb(255, 255, 255);
}
/* Background colors */
.ansi-black-bg {
background-color: rgb(0, 0, 0);
}
.ansi-red-bg {
background-color: rgb(187, 0, 0);
}
.ansi-green-bg {
background-color: rgb(0, 187, 0);
}
.ansi-yellow-bg {
background-color: rgb(187, 187, 0);
}
.ansi-blue-bg {
background-color: rgb(0, 0, 187);
}
.ansi-magenta-bg {
background-color: rgb(187, 0, 187);
}
.ansi-cyan-bg {
background-color: rgb(0, 187, 187);
}
.ansi-white-bg {
background-color: rgb(255, 255, 255);
}
.ansi-bright-black-bg {
background-color: rgb(85, 85, 85);
}
.ansi-bright-red-bg {
background-color: rgb(255, 85, 85);
}
.ansi-bright-green-bg {
background-color: rgb(0, 255, 0);
}
.ansi-bright-yellow-bg {
background-color: rgb(255, 255, 85);
}
.ansi-bright-blue-bg {
background-color: rgb(85, 85, 255);
}
.ansi-bright-magenta-bg {
background-color: rgb(255, 85, 255);
}
.ansi-bright-cyan-bg {
background-color: rgb(85, 255, 255);
}
.ansi-bright-white-bg {
background-color: rgb(255, 255, 255);
}
/* Foreground colors for dark mode (Nord theme) */
.dark .ansi-black-fg {
color: rgb(46, 52, 64);
}
.dark .ansi-red-fg {
color: rgb(191, 97, 106);
}
.dark .ansi-green-fg {
color: rgb(163, 190, 140);
}
.dark .ansi-yellow-fg {
color: rgb(235, 203, 139);
}
.dark .ansi-blue-fg {
color: rgb(94, 129, 172);
}
.dark .ansi-magenta-fg {
color: rgb(180, 142, 173);
}
.dark .ansi-cyan-fg {
color: rgb(136, 192, 208);
}
.dark .ansi-white-fg {
color: rgb(216, 222, 233);
}
.dark .ansi-bright-black-fg {
color: rgb(67, 76, 94);
}
.dark .ansi-bright-red-fg {
color: rgb(191, 97, 106);
}
.dark .ansi-bright-green-fg {
color: rgb(163, 190, 140);
}
.dark .ansi-bright-yellow-fg {
color: rgb(235, 203, 139);
}
.dark .ansi-bright-blue-fg {
color: rgb(94, 129, 172);
}
.dark .ansi-bright-magenta-fg {
color: rgb(180, 142, 173);
}
.dark .ansi-bright-cyan-fg {
color: rgb(136, 192, 208);
}
.dark .ansi-bright-white-fg {
color: rgb(229, 233, 240);
}
/* Background colors for dark mode (Nord theme) */
.dark .ansi-black-bg {
background-color: rgb(46, 52, 64);
}
.dark .ansi-red-bg {
background-color: rgb(191, 97, 106);
}
.dark .ansi-green-bg {
background-color: rgb(163, 190, 140);
}
.dark .ansi-yellow-bg {
background-color: rgb(235, 203, 139);
}
.dark .ansi-blue-bg {
background-color: rgb(94, 129, 172);
}
.dark .ansi-magenta-bg {
background-color: rgb(180, 142, 173);
}
.dark .ansi-cyan-bg {
background-color: rgb(136, 192, 208);
}
.dark .ansi-white-bg {
background-color: rgb(216, 222, 233);
}
.dark .ansi-bright-black-bg {
background-color: rgb(67, 76, 94);
}
.dark .ansi-bright-red-bg {
background-color: rgb(191, 97, 106);
}
.dark .ansi-bright-green-bg {
background-color: rgb(163, 190, 140);
}
.dark .ansi-bright-yellow-bg {
background-color: rgb(235, 203, 139);
}
.dark .ansi-bright-blue-bg {
background-color: rgb(94, 129, 172);
}
.dark .ansi-bright-magenta-bg {
background-color: rgb(180, 142, 173);
}
.dark .ansi-bright-cyan-bg {
background-color: rgb(136, 192, 208);
}
.dark .ansi-bright-white-bg {
background-color: rgb(229, 233, 240);
}
[data-nav-id]:focus-visible {
outline: none;
outline-offset: 0;
}
</style>