From 61c9e19047eaec6b9c784a4fc462079ca5f5bcf9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 10:52:09 -0700 Subject: [PATCH 1/8] fix(mothership): stop chat perf decay from permanently-animated streamed messages --- .../components/chat-content/chat-content.tsx | 101 ++++++++++++++---- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index afeac25cc3b..9cd2d787f4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react' +import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef, useState } from 'react' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' // prismjs core must load before its language components — they register on the @@ -53,17 +53,42 @@ const PROSE_CLASSES = cn( /** * Soft fade for newly revealed text. Paired with {@link useSmoothText}, which - * paces the reveal: `sep: 'char'` fades each character as the pacer exposes it - * (so a growing trailing word never re-animates), and `stagger: 0` keeps the - * cadence driven by the pacer rather than an overlapping per-token delay ramp. + * paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather + * than an overlapping per-token delay ramp — every span revealed in one tick + * fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while + * creating ~5x fewer spans. That span count is the dominant mid-stream cost: + * the animate plugin rebuilds a span per token for the WHOLE trailing block on + * every reveal tick, so per-char wrapping of a long paragraph meant thousands + * of hast nodes + React elements reconciled ~40x/sec. Streamdown's + * prev-content tracking keeps a word that grows across two ticks from + * re-fading (its continuation renders unfaded), and the pacer's word-boundary + * snapping makes such splits rare to begin with. */ const STREAM_ANIMATION = { animation: 'fadeIn', duration: 220, stagger: 0, - sep: 'char', + sep: 'word', } as const +/** + * How long after the reveal fully settles before the animated tree is dropped. + * Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters + * finish fading at full opacity before their spans are swapped for plain text. + */ +const ANIMATION_DRAIN_MS = 300 + +/** + * Once a segment has revealed this many characters, new text stops fading in; + * the word-paced reveal itself is unchanged. Fade cost scales with segment + * length — every reveal tick rebuilds a span per word for the WHOLE trailing + * markdown block — so on an unbroken wall of text it eventually swamps the + * frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread + * time in long tasks) while the fade itself is imperceptible detail that deep + * into a reply. + */ +const FADE_MAX_REVEALED_CHARS = 6000 + function startsInlineWord(value: string): boolean { return /^[A-Za-z0-9_(]/.test(value) } @@ -306,19 +331,51 @@ function ChatContentInner({ }, [isRevealing]) /** - * One-way latch: once a message has streamed in this mount, keep rendering it - * through Streamdown's streaming/animation pipeline for the rest of its life. - * Drives `mode`, `animated`, AND `isAnimating` together — all three must stay - * constant across the completion boundary. Streamdown removes the per-word - * `` wrappers (and re-parses the whole message) the instant `isAnimating` - * goes false, so wiring `isAnimating` to `isRevealing` (which flips at - * completion) reintroduces the streaming→static flash this latch exists to - * prevent. Content is stable once revealed, so a permanently-true - * `isAnimating` never re-fades anything. + * Streaming-tree lifecycle. While a message streams (and until its reveal + * drains), it renders through Streamdown's streaming/animated pipeline, whose + * animate plugin wraps every character in its own `` — + * thousands of DOM nodes per streamed message. Holding that tree forever made + * long sessions progressively laggier until a refresh (which renders the same + * transcript static). `animationDrained` flips one-way + * {@link ANIMATION_DRAIN_MS} after the reveal settles and swaps to the static + * pipeline; the drain window lets the last 220ms fades finish so the swap + * trades identical pixels, unlike flipping at `isRevealing`'s edge, which cut + * running fades short (the old completion flash). + * + * The swap must REMOUNT Streamdown (via `key`), not just flip its props: + * Streamdown's default element components are memoized on className + source + * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content + * without the animate plugin bails at every unoverridden element (`p`, + * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in + * place. Remounting also converges the settled DOM byte-for-byte with what a + * reloaded transcript renders. + * + * The drain is deliberately one-way: a stream that resumes afterwards + * (reconnect/continuation) reveals paced but unfaded, because re-arming + * mounts a fresh animate plugin with no prev-content tracking, which would + * re-fade the entire already-visible message. */ const streamedThisSession = useRef(false) if (isStreaming) streamedThisSession.current = true - const keepStreamingTree = isRevealing || streamedThisSession.current + + const [animationDrained, setAnimationDrained] = useState(false) + useEffect(() => { + if (isRevealing || animationDrained || !streamedThisSession.current) return + const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS) + return () => clearTimeout(timeout) + }, [isRevealing, animationDrained]) + + const streamingTree = (isRevealing || streamedThisSession.current) && !animationDrained + + /** + * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a + * sanitize-induced content shrink back across the boundary cannot re-arm + * `animated` — a fresh animate plugin has no prev-content tracking and would + * re-fade the entire visible segment. + */ + const fadeCutoffRef = useRef(false) + if (streamedContent.length > FADE_MAX_REVEALED_CHARS) fadeCutoffRef.current = true + const fadeActive = streamingTree && !fadeCutoffRef.current useEffect(() => { const handler = (e: Event) => { @@ -392,9 +449,10 @@ function ChatContentInner({ className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')} > {group.markdown} @@ -418,9 +476,10 @@ function ChatContentInner({ return (
:first-child]:mt-0 [&>:last-child]:mb-0')}> {streamedContent} From dfdb3625917f5147e43d6b136a8f3fbafd81ac05 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 11:03:02 -0700 Subject: [PATCH 2/8] fix(mothership): reset animation latches when a reused ChatContent gets replaced content --- .../components/chat-content/chat-content.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 9cd2d787f4e..9b6a9799683 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -356,9 +356,34 @@ function ChatContentInner({ * re-fade the entire already-visible message. */ const streamedThisSession = useRef(false) + const [animationDrained, setAnimationDrained] = useState(false) + const fadeCutoffRef = useRef(false) + + /** + * The per-session latches above outlive the content when React reuses this + * instance for a different logical message — parent rows key by turn + * position and text segments by run ordinal (both deliberately stable across + * the live→persisted id swap), so an ordinal shift or regeneration can hand + * a settled instance brand-new content whose stale `animationDrained` would + * silently render the new stream static. Reset the latches when the content + * is REPLACED (not an append of the previous string) after the instance has + * settled. A resumed turn only ever appends, so this never undoes the + * one-way drain; mid-stream sanitize rewrites are excluded by the + * `animationDrained` gate (the drain only fires after settle). + */ + const prevDisplayContentRef = useRef(displayContent) + if (prevDisplayContentRef.current !== displayContent) { + const replaced = !displayContent.startsWith(prevDisplayContentRef.current) + prevDisplayContentRef.current = displayContent + if (replaced && animationDrained) { + streamedThisSession.current = false + fadeCutoffRef.current = false + setAnimationDrained(false) + } + } + if (isStreaming) streamedThisSession.current = true - const [animationDrained, setAnimationDrained] = useState(false) useEffect(() => { if (isRevealing || animationDrained || !streamedThisSession.current) return const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS) @@ -373,7 +398,6 @@ function ChatContentInner({ * `animated` — a fresh animate plugin has no prev-content tracking and would * re-fade the entire visible segment. */ - const fadeCutoffRef = useRef(false) if (streamedContent.length > FADE_MAX_REVEALED_CHARS) fadeCutoffRef.current = true const fadeActive = streamingTree && !fadeCutoffRef.current From 61f6e6cad0166c5571bd0ddcd283b1d33b27a4dc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 11:29:09 -0700 Subject: [PATCH 3/8] fix(mothership): keep streaming parser on settled messages to kill the drain-swap flash --- .../components/chat-content/chat-content.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 9b6a9799683..8799eb73f35 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -347,8 +347,9 @@ function ChatContentInner({ * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content * without the animate plugin bails at every unoverridden element (`p`, * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in - * place. Remounting also converges the settled DOM byte-for-byte with what a - * reloaded transcript renders. + * place. The settled instance keeps the streaming parser (`parserTree` + * below) so the remount only sheds the spans, never re-interprets the + * markdown. * * The drain is deliberately one-way: a stream that resumes afterwards * (reconnect/continuation) reveals paced but unfaded, because re-arming @@ -390,7 +391,20 @@ function ChatContentInner({ return () => clearTimeout(timeout) }, [isRevealing, animationDrained]) - const streamingTree = (isRevealing || streamedThisSession.current) && !animationDrained + /** + * `parserTree` (drives `mode`) stays latched for the mount's life: streaming + * mode is the only one that applies remend/incomplete-markdown repair and + * block-split parsing, so a settled message must KEEP the streaming parser — + * swapping to `mode='static'` at drain re-parses the same source through a + * different pipeline (no remend, whole-doc parse) and visibly flashes on any + * reply with unbalanced markdown. `streamingTree` (drives the remount key + * and animation props) additionally drops at drain, so the settled instance + * re-renders through the SAME parser minus the per-word animation spans — + * byte-identical pixels. Only never-streamed mounts (reloaded history) + * render static. + */ + const parserTree = isRevealing || streamedThisSession.current + const streamingTree = parserTree && !animationDrained /** * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a @@ -473,8 +487,8 @@ function ChatContentInner({ className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')} > :first-child]:mt-0 [&>:last-child]:mb-0')}> Date: Sat, 4 Jul 2026 11:55:45 -0700 Subject: [PATCH 4/8] fix(mothership): unify plain/special render branches to stop whole-message re-fade when options arrive --- .../components/chat-content/chat-content.tsx | 160 +++++++++--------- 1 file changed, 77 insertions(+), 83 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 8799eb73f35..edaad4c0d1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -433,95 +433,89 @@ function ChatContentInner({ () => parseSpecialTags(streamedContent, isRevealing), [streamedContent, isRevealing] ) - const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') - - if (hasSpecialContent) { - type BlockSegment = Exclude< - ContentSegment, - { type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } - > - type RenderGroup = - | { kind: 'inline'; markdown: string } - | { kind: 'block'; segment: BlockSegment; index: number } - - const groups: RenderGroup[] = [] - let pendingMarkdown = '' - - const flushMarkdown = () => { - if (pendingMarkdown.trim()) { - groups.push({ kind: 'inline', markdown: pendingMarkdown }) - } - pendingMarkdown = '' - } - for (let i = 0; i < parsed.segments.length; i++) { - const s = parsed.segments[i] - const nextSegment = parsed.segments[i + 1] - if (s.type === 'workspace_resource') { - // Files are addressed by their encoded VFS path (copied verbatim from the tag); - // workflows/tables/KBs by id. The angle-bracket link destination keeps the path - // intact through markdown parsing (tolerates parens) without re-encoding it. - const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '') - const label = s.data.title || ref - pendingMarkdown = appendInlineReferenceMarkdown( - pendingMarkdown, - `[${label}](<#wsres-${s.data.type}-${ref}>)`, - nextSegment - ) - } else if (s.type === 'text' || s.type === 'thinking') { - pendingMarkdown += s.content - } else { - flushMarkdown() - groups.push({ kind: 'block', segment: s, index: i }) - } + type BlockSegment = Exclude< + ContentSegment, + { type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } + > + type RenderGroup = + | { kind: 'inline'; markdown: string } + | { kind: 'block'; segment: BlockSegment; index: number } + + const groups: RenderGroup[] = [] + let pendingMarkdown = '' + + const flushMarkdown = () => { + if (pendingMarkdown.trim()) { + groups.push({ kind: 'inline', markdown: pendingMarkdown }) } - flushMarkdown() + pendingMarkdown = '' + } - return ( -
- {groups.map((group, i) => { - if (group.kind === 'inline') { - return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')} - > - - {group.markdown} - -
- ) - } - return ( - - ) - })} - {parsed.hasPendingTag && isRevealing && } -
- ) + for (let i = 0; i < parsed.segments.length; i++) { + const s = parsed.segments[i] + const nextSegment = parsed.segments[i + 1] + if (s.type === 'workspace_resource') { + // Files are addressed by their encoded VFS path (copied verbatim from the tag); + // workflows/tables/KBs by id. The angle-bracket link destination keeps the path + // intact through markdown parsing (tolerates parens) without re-encoding it. + const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '') + const label = s.data.title || ref + pendingMarkdown = appendInlineReferenceMarkdown( + pendingMarkdown, + `[${label}](<#wsres-${s.data.type}-${ref}>)`, + nextSegment + ) + } else if (s.type === 'text' || s.type === 'thinking') { + pendingMarkdown += s.content + } else { + flushMarkdown() + groups.push({ kind: 'block', segment: s, index: i }) + } } + flushMarkdown() + /** + * Plain text and special-tag content share ONE render structure. A message + * with no special tags is simply a single inline group — it must NOT get a + * dedicated JSX branch, because most replies gain a trailing `` tag + * (suggested follow-ups) at the very end, and switching branches at that + * moment re-parents the Streamdown to a different tree position. React then + * remounts it with a fresh animate plugin and the ENTIRE message re-fades + * from transparent — the "flash at the conclusion". With the unified + * structure the leading text group keeps its position (`inline-0`) and only + * the new special block mounts. + */ return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')}> - - {streamedContent} - +
+ {groups.map((group, i) => { + if (group.kind === 'inline') { + return ( +
:first-child]:mt-0 [&>:last-child]:mb-0')} + > + + {group.markdown} + +
+ ) + } + return ( + + ) + })} + {parsed.hasPendingTag && isRevealing && }
) } From 56f391b4c9e2573fa72510beac9f425f642bd73b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:03:16 -0700 Subject: [PATCH 5/8] improvement(mothership): hold render-phase animation latches in useState per updated hook rules --- .../components/chat-content/chat-content.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index edaad4c0d1b..1283dc8617e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -356,9 +356,9 @@ function ChatContentInner({ * mounts a fresh animate plugin with no prev-content tracking, which would * re-fade the entire already-visible message. */ - const streamedThisSession = useRef(false) + const [streamedThisSession, setStreamedThisSession] = useState(false) const [animationDrained, setAnimationDrained] = useState(false) - const fadeCutoffRef = useRef(false) + const [fadeCutoff, setFadeCutoff] = useState(false) /** * The per-session latches above outlive the content when React reuses this @@ -370,26 +370,28 @@ function ChatContentInner({ * is REPLACED (not an append of the previous string) after the instance has * settled. A resumed turn only ever appends, so this never undoes the * one-way drain; mid-stream sanitize rewrites are excluded by the - * `animationDrained` gate (the drain only fires after settle). + * `animationDrained` gate (the drain only fires after settle). All latches + * are render-phase `useState` adjustments (prev-tracker idiom), not refs — + * they are read during render, and state is concurrent-safe where a + * render-phase ref mutation is not. */ - const prevDisplayContentRef = useRef(displayContent) - if (prevDisplayContentRef.current !== displayContent) { - const replaced = !displayContent.startsWith(prevDisplayContentRef.current) - prevDisplayContentRef.current = displayContent - if (replaced && animationDrained) { - streamedThisSession.current = false - fadeCutoffRef.current = false + const [prevDisplayContent, setPrevDisplayContent] = useState(displayContent) + if (prevDisplayContent !== displayContent) { + setPrevDisplayContent(displayContent) + if (!displayContent.startsWith(prevDisplayContent) && animationDrained) { + setStreamedThisSession(false) + setFadeCutoff(false) setAnimationDrained(false) } } - if (isStreaming) streamedThisSession.current = true + if (isStreaming && !streamedThisSession) setStreamedThisSession(true) useEffect(() => { - if (isRevealing || animationDrained || !streamedThisSession.current) return + if (isRevealing || animationDrained || !streamedThisSession) return const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS) return () => clearTimeout(timeout) - }, [isRevealing, animationDrained]) + }, [isRevealing, animationDrained, streamedThisSession]) /** * `parserTree` (drives `mode`) stays latched for the mount's life: streaming @@ -403,7 +405,7 @@ function ChatContentInner({ * byte-identical pixels. Only never-streamed mounts (reloaded history) * render static. */ - const parserTree = isRevealing || streamedThisSession.current + const parserTree = isRevealing || streamedThisSession const streamingTree = parserTree && !animationDrained /** @@ -412,8 +414,8 @@ function ChatContentInner({ * `animated` — a fresh animate plugin has no prev-content tracking and would * re-fade the entire visible segment. */ - if (streamedContent.length > FADE_MAX_REVEALED_CHARS) fadeCutoffRef.current = true - const fadeActive = streamingTree && !fadeCutoffRef.current + if (!fadeCutoff && streamedContent.length > FADE_MAX_REVEALED_CHARS) setFadeCutoff(true) + const fadeActive = streamingTree && !fadeCutoff useEffect(() => { const handler = (e: Event) => { From 0e288c23e20ade1a5c59cafec8f763dedb2fb6e5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:06:52 -0700 Subject: [PATCH 6/8] fix(styling): translucent text-selection in form controls so field text stays readable --- apps/sim/app/_styles/globals.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index ecdff6f7b2b..e316760dfbc 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -225,6 +225,7 @@ html.sidebar-booting .sidebar-shell-inner { --brand-accent: #33c482; --brand-accent-hover: #2dac72; --selection: #1a5cf6; + --selection-muted: rgba(26, 92, 246, 0.28); --warning: #ea580c; /* Inverted surface (dark chrome on light page, e.g. tag dropdown) */ @@ -382,6 +383,7 @@ html.sidebar-booting .sidebar-shell-inner { --brand-accent: #33c482; --brand-accent-hover: #2dac72; --selection: #4b83f7; + --selection-muted: rgba(75, 131, 247, 0.35); --warning: #ff6600; /* Inverted surface (in dark mode, falls back to standard surfaces) */ @@ -498,6 +500,17 @@ html.sidebar-booting .sidebar-shell-inner { color: var(--white); } + /* Form controls keep their own text color. Chromium does not paint + ::selection color inside input/textarea, and the chat input renders a + transparent textarea over a styled mirror — forced white can never reach + those glyphs, which left black text on the solid brand blue. A translucent + highlight stays readable over any field text in every browser. */ + input::selection, + textarea::selection { + background-color: var(--selection-muted); + color: currentColor; + } + body { background-color: var(--bg); color: var(--text-primary); From 368701787635c7e28e5607daef82eb86fe57af01 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:14:24 -0700 Subject: [PATCH 7/8] improvement(styling): one lighter translucent text-selection color everywhere, no forced text color --- apps/sim/app/_styles/globals.css | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index e316760dfbc..81401a1449e 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -494,21 +494,14 @@ html.sidebar-booting .sidebar-shell-inner { } /* Consistent branded text-selection color everywhere, independent of the - browser/OS default (which dims when the window loses focus). */ + browser/OS default (which dims when the window loses focus). Translucent, + with no color override, so selected text keeps its own paint on every + surface — solid-brand + forced white broke wherever glyphs aren't the + selected element's own (Chromium ignores ::selection color in form + controls, and the chat/workflow inputs render transparent textareas over + styled mirrors, which left black text on solid brand blue). */ ::selection { - background-color: var(--selection); - color: var(--white); - } - - /* Form controls keep their own text color. Chromium does not paint - ::selection color inside input/textarea, and the chat input renders a - transparent textarea over a styled mirror — forced white can never reach - those glyphs, which left black text on the solid brand blue. A translucent - highlight stays readable over any field text in every browser. */ - input::selection, - textarea::selection { background-color: var(--selection-muted); - color: currentColor; } body { From 5edddda6e4fbc1fa15c3210b15890a9c63ba6648 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:43:37 -0700 Subject: [PATCH 8/8] chore(styling): selection-muted tokens as 8-digit hex to match color token convention --- apps/sim/app/_styles/globals.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 81401a1449e..bbb8eca1745 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -225,7 +225,7 @@ html.sidebar-booting .sidebar-shell-inner { --brand-accent: #33c482; --brand-accent-hover: #2dac72; --selection: #1a5cf6; - --selection-muted: rgba(26, 92, 246, 0.28); + --selection-muted: #1a5cf647; --warning: #ea580c; /* Inverted surface (dark chrome on light page, e.g. tag dropdown) */ @@ -383,7 +383,7 @@ html.sidebar-booting .sidebar-shell-inner { --brand-accent: #33c482; --brand-accent-hover: #2dac72; --selection: #4b83f7; - --selection-muted: rgba(75, 131, 247, 0.35); + --selection-muted: #4b83f759; --warning: #ff6600; /* Inverted surface (in dark mode, falls back to standard surfaces) */