diff --git a/static/style.css b/static/style.css index c9cf00b39..721540a25 100644 --- a/static/style.css +++ b/static/style.css @@ -1458,6 +1458,21 @@ :root:not(.dark) .cron-btn:hover{background:rgba(0,0,0,.08);} /* ── Smooth dark mode transitions ── */ body,header,footer,aside,nav,main,div,button,input,textarea,select{transition-property:background-color,border-color,color;transition-duration:.15s;transition-timing-function:ease;} + /* Live assistant content is updated token-by-token. The global div color/background + transition above makes each streamed markdown rewrite briefly fade/flash on light + themes. Keep theme transitions elsewhere, but make the live turn paint instantly. */ + #liveAssistantTurn, + #liveAssistantTurn *, + #thinkingRow, + #thinkingRow *, + .assistant-segment[data-live-assistant="1"], + .assistant-segment[data-live-assistant="1"] *, + .agent-activity-thinking[data-thinking-active="1"], + .agent-activity-thinking[data-thinking-active="1"] *, + .agent-activity-thinking[data-live-thinking="1"], + .agent-activity-thinking[data-live-thinking="1"] *, + .live-worklog[data-live-worklog-shell="1"], + .live-worklog[data-live-worklog-shell="1"] *{transition-property:none!important;transition-duration:0s!important;transition-delay:0s!important;} :root{--app-titlebar-safe-top:0px;} .pwa-standalone{overscroll-behavior:none;} .pwa-standalone body{-webkit-tap-highlight-color:transparent;} diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index aaf918ff5..5a1cd78a9 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -55,6 +55,14 @@ def assert_contains_all(src: str, snippets: list[str]) -> None: assert snippet in src +def slice_between(src: str, start_anchor: str, end_anchor: str) -> str: + start = src.find(start_anchor) + assert start != -1, f"start anchor not found: {start_anchor!r}" + end = src.find(end_anchor, start + len(start_anchor)) + assert end != -1, f"end anchor not found after {start_anchor!r}: {end_anchor!r}" + return src[start:end] + + def fade_helper_script(performance_stub: str = "{_t:0,now(){return this._t;}}") -> str: helpers = "\n".join( function_block(MESSAGES_JS, name) @@ -197,6 +205,35 @@ def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): ) +def test_live_streaming_assistant_content_opts_out_of_global_theme_transitions(): + """Per-token markdown rewrites must not inherit global div color/background fades. + + The global theme transition is useful for dark/light switches, but live + assistant DOM updates happen for every streamed token. If those live nodes + inherit color/background transitions, light themes visibly flash/fade on + each word. + """ + live_transition_guard = slice_between( + STYLE_CSS, + "Live assistant content is updated token-by-token", + ":root{--app-titlebar-safe-top", + ) + assert_contains_all( + live_transition_guard, + [ + "#liveAssistantTurn *", + "#thinkingRow *", + '.assistant-segment[data-live-assistant="1"] *', + '.agent-activity-thinking[data-thinking-active="1"] *', + '.agent-activity-thinking[data-live-thinking="1"] *', + '.live-worklog[data-live-worklog-shell="1"] *', + "transition-property:none!important", + "transition-duration:0s!important", + "transition-delay:0s!important", + ], + ) + + def test_stream_fade_css_is_opacity_only_and_hides_live_cursor(): fade_css = STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :] assert "filter:" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(