From 7a732989fd44b20ade76c40f4de7fe2d5bb9064f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Tue, 29 Sep 2026 16:36:01 +0200 Subject: [PATCH 01/18] fix(css): collapse duplicate @keyframes names to the definition that wins Duplicate @keyframes names resolve last-wins across the whole cascade, so every definition but the last was dead code that still read as live at its call site. Two of the five duplicated names differed from the winner: fadeIn style.css had an opacity-only variant before the one that adds translateY, so every consumer was already sliding research-pulse a background-colour pulse sat before the opacity/scale one that actually runs The other three (spin, loading-bounce, pulse) were byte-identical repeats. Removing the losing definitions changes nothing rendered, which the computed style snapshot confirms: the committed baseline is reproduced exactly across all three pages and 24 variants. Deciding that a consumer wanted the plain fade, or the background pulse, would be a visual change and belongs in its own PR with screenshots. This also unblocks the mechanical stylesheet split. While two definitions of a name differed, neither could be moved: relocating either changes which one is last, and therefore changes behaviour. A regression test now fails on any duplicate name so the trap cannot come back. --- .../css/cookbook-research-memory-settings.css | 4 -- static/style.css | 18 ------- tests/test_stylesheet_keyframe_uniqueness.py | 52 +++++++++++++++++++ 3 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 tests/test_stylesheet_keyframe_uniqueness.py diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css index 3bcbb08cd..67f425dae 100644 --- a/static/css/cookbook-research-memory-settings.css +++ b/static/css/cookbook-research-memory-settings.css @@ -28,10 +28,6 @@ 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.7; } } -@keyframes research-pulse { - 0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); } - 50% { background: color-mix(in srgb, var(--red) 22%, transparent); } - } #cookbook-gguf-delete-overlay { background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); diff --git a/static/style.css b/static/style.css index 34711963c..94788c585 100644 --- a/static/style.css +++ b/static/style.css @@ -1480,7 +1480,6 @@ body.bg-pattern-ascii-fireflies { 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); } } - @keyframes spin { to { transform: rotate(360deg); } } .row { display:flex; gap:6px; align-items:center; } .list-item:hover, .models-row:hover { @@ -6119,9 +6118,6 @@ body.bg-pattern-ascii-fireflies { #mobile-backdrop, #mobile-menu-btn { display:none !important; } #sidebar-backdrop { display:none !important; } /* ----- Loading spinner ----- */ - @keyframes spin { - to { transform: rotate(360deg); } - } .spinner { width: 24px; height: 24px; @@ -6240,14 +6236,6 @@ body.bg-pattern-ascii-fireflies { .session-skeleton-bubble.is-user { width: 72%; } .session-skeleton-line { height: 9px; margin-top: 7px; } } - @keyframes loading-bounce { - 0%, 80%, 100% { - transform: scale(0); - } - 40% { - transform: scale(1); - } - } /* Modal styling */ .modal { position:fixed; @@ -7555,11 +7543,6 @@ body.bg-pattern-ascii-fireflies { animation: pulse 1.5s infinite; } - @keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } - } #recording-indicator { position: fixed; @@ -8720,7 +8703,6 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; } } .compare-probe-start-anyway svg { color: currentColor; } .compare-probe-start-anyway span { font-weight: 700; } - @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes pane-shake { 0%, 100% { transform: translateX(0); } 15% { transform: translateX(-3px) rotate(-0.5deg); } diff --git a/tests/test_stylesheet_keyframe_uniqueness.py b/tests/test_stylesheet_keyframe_uniqueness.py new file mode 100644 index 000000000..9fe26337d --- /dev/null +++ b/tests/test_stylesheet_keyframe_uniqueness.py @@ -0,0 +1,52 @@ +"""No two @keyframes may share a name across the app's stylesheets. + +Duplicate names resolve last-wins across the whole cascade, so an earlier +definition is dead code that still looks live at its call site. Two of them +existed and differed from the definition that actually won, which also blocked +moving either animation during the stylesheet decomposition: relocating one +changes which is last and therefore changes rendered behaviour. +""" + +import collections +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "static" + + +def _stylesheets() -> list[Path]: + """App-owned stylesheets, in no particular order. Vendored CSS is excluded.""" + sheets = [STATIC / "style.css"] + sheets.extend(sorted((STATIC / "css").glob("*.css"))) + return [s for s in sheets if s.exists()] + + +def _keyframe_names(text: str) -> list[str]: + without_comments = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.findall( + r"@(?:-webkit-)?keyframes\s+([A-Za-z0-9_-]+)\s*\{", without_comments + ) + + +def test_app_stylesheets_exist() -> None: + """Guard the guard: a rename must not turn this file into a no-op.""" + assert _stylesheets(), "no app stylesheets found to check" + + +def test_no_keyframes_name_is_declared_twice() -> None: + counts: collections.Counter[str] = collections.Counter() + where: dict[str, list[str]] = collections.defaultdict(list) + + for sheet in _stylesheets(): + for name in _keyframe_names(sheet.read_text(encoding="utf-8")): + counts[name] += 1 + where[name].append(sheet.relative_to(ROOT).as_posix()) + + duplicates = {name: where[name] for name, n in counts.items() if n > 1} + + assert duplicates == {}, ( + "duplicate @keyframes names resolve last-wins, so every definition but " + f"the last is dead: {duplicates}" + ) From 1323fab0bd2a301e93195e512c4301819eeb7e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Tue, 29 Sep 2026 17:05:29 +0200 Subject: [PATCH 02/18] refactor(tests): read the whole cascade instead of style.css alone static/style.css no longer holds every rule. 79 rules for the document, gallery and editor panels now live in static/css/, yet 27 browser tests still built their synthetic page with a single to style.css, and 51 more read that one file as though it were the whole cascade. Those tests kept passing while covering less: a rule that moved became invisible to the assertion that was meant to pin it. Python readers now call tests.helpers.stylesheets.app_css(), and synthetic pages are built from stylesheet_link_tags() so they load exactly what index.html loads, in the same order. The helper already existed; this moves the remaining callers onto it. test_portal_dropdown_z_js parametrised over a file list including style.css to assert an absence. Checking a negative against one file of a split stylesheet is how a moved rule escapes, so the CSS case now checks the concatenation. Two guards keep it from coming back: one fails on any test reading static/style.css directly, the other on any synthetic page linking it alone. Both name the helper to use. No production code changes. Suite is unchanged at 6 pre-existing failures. --- tests/test_agent_thread_chevron_static.py | 3 +- tests/test_agent_thread_dot_alignment_css.py | 5 +- ...st_background_chat_completion_ui_static.py | 3 +- tests/test_calendar_event_contrast.py | 5 +- tests/test_card_menu_style_contract.py | 3 +- tests/test_chat_mode_pill_static.py | 3 +- tests/test_chatgpt_reasoning_and_grouping.py | 3 +- tests/test_chatgpt_subscription_usage_js.py | 5 +- tests/test_compare_ask_user_routing.py | 9 ++-- tests/test_compare_mobile_tabs_static.py | 7 +-- tests/test_compare_shuffle_visual_static.py | 9 ++-- tests/test_dialog_aria.py | 3 +- tests/test_doc_library_open_orphaned.py | 4 +- tests/test_document_edit_reference_js.py | 3 +- tests/test_document_editor_scroll.py | 3 +- tests/test_document_history_controls.py | 4 +- tests/test_document_library_mobile_footer.py | 7 ++- tests/test_document_outline.py | 10 ++-- tests/test_document_preview_edit_button.py | 3 +- tests/test_document_rich_checklist_enter.py | 4 +- ..._document_rich_color_reset_and_contrast.py | 7 ++- tests/test_document_rich_docx_export.py | 7 ++- tests/test_document_rich_find_boundaries.py | 4 +- .../test_document_rich_font_color_controls.py | 4 +- tests/test_document_rich_heading_enter.py | 5 +- tests/test_document_rich_image_caption.py | 4 +- tests/test_document_rich_input_rules.py | 4 +- .../test_document_rich_keyboard_shortcuts.py | 4 +- tests/test_document_rich_selection_toolbar.py | 7 ++- tests/test_document_rich_slash_menu.py | 7 ++- tests/test_document_rich_smart_link_paste.py | 4 +- tests/test_document_rich_structure_tools.py | 7 ++- ...test_document_rich_table_cell_alignment.py | 4 +- ...document_rich_table_header_preservation.py | 4 +- tests/test_document_rich_table_headers.py | 4 +- tests/test_document_rich_table_merge_split.py | 4 +- tests/test_document_rich_table_tab_history.py | 4 +- tests/test_document_rich_toolbar_menus.py | 13 +++-- tests/test_document_save_shortcut.py | 4 +- tests/test_document_save_status.py | 4 +- tests/test_document_suggestion_highlight.py | 3 +- tests/test_document_toolbar_arrow_offset.py | 3 +- tests/test_document_toolbar_order.py | 4 +- tests/test_editor_layer_thumbnail_render.py | 3 +- tests/test_editor_quick_edit_static.py | 3 +- tests/test_editor_topbar_static.py | 3 +- tests/test_email_folder_chip_static.py | 3 +- tests/test_email_split_border_css.py | 3 +- tests/test_endpoint_card_ui_polish.py | 3 +- tests/test_escape_inner_layers.py | 5 +- tests/test_image_research_settings_static.py | 3 +- tests/test_interrupted_resume_label_static.py | 3 +- tests/test_mobile_search_position_static.py | 3 +- tests/test_mobile_welcome_keyboard_static.py | 5 +- tests/test_modal_dock_composer_clearance.py | 3 +- tests/test_notes_sidebar_new_note.py | 11 ++-- tests/test_portal_dropdown_z_js.py | 7 ++- tests/test_research_panel_ui.py | 3 +- tests/test_resend_message_nondestructive.py | 3 +- tests/test_select_dropdown_theme_css.py | 7 ++- tests/test_selection_overlay_clear_static.py | 3 +- tests/test_settings_shell_js_behavior.py | 7 ++- tests/test_shared_tag_strips_static.py | 3 +- tests/test_sidebar_chat_list_sizing_static.py | 3 +- tests/test_skill_deeplink_static.py | 11 ++-- ...test_stream_completion_scroll_stability.py | 3 +- tests/test_stylesheet_test_hygiene.py | 54 +++++++++++++++++++ tests/test_theme_sidebar_mobile.py | 7 ++- tests/test_toast_ui_polish.py | 5 +- tests/test_tool_approval_frontend_routing.py | 5 +- tests/test_tool_header_icons_static.py | 3 +- tests/test_visual_research_mode.py | 3 +- 72 files changed, 273 insertions(+), 116 deletions(-) create mode 100644 tests/test_stylesheet_test_hygiene.py diff --git a/tests/test_agent_thread_chevron_static.py b/tests/test_agent_thread_chevron_static.py index 62c0103f8..413506e23 100644 --- a/tests/test_agent_thread_chevron_static.py +++ b/tests/test_agent_thread_chevron_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_agent_thread_chevron_uses_css_shape_in_live_and_history_renderers(): live = (ROOT / "static/js/chat.js").read_text() history = (ROOT / "static/js/chatRenderer.js").read_text() - css = (ROOT / "static/style.css").read_text() + css = app_css() for src in (live, history): assert 'class="agent-thread-chevron" aria-hidden="true">' in src diff --git a/tests/test_agent_thread_dot_alignment_css.py b/tests/test_agent_thread_dot_alignment_css.py index 5c3558f7b..f0f394225 100644 --- a/tests/test_agent_thread_dot_alignment_css.py +++ b/tests/test_agent_thread_dot_alignment_css.py @@ -14,11 +14,10 @@ file identifies the breakpoint it belongs to. import re from pathlib import Path +from tests.helpers.stylesheets import app_css -CSS = (Path(__file__).resolve().parents[1] / "static" / "style.css").read_text( - encoding="utf-8" -) +CSS = app_css() THREAD = r"^[ \t]*\.agent-thread[ \t]*\{" RAIL = r"^[ \t]*\.agent-thread::before[ \t]*\{" diff --git a/tests/test_background_chat_completion_ui_static.py b/tests/test_background_chat_completion_ui_static.py index 3a7f50b51..a64f7fb47 100644 --- a/tests/test_background_chat_completion_ui_static.py +++ b/tests/test_background_chat_completion_ui_static.py @@ -1,10 +1,11 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CHAT = (ROOT / "static/js/chat.js").read_text() SESSIONS = (ROOT / "static/js/sessions.js").read_text() -CSS = (ROOT / "static/style.css").read_text() +CSS = app_css() def test_queued_prompts_are_persisted_per_session_and_restored_on_return(): diff --git a/tests/test_calendar_event_contrast.py b/tests/test_calendar_event_contrast.py index 1558551a5..ac692e451 100644 --- a/tests/test_calendar_event_contrast.py +++ b/tests/test_calendar_event_contrast.py @@ -4,11 +4,12 @@ import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CALENDAR_JS = ROOT / "static" / "js" / "calendar.js" -STYLE_CSS = ROOT / "static" / "style.css" +STYLE_CSS_TEXT = app_css() UTILS_JS = ROOT / "static" / "js" / "calendar" / "utils.js" pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH") @@ -65,7 +66,7 @@ def test_calendar_readable_text_color_keeps_light_text_for_dark_colors(): def test_calendar_event_surfaces_use_computed_foreground_variable(): calendar_js = CALENDAR_JS.read_text(encoding="utf-8") - style_css = STYLE_CSS.read_text(encoding="utf-8") + style_css = STYLE_CSS_TEXT utils_js = UTILS_JS.read_text(encoding="utf-8") assert "_calReadableTextColor" in utils_js diff --git a/tests/test_card_menu_style_contract.py b/tests/test_card_menu_style_contract.py index f32fb3950..f073a56d2 100644 --- a/tests/test_card_menu_style_contract.py +++ b/tests/test_card_menu_style_contract.py @@ -1,8 +1,9 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() LIBRARY = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8") diff --git a/tests/test_chat_mode_pill_static.py b/tests/test_chat_mode_pill_static.py index 6ef49150b..8cf375bcf 100644 --- a/tests/test_chat_mode_pill_static.py +++ b/tests/test_chat_mode_pill_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_user_mode_pill_is_rendered_and_live_updated(): renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8") chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") - styles = (ROOT / "static/style.css").read_text(encoding="utf-8") + styles = app_css() routes = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8") helpers = (ROOT / "routes/chat_helpers.py").read_text(encoding="utf-8") diff --git a/tests/test_chatgpt_reasoning_and_grouping.py b/tests/test_chatgpt_reasoning_and_grouping.py index 9c36ad474..ebdbbc0fa 100644 --- a/tests/test_chatgpt_reasoning_and_grouping.py +++ b/tests/test_chatgpt_reasoning_and_grouping.py @@ -12,6 +12,7 @@ import subprocess import pytest from src import chatgpt_subscription, llm_core +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).parents[1] @@ -101,7 +102,7 @@ def test_model_picker_source_invariants(): def test_composer_reasoning_effort_ui_markup(): """Verify static/index.html and static/style.css include reasoning effort controls.""" html = (ROOT / "static/index.html").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() # HTML elements assert 'id="reasoning-effort-wrap"' in html assert 'id="reasoning-effort-btn"' in html diff --git a/tests/test_chatgpt_subscription_usage_js.py b/tests/test_chatgpt_subscription_usage_js.py index 401cef6de..6014a85e9 100644 --- a/tests/test_chatgpt_subscription_usage_js.py +++ b/tests/test_chatgpt_subscription_usage_js.py @@ -6,11 +6,12 @@ import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent _MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js" _ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") -_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") +_STYLE = app_css() pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") @@ -269,7 +270,7 @@ def test_refresh_and_reconnect_handlers_target_only_the_clicked_account(): def test_admin_renders_chatgpt_usage_collapsible_and_styled(): admin_source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") - style_source = (_REPO / "static" / "style.css").read_text(encoding="utf-8") + style_source = app_css() load_block = admin_source[admin_source.index("async function loadEndpoints()"):admin_source.index("function initEndpointForm()")] assert "adm-chatgpt-controls" in load_block assert "adm-chatgpt-usage-toggle" in load_block diff --git a/tests/test_compare_ask_user_routing.py b/tests/test_compare_ask_user_routing.py index 6fb61cecc..e39ab5b78 100644 --- a/tests/test_compare_ask_user_routing.py +++ b/tests/test_compare_ask_user_routing.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css def test_compare_renders_ask_user_in_the_originating_pane(): @@ -80,7 +81,7 @@ def test_compare_pane_templates_hide_response_actions_until_response_exists(): root = Path(__file__).resolve().parents[1] index = (root / "static/js/compare/index.js").read_text(encoding="utf-8") panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert re.search(r"from './panes\.js\?v=[A-Za-z0-9_-]+'", index) assert re.search(r"from './selector\.js\?v=[A-Za-z0-9_-]+'", index) @@ -152,7 +153,7 @@ def test_compare_panes_have_visible_runtime_state_without_revealing_empty_action root = Path(__file__).resolve().parents[1] stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8") panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert "_paneEl.classList.remove('is-done', 'is-failed', 'is-awaiting-input');" in stream assert "_paneEl.classList.add('is-streaming');" in stream @@ -179,7 +180,7 @@ def test_compare_panes_surface_compact_result_summary(): index = (root / "static/js/compare/index.js").read_text(encoding="utf-8") panes = (root / "static/js/compare/panes.js").read_text(encoding="utf-8") stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert 'pane-header-row pane-header-secondary' in index assert 'class=\"pane-summary\" id=\"cmp-summary-' in index @@ -203,7 +204,7 @@ def test_compare_panes_surface_compact_result_summary(): def test_compare_selector_surfaces_duplicate_warning_without_blocking_start(): root = Path(__file__).resolve().parents[1] selector = (root / "static/js/compare/selector.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert "function _selectionKey(sel)" in selector assert "function _duplicateSelectionKeys()" in selector diff --git a/tests/test_compare_mobile_tabs_static.py b/tests/test_compare_mobile_tabs_static.py index 14270050b..55ad7ab5a 100644 --- a/tests/test_compare_mobile_tabs_static.py +++ b/tests/test_compare_mobile_tabs_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent @@ -32,7 +33,7 @@ def test_mobile_compare_reconciles_tabs_after_pane_lifecycle_changes(): def test_mobile_compare_css_shows_only_the_active_card(): - css = _read("static/style.css") + css = app_css() mobile = css[css.index("/* Compare uses one full-width response card on phones."):] assert ".compare-mobile-tabs" in mobile @@ -44,7 +45,7 @@ def test_mobile_compare_css_shows_only_the_active_card(): def test_probe_feedback_and_actions_use_the_shared_card_layout(): selector = _read("static/js/compare/selector.js") - css = _read("static/style.css") + css = app_css() assert "probeFeedback.className = 'compare-probe-feedback'" in selector assert "probeFeedback.appendChild(detail)" in selector @@ -66,7 +67,7 @@ def test_probe_feedback_and_actions_use_the_shared_card_layout(): def test_probe_swap_reopens_and_highlights_the_failed_model_slot(): selector = _read("static/js/compare/selector.js") - css = _read("static/style.css") + css = app_css() assert "function _expandModelSlot(slotIdx)" in selector assert "row.dataset.slotIndex = String(idx);" in selector diff --git a/tests/test_compare_shuffle_visual_static.py b/tests/test_compare_shuffle_visual_static.py index 1e2f00505..70768c70a 100644 --- a/tests/test_compare_shuffle_visual_static.py +++ b/tests/test_compare_shuffle_visual_static.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent @@ -11,7 +12,7 @@ def _read(path: str) -> str: def test_compare_shuffle_shows_center_notice_with_dice_icon(): panes = _read("static/js/compare/panes.js") - css = _read("static/style.css") + css = app_css() assert "ICON_DICE" in panes assert "compare-shuffle-notice" in panes @@ -23,7 +24,7 @@ def test_compare_shuffle_shows_center_notice_with_dice_icon(): def test_compare_chat_and_agent_panes_expose_per_pane_inference_settings(): index = _read("static/js/compare/index.js") panes = _read("static/js/compare/panes.js") - css = _read("static/style.css") + css = app_css() assert "pane-settings-btn" in panes assert "paneSettingsButtonHtml" in index @@ -41,7 +42,7 @@ def test_compare_chat_and_agent_panes_expose_per_pane_inference_settings(): def test_compare_probe_control_has_requested_vertical_alignment(): index = _read("static/js/compare/index.js") probe = _read("static/js/compare/probe.js") - css = _read("static/style.css") + css = app_css() assert 'class="compare-check-icon"' in index assert 'Probe' in index @@ -75,7 +76,7 @@ def test_compare_cache_key_bumped_for_shuffle_notice(): def test_compare_score_button_label_is_nudged_up(): vote = _read("static/js/compare/vote.js") - css = _read("static/style.css") + css = app_css() assert 'Score' in vote assert ".compare-score-label" in css diff --git a/tests/test_dialog_aria.py b/tests/test_dialog_aria.py index 3513f9b0d..5ce8ce427 100644 --- a/tests/test_dialog_aria.py +++ b/tests/test_dialog_aria.py @@ -10,6 +10,7 @@ of the other tests in this suite. """ import re from pathlib import Path +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent _INDEX = (_REPO / "static" / "index.html").read_text(encoding="utf-8") @@ -47,7 +48,7 @@ def test_styled_confirm_and_prompt_are_modal_dialogs(): def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button(): - css = (_REPO / "static" / "style.css").read_text(encoding="utf-8") + css = app_css() assert "cancelLabel.textContent = cancelText;" in _UI assert "cancelBtn.appendChild(cancelLabel);" in _UI diff --git a/tests/test_doc_library_open_orphaned.py b/tests/test_doc_library_open_orphaned.py index 58e52d673..76e47b7cc 100644 --- a/tests/test_doc_library_open_orphaned.py +++ b/tests/test_doc_library_open_orphaned.py @@ -18,6 +18,7 @@ import re import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags SRC = Path(__file__).resolve().parent.parent / "static/js/documentLibrary.js" @@ -96,7 +97,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); const state = await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&mobile-library-open-test=1'); mod.init('/api'); @@ -134,6 +135,7 @@ def test_mobile_explicit_load_restores_full_editor_from_bottom_dock(): console.log(JSON.stringify(state)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=SRC.parents[1], diff --git a/tests/test_document_edit_reference_js.py b/tests/test_document_edit_reference_js.py index 243d23406..65b607598 100644 --- a/tests/test_document_edit_reference_js.py +++ b/tests/test_document_edit_reference_js.py @@ -1,12 +1,13 @@ """Regression guards for document-selection references in chat bubbles.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8") DOCUMENT = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8") APP = (ROOT / "static/app.js").read_text(encoding="utf-8") diff --git a/tests/test_document_editor_scroll.py b/tests/test_document_editor_scroll.py index 89cbc7b81..1023cab2f 100644 --- a/tests/test_document_editor_scroll.py +++ b/tests/test_document_editor_scroll.py @@ -9,11 +9,12 @@ document.js is browser-coupled and not importable in pytest. """ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE_CSS = app_css() def test_document_textarea_scrollbar_is_visible(): diff --git a/tests/test_document_history_controls.py b/tests/test_document_history_controls.py index bd4b297ac..ddffe6e69 100644 --- a/tests/test_document_history_controls.py +++ b/tests/test_document_history_controls.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -24,7 +25,7 @@ def test_mobile_rich_text_history_state_and_document_switch(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&history-controls-test=1'); window.documentModuleForTest = mod; @@ -85,6 +86,7 @@ def test_mobile_rich_text_history_state_and_document_switch(): console.log(JSON.stringify({ initial, typed, undone, redone, switched, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_library_mobile_footer.py b/tests/test_document_library_mobile_footer.py index f5a9921ae..ad94861c9 100644 --- a/tests/test_document_library_mobile_footer.py +++ b/tests/test_document_library_mobile_footer.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] SOURCE = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_mobile_footer_exposes_delete_open_and_more(): @@ -53,7 +55,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); const state = await page.evaluate(async () => { let currentSession = 'current-chat'; let createDirectCalls = 0; @@ -106,6 +108,7 @@ def test_mobile_open_in_new_chat_copies_to_materialized_session(): console.log(JSON.stringify(state)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_outline.py b/tests/test_document_outline.py index 4ab98d680..63f4ebc90 100644 --- a/tests/test_document_outline.py +++ b/tests/test_document_outline.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def _run_node(script: str): @@ -41,7 +43,7 @@ def test_markdown_outline_parses_structure_and_ignores_fenced_code(): '#### Final `code` section', ].join('\n'); console.log(JSON.stringify(parseMarkdownOutline(source))); - """ + """.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) ) assert [(entry["level"], entry["text"]) for entry in data] == [ (1, "Overview"), @@ -71,7 +73,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&outline-test=1'); mod.init('/api'); @@ -126,7 +128,7 @@ def test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile(): console.log(JSON.stringify({ markdownLabels, selected, liveLabels, mobileBox, richLabels, richCaretHeading })); await browser.close(); - """ + """.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) ) assert data["markdownLabels"] == ["Intro", "Details", "End"] assert data["selected"] == "## Details" diff --git a/tests/test_document_preview_edit_button.py b/tests/test_document_preview_edit_button.py index 52340f96d..ffe44f386 100644 --- a/tests/test_document_preview_edit_button.py +++ b/tests/test_document_preview_edit_button.py @@ -1,11 +1,12 @@ """Regression guards for the Markdown preview hover-to-edit control.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE_CSS = app_css() def test_preview_installs_hover_edit_button(): diff --git a/tests/test_document_rich_checklist_enter.py b/tests/test_document_rich_checklist_enter.py index b36bdb67b..92154adb0 100644 --- a/tests/test_document_rich_checklist_enter.py +++ b/tests/test_document_rich_checklist_enter.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -26,7 +27,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&checklist-enter-test=1'); mod.init('/api'); @@ -78,6 +79,7 @@ def test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly(): console.log(JSON.stringify({ afterFirstEnter, ...data })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_color_reset_and_contrast.py b/tests/test_document_rich_color_reset_and_contrast.py index 64c237a7b..287bef986 100644 --- a/tests/test_document_rich_color_reset_and_contrast.py +++ b/tests/test_document_rich_color_reset_and_contrast.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_color_controls_have_theme_reset_and_split_palettes(): @@ -27,7 +29,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit(): await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); await page.setContent(` + __ODY_STYLESHEETS__
`); await page.evaluate(async () => { const mod = await import('/static/js/document.js?rich-color-test=' + Date.now()); @@ -80,6 +82,7 @@ def test_rich_colors_follow_theme_and_undo_as_one_edit(): console.log(JSON.stringify({ palette, highlighted, highlightUndone, defaultColor, changedThemeColor, colorUndone })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_docx_export.py b/tests/test_document_rich_docx_export.py index f5b6d5c6b..61cc71ad2 100644 --- a/tests/test_document_rich_docx_export.py +++ b/tests/test_document_rich_docx_export.py @@ -5,6 +5,7 @@ import subprocess import tempfile import zipfile from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -64,7 +65,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml(): contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), }})); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => {{ const mod = await import('/static/js/document.js?v=20260831richtexttools91&docx-export-test=1'); mod.init('/api'); @@ -89,6 +90,7 @@ def test_browser_word_export_contains_native_rich_docx_ooxml(): }})); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -158,7 +160,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting(): acceptDownloads: true, }}); await page.goto(`${{process.env.ODYSSEUS_TEST_STATIC_ORIGIN}}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => {{ const mod = await import('/static/js/document.js?v=20260831richtexttools91&markdown-docx-export-test=1'); mod.init('/api'); @@ -180,6 +182,7 @@ def test_browser_markdown_word_export_keeps_heading_and_inline_formatting(): console.log(JSON.stringify({{ failure: await download.failure() }})); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_find_boundaries.py b/tests/test_document_rich_find_boundaries.py index c4e293ad8..ca2280455 100644 --- a/tests/test_document_rich_find_boundaries.py +++ b/tests/test_document_rich_find_boundaries.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -25,7 +26,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&find-boundaries-test=1'); mod.init('/api'); @@ -73,6 +74,7 @@ def test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replac console.log(JSON.stringify({ crossParagraph, crossBreak, crossInline, ...data })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_font_color_controls.py b/tests/test_document_rich_font_color_controls.py index cfecf8699..fa8691c2f 100644 --- a/tests/test_document_rich_font_color_controls.py +++ b/tests/test_document_rich_font_color_controls.py @@ -6,6 +6,7 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -94,7 +95,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile(): async function exercise(viewport, suffix) { const page = await browser.newPage({ viewport }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&font-color=${suffix}`); mod.init('/api'); @@ -190,6 +191,7 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile(): console.log(JSON.stringify({ desktop, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_heading_enter.py b/tests/test_document_rich_heading_enter.py index d2df9ca89..9a876ca9d 100644 --- a/tests/test_document_rich_heading_enter.py +++ b/tests/test_document_rich_heading_enter.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -29,7 +30,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&heading-enter=1'); mod.init('/api'); @@ -73,6 +74,7 @@ def test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable(): console.log(JSON.stringify({ entered, undone, redone })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -152,6 +154,7 @@ def test_heading_enter_preserves_shift_middle_and_empty_heading_semantics(): console.log(JSON.stringify(state)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_image_caption.py b/tests/test_document_rich_image_caption.py index 7c573dd03..b7e04101e 100644 --- a/tests/test_document_rich_image_caption.py +++ b/tests/test_document_rich_image_caption.py @@ -5,6 +5,7 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -36,7 +37,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&image-caption=1'); mod.init('/api'); @@ -116,6 +117,7 @@ def test_mobile_image_caption_survives_resize_history_and_empty_removal(): console.log(JSON.stringify({ added, undone, redone, resized, removed, removalUndone, menuRect, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_input_rules.py b/tests/test_document_rich_input_rules.py index 0e93edc35..cf714fe03 100644 --- a/tests/test_document_rich_input_rules.py +++ b/tests/test_document_rich_input_rules.py @@ -5,6 +5,7 @@ import subprocess from pathlib import Path from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -28,7 +29,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&input-rules-test=1'); mod.init('/api'); @@ -101,6 +102,7 @@ def test_typing_markers_converts_blocks_and_preserves_following_text(): console.log(JSON.stringify(data)); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_keyboard_shortcuts.py b/tests/test_document_rich_keyboard_shortcuts.py index 89a09ec27..570daa87e 100644 --- a/tests/test_document_rich_keyboard_shortcuts.py +++ b/tests/test_document_rich_keyboard_shortcuts.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -16,7 +17,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths(): async function exercise(viewport, suffix) { const page = await browser.newPage({ viewport }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&keyboard-shortcuts=${suffix}`); mod.init('/api'); @@ -94,6 +95,7 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths(): console.log(JSON.stringify({ desktop, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_selection_toolbar.py b/tests/test_document_rich_selection_toolbar.py index 306d45cce..b13977268 100644 --- a/tests/test_document_rich_selection_toolbar.py +++ b/tests/test_document_rich_selection_toolbar.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_selection_toolbar_uses_shared_formatting_path_and_preserves_range(): @@ -26,7 +28,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&selection-toolbar-test=1'); mod.init('/api'); @@ -102,6 +104,7 @@ def test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports console.log(JSON.stringify({ activeBold, desktopGeometry, formattedHtml, selectedAfterFormat, dismissed, mobileGeometry })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_slash_menu.py b/tests/test_document_rich_slash_menu.py index 7c191985d..1ade47972 100644 --- a/tests/test_document_rich_slash_menu.py +++ b/tests/test_document_rich_slash_menu.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_slash_menu_reuses_rich_text_actions_and_is_accessible(): @@ -41,7 +43,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentOutline.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&slash-menu-test=1'); mod.init('/api'); @@ -149,6 +151,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile(): console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_smart_link_paste.py b/tests/test_document_rich_smart_link_paste.py index 8f8c675e7..51b844209 100644 --- a/tests/test_document_rich_smart_link_paste.py +++ b/tests/test_document_rich_smart_link_paste.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -14,7 +15,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&smart-link-paste=1'); mod.init('/api'); @@ -133,6 +134,7 @@ def test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks console.log(JSON.stringify({ selectedLink, storedAfterPaste, afterUndo, afterRedoLinks, caretLink, plainResults, emailLink, crossBlock })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_structure_tools.py b/tests/test_document_rich_structure_tools.py index e6fd58922..049e7972b 100644 --- a/tests/test_document_rich_structure_tools.py +++ b/tests/test_document_rich_structure_tools.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_heading_levels_and_page_break_are_exposed_everywhere(): @@ -44,7 +46,7 @@ def test_mobile_headings_page_break_history_and_persistence(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&structure-tools-test=1'); window.__structureToolsDocModule = mod; @@ -152,6 +154,7 @@ def test_mobile_headings_page_break_history_and_persistence(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_cell_alignment.py b/tests/test_document_rich_table_cell_alignment.py index a410723e3..8f07d4231 100644 --- a/tests/test_document_rich_table_cell_alignment.py +++ b/tests/test_document_rich_table_cell_alignment.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -31,7 +32,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-cell-alignment=1'); mod.init('/api'); @@ -122,6 +123,7 @@ def test_mobile_table_cell_alignment_tracks_state_and_native_history(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_header_preservation.py b/tests/test_document_rich_table_header_preservation.py index 4c7c03626..f08825dd0 100644 --- a/tests/test_document_rich_table_header_preservation.py +++ b/tests/test_document_rich_table_header_preservation.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -36,7 +37,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-preservation-test=1'); mod.init('/api'); @@ -119,6 +120,7 @@ def test_mobile_structural_edits_preserve_header_modes_and_history(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_headers.py b/tests/test_document_rich_table_headers.py index cd77ba93c..fb52d4630 100644 --- a/tests/test_document_rich_table_headers.py +++ b/tests/test_document_rich_table_headers.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -40,7 +41,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-header-test=1'); mod.init('/api'); @@ -117,6 +118,7 @@ def test_mobile_header_row_and_column_toggle_independently_with_undo(): console.log(JSON.stringify({ initial, menuRect, columnOn, undone, redone, bothOn, rowOff, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_merge_split.py b/tests/test_document_rich_table_merge_split.py index 85bb04387..6aeb76897 100644 --- a/tests/test_document_rich_table_merge_split.py +++ b/tests/test_document_rich_table_merge_split.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -34,7 +35,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history( const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-merge-split=1'); mod.init('/api'); @@ -138,6 +139,7 @@ def test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history( })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_table_tab_history.py b/tests/test_document_rich_table_tab_history.py index abca1d02a..ec7de0013 100644 --- a/tests/test_document_rich_table_tab_history.py +++ b/tests/test_document_rich_table_tab_history.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -31,7 +32,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&table-tab-history-test=1'); mod.init('/api'); @@ -98,6 +99,7 @@ def test_mobile_table_tab_navigation_row_creation_and_history(): console.log(JSON.stringify({ forward, backward, appended, undone, redone, overflow })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_rich_toolbar_menus.py b/tests/test_document_rich_toolbar_menus.py index 178820293..6aa33b379 100644 --- a/tests/test_document_rich_toolbar_menus.py +++ b/tests/test_document_rich_toolbar_menus.py @@ -3,11 +3,13 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import app_css +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_rich_toolbar_menus_expose_keyboard_and_context_state(): @@ -58,7 +60,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toggle-test=1'); mod.init('/api'); @@ -101,6 +103,7 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens(): console.log(JSON.stringify({ opened, closed, sliderOpened, sliderClosed, pickerOpened, colorClosed, before, scroll })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -127,7 +130,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-menu-test=1'); mod.init('/api'); @@ -204,6 +207,7 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, @@ -242,7 +246,7 @@ def test_rich_toolbar_menus_track_live_formatting_values(): const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&toolbar-state-test=1'); mod.init('/api'); @@ -322,6 +326,7 @@ def test_rich_toolbar_menus_track_live_formatting_values(): })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_save_shortcut.py b/tests/test_document_save_shortcut.py index 0b1fe4f9b..c1265071e 100644 --- a/tests/test_document_save_shortcut.py +++ b/tests/test_document_save_shortcut.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -38,7 +39,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status(): }); }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-shortcut-test=1'); mod.init('/api'); @@ -71,6 +72,7 @@ def test_ctrl_s_saves_rich_text_immediately_once_and_updates_status(): console.log(JSON.stringify({ dirty, saved, badge, requests })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_save_status.py b/tests/test_document_save_status.py index f29698ff0..487667799 100644 --- a/tests/test_document_save_status.py +++ b/tests/test_document_save_status.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -32,7 +33,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures(): active -= 1; }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=20260831richtexttools91&save-status-test=1'); mod.init('/api'); @@ -81,6 +82,7 @@ def test_save_status_is_dirty_race_safe_and_reports_failures(): console.log(JSON.stringify({ initial, dirty, duringSave, afterSave, afterFailure, bodies, maxActive, footer, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_document_suggestion_highlight.py b/tests/test_document_suggestion_highlight.py index 3dd819bda..f2272d66f 100644 --- a/tests/test_document_suggestion_highlight.py +++ b/tests/test_document_suggestion_highlight.py @@ -1,11 +1,12 @@ """Regression guards for exact inline document-suggestion highlighting.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8") -STYLE_CSS = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE_CSS = app_css() def test_suggestion_highlight_measures_exact_referenced_range(): diff --git a/tests/test_document_toolbar_arrow_offset.py b/tests/test_document_toolbar_arrow_offset.py index 5a145b620..096586249 100644 --- a/tests/test_document_toolbar_arrow_offset.py +++ b/tests/test_document_toolbar_arrow_offset.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_toolbar_arrows_have_real_flex_slots_outside_icon_scroller(): script = (ROOT / "static/js/document.js").read_text() - styles = (ROOT / "static/style.css").read_text() + styles = app_css() leading = script.index('class="md-toolbar-leading-controls"') left_arrow = script.index('id="md-scroll-left"') diff --git a/tests/test_document_toolbar_order.py b/tests/test_document_toolbar_order.py index 82e883380..669422830 100644 --- a/tests/test_document_toolbar_order.py +++ b/tests/test_document_toolbar_order.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -38,7 +39,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile(): async function inspect(viewport, suffix) { const page = await browser.newPage({ viewport }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async suffix => { const mod = await import(`/static/js/document.js?v=20260831richtexttools91&toolbar-order=${suffix}`); mod.init('/api'); @@ -79,6 +80,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile(): console.log(JSON.stringify({ desktop, mobile })); await browser.close(); """ + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=ROOT, diff --git a/tests/test_editor_layer_thumbnail_render.py b/tests/test_editor_layer_thumbnail_render.py index aa9e283e1..286042011 100644 --- a/tests/test_editor_layer_thumbnail_render.py +++ b/tests/test_editor_layer_thumbnail_render.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -49,7 +50,7 @@ def test_group_rows_support_keyboard_selection(): def test_keyboard_focus_is_visible_for_layer_and_group_rows(): - styles = (ROOT / "static/style.css").read_text() + styles = app_css() assert ".ge-layer-item:focus-visible," in styles assert ".ge-layer-group-row:focus-visible" in styles diff --git a/tests/test_editor_quick_edit_static.py b/tests/test_editor_quick_edit_static.py index 7f90d92c4..72b17115a 100644 --- a/tests/test_editor_quick_edit_static.py +++ b/tests/test_editor_quick_edit_static.py @@ -1,9 +1,10 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_quick_edit_has_persistent_expanded_identity_and_labeled_input(): diff --git a/tests/test_editor_topbar_static.py b/tests/test_editor_topbar_static.py index 8f2ed3d50..377e47cf8 100644 --- a/tests/test_editor_topbar_static.py +++ b/tests/test_editor_topbar_static.py @@ -1,11 +1,12 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] TOPBAR = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8") OVERFLOW = (ROOT / "static/js/editor/wire-topbar-overflow.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_primary_editor_text_actions_use_stacked_toolbar_contract(): diff --git a/tests/test_email_folder_chip_static.py b/tests/test_email_folder_chip_static.py index 193cfa6aa..4410414ab 100644 --- a/tests/test_email_folder_chip_static.py +++ b/tests/test_email_folder_chip_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_folder_chip_stays_with_date_and_moves_down(): source = (ROOT / "static" / "js" / "emailLibrary.js").read_text() - css = (ROOT / "static" / "style.css").read_text() + css = app_css() assert 'class="email-meta-date-group"' in source group_markup = source[source.index('class="email-meta-date-group"'):][:180] diff --git a/tests/test_email_split_border_css.py b/tests/test_email_split_border_css.py index cf34d51b9..78a11899c 100644 --- a/tests/test_email_split_border_css.py +++ b/tests/test_email_split_border_css.py @@ -1,7 +1,8 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -CSS = (Path(__file__).parents[1] / "static" / "style.css").read_text(encoding="utf-8") +CSS = app_css() def _rule(selector: str) -> str: diff --git a/tests/test_endpoint_card_ui_polish.py b/tests/test_endpoint_card_ui_polish.py index afd160b25..6f31e2a06 100644 --- a/tests/test_endpoint_card_ui_polish.py +++ b/tests/test_endpoint_card_ui_polish.py @@ -5,11 +5,12 @@ import shutil import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent _ADMIN_JS = _REPO / "static" / "js" / "admin.js" _ADMIN = _ADMIN_JS.read_text(encoding="utf-8") -_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") +_STYLE = app_css() pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") diff --git a/tests/test_escape_inner_layers.py b/tests/test_escape_inner_layers.py index f7818178d..d29dd5bb5 100644 --- a/tests/test_escape_inner_layers.py +++ b/tests/test_escape_inner_layers.py @@ -3,6 +3,7 @@ import json import subprocess from pathlib import Path +from tests.helpers.stylesheets import stylesheet_link_tags ROOT = Path(__file__).resolve().parents[1] @@ -14,7 +15,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None: const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 900, height: 700 } }); await page.goto(`${process.env.ODYSSEUS_TEST_STATIC_ORIGIN}/static/js/documentStats.js`); - await page.setContent('
'); + await page.setContent('__ODY_STYLESHEETS__
'); await page.evaluate(async () => { const mod = await import('/static/js/document.js?v=escape-regression-1'); mod.init('/api'); @@ -55,6 +56,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None: console.log(JSON.stringify({ before, afterOne, afterTwo })); await browser.close(); ''' + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ['node', '--input-type=module', '-e', script], cwd=ROOT, @@ -114,6 +116,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None: console.log(JSON.stringify({ settings, select, reading })); await browser.close(); ''' + script = script.replace("__ODY_STYLESHEETS__", stylesheet_link_tags()) result = subprocess.run( ['node', '--input-type=module', '-e', script], cwd=ROOT, diff --git a/tests/test_image_research_settings_static.py b/tests/test_image_research_settings_static.py index 1e07626e1..f56126469 100644 --- a/tests/test_image_research_settings_static.py +++ b/tests/test_image_research_settings_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -15,7 +16,7 @@ def test_image_settings_only_list_online_served_image_models(): def test_deep_research_fields_are_constrained_to_their_card(): - styles = (ROOT / "static/style.css").read_text(encoding="utf-8") + styles = app_css() rule = styles[styles.index("/* Deep Research uses long labels"):] assert ".admin-card:has(#set-researchSearch) .settings-row { min-width: 0; }" in rule diff --git a/tests/test_interrupted_resume_label_static.py b/tests/test_interrupted_resume_label_static.py index d4fddf388..b56119da6 100644 --- a/tests/test_interrupted_resume_label_static.py +++ b/tests/test_interrupted_resume_label_static.py @@ -1,11 +1,12 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CHAT = (ROOT / "static/js/chat.js").read_text() RENDERER = (ROOT / "static/js/chatRenderer.js").read_text() -STYLE = (ROOT / "static/style.css").read_text() +STYLE = app_css() APP = (ROOT / "static/app.js").read_text() INDEX = (ROOT / "static/index.html").read_text() diff --git a/tests/test_mobile_search_position_static.py b/tests/test_mobile_search_position_static.py index 411244286..eb7d31bfb 100644 --- a/tests/test_mobile_search_position_static.py +++ b/tests/test_mobile_search_position_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_search_popup_freezes_offset_before_mobile_keyboard_focus() -> None: source = (ROOT / "static/js/search-chat.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() set_offset = source.index("--search-overlay-top") show_overlay = source.index("overlay.classList.remove('hidden')", set_offset) diff --git a/tests/test_mobile_welcome_keyboard_static.py b/tests/test_mobile_welcome_keyboard_static.py index c957724c8..9339f1563 100644 --- a/tests/test_mobile_welcome_keyboard_static.py +++ b/tests/test_mobile_welcome_keyboard_static.py @@ -1,11 +1,12 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] def test_mobile_keyboard_fades_welcome_without_moving_it() -> None: - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() init_js = (ROOT / "static/js/init.js").read_text(encoding="utf-8") kb_rule = css.split("#welcome-screen.kb-hidden {", 1)[1].split("}", 1)[0] @@ -22,7 +23,7 @@ def test_mobile_keyboard_fades_welcome_without_moving_it() -> None: def test_nobody_label_collapses_by_width_not_keyboard_height() -> None: - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() label_rule = css.rsplit(".incognito-btn .incognito-label {", 1)[0] media_header = label_rule.rsplit("@media", 1)[1].split("{", 1)[0] diff --git a/tests/test_modal_dock_composer_clearance.py b/tests/test_modal_dock_composer_clearance.py index 5dfcfe2c1..b9480e06e 100644 --- a/tests/test_modal_dock_composer_clearance.py +++ b/tests/test_modal_dock_composer_clearance.py @@ -1,7 +1,8 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -CSS = Path("static/style.css").read_text(encoding="utf-8") +CSS = app_css() INIT_JS = Path("static/js/init.js").read_text(encoding="utf-8") diff --git a/tests/test_notes_sidebar_new_note.py b/tests/test_notes_sidebar_new_note.py index 6caf9f9a3..7c78818be 100644 --- a/tests/test_notes_sidebar_new_note.py +++ b/tests/test_notes_sidebar_new_note.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_notes_sidebar_new_action_matches_library_structure_and_animation(): html = (ROOT / "static/index.html").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert 'class="list-item-plus-btn sidebar-new-item-btn" id="notes-new-note-btn"' in html assert 'new' in html[html.index('id="notes-new-note-btn"'):] @@ -51,7 +52,7 @@ def test_checklist_preview_starts_at_first_unfinished_item(): def test_checklist_preview_releases_scroll_at_its_boundaries(): notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "el.addEventListener('wheel'" in notes assert "el.closest('.notes-pane-body')" in notes @@ -60,7 +61,7 @@ def test_checklist_preview_releases_scroll_at_its_boundaries(): def test_notes_tags_toggle_is_nudged_down(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() rule = css[css.index(".notes-search-bar .notes-label-toggle {"):] rule = rule[:rule.index("}")] @@ -68,7 +69,7 @@ def test_notes_tags_toggle_is_nudged_down(): def test_notes_body_and_tag_arrow_are_nudged_down(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert css.count("padding: 10px 8px 8px") >= 2 assert ".notes-pane-body .doclib-chip-scroll-arrow.right" in css @@ -76,7 +77,7 @@ def test_notes_body_and_tag_arrow_are_nudged_down(): def test_notes_tag_strip_cannot_grow_into_blank_space(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() rule = css[css.index(".notes-pane-body > .doclib-chip-scroll-frame:has(> .notes-labels-bar) {"):] rule = rule[:rule.index("}")] diff --git a/tests/test_portal_dropdown_z_js.py b/tests/test_portal_dropdown_z_js.py index 71248ee7c..2e15549e9 100644 --- a/tests/test_portal_dropdown_z_js.py +++ b/tests/test_portal_dropdown_z_js.py @@ -16,6 +16,7 @@ import textwrap from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -100,9 +101,11 @@ def test_late_routed_dropdowns_use_top_portal_z(rel): assert "topPortalZ()" in src, f"{rel} must call topPortalZ() for its dropdown z" -@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "static/style.css"]) +@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "app-css"]) def test_no_hardcoded_portal_z_literals_remain(rel): - src = (ROOT / rel).read_text() + # "app-css" is the whole cascade: a moved rule must not escape this check + # by landing in a stylesheet this list does not name. + src = app_css() if rel == "app-css" else (ROOT / rel).read_text() # Match the exact 100000/100002 these dropdowns used; the trailing-digit # guard avoids false-matching an unrelated 1000000 elsewhere. hits = re.findall(r"z-index:\s*10000[02](?!\d)", src) diff --git a/tests/test_research_panel_ui.py b/tests/test_research_panel_ui.py index 304b273a7..ecec6db8a 100644 --- a/tests/test_research_panel_ui.py +++ b/tests/test_research_panel_ui.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_research_settings_use_custom_pickers_and_shared_provider_icons(): panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8") - style = (ROOT / "static/style.css").read_text(encoding="utf-8") + style = app_css() settings = (ROOT / "static/js/settings.js").read_text(encoding="utf-8") icons = (ROOT / "static/js/searchProviderIcons.js").read_text(encoding="utf-8") diff --git a/tests/test_resend_message_nondestructive.py b/tests/test_resend_message_nondestructive.py index ebd9bf435..a5274cc17 100644 --- a/tests/test_resend_message_nondestructive.py +++ b/tests/test_resend_message_nondestructive.py @@ -9,6 +9,7 @@ from pathlib import Path from types import SimpleNamespace from routes.history.history_routes import _keep_count_before_message +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent @@ -76,7 +77,7 @@ def test_footer_resend_uses_default_replacement_behavior(): def test_footer_resend_uses_round_svg_icon_not_text_glyph(): renderer = _CHAT_RENDERER_JS.read_text(encoding="utf-8") - style = (_REPO / "static" / "style.css").read_text(encoding="utf-8") + style = app_css() assert "const RESEND_ICON =" in renderer assert "resend-message-icon" in renderer diff --git a/tests/test_select_dropdown_theme_css.py b/tests/test_select_dropdown_theme_css.py index bcfdf23ec..80a59df8c 100644 --- a/tests/test_select_dropdown_theme_css.py +++ b/tests/test_select_dropdown_theme_css.py @@ -1,11 +1,10 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -STYLE_CSS = Path(__file__).resolve().parents[1] / "static" / "style.css" - - +STYLE_CSS_TEXT = app_css() def _style_text() -> str: - return STYLE_CSS.read_text(encoding="utf-8") + return STYLE_CSS_TEXT def test_native_select_options_use_theme_tokens(): diff --git a/tests/test_selection_overlay_clear_static.py b/tests/test_selection_overlay_clear_static.py index d3671fc67..4761f4ff3 100644 --- a/tests/test_selection_overlay_clear_static.py +++ b/tests/test_selection_overlay_clear_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_selection_overlays_have_individual_clear_controls(): js = (ROOT / "static/js/document.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "function clearSelectionAt(index)" in js assert "className = 'doc-selection-overlay-clear'" in js diff --git a/tests/test_settings_shell_js_behavior.py b/tests/test_settings_shell_js_behavior.py index e353972a6..d1bb57635 100644 --- a/tests/test_settings_shell_js_behavior.py +++ b/tests/test_settings_shell_js_behavior.py @@ -14,6 +14,7 @@ import subprocess from pathlib import Path import pytest +from tests.helpers.stylesheets import app_css _REPO = Path(__file__).resolve().parent.parent @@ -22,11 +23,9 @@ _COORDINATOR_HELPER = ( _REPO / "tests" / "helpers" / "test_settings_shell_coordinator.mjs" ) _HAS_NODE = shutil.which("node") is not None -_STYLE = _REPO / "static" / "style.css" - - +_STYLE_TEXT = app_css() def test_settings_desktop_width_targets_settings_not_cookbook(): - source = _STYLE.read_text(encoding="utf-8") + source = _STYLE_TEXT settings_rule = re.search( r"(?ms)^\.settings-modal-content\s*\{[^}]*" diff --git a/tests/test_shared_tag_strips_static.py b/tests/test_shared_tag_strips_static.py index ba79b71ae..f58ee3b1b 100644 --- a/tests/test_shared_tag_strips_static.py +++ b/tests/test_shared_tag_strips_static.py @@ -1,8 +1,9 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8") CHIP_SCROLL = (ROOT / "static/js/chipScroll.js").read_text(encoding="utf-8") diff --git a/tests/test_sidebar_chat_list_sizing_static.py b/tests/test_sidebar_chat_list_sizing_static.py index aa923ba2c..488da68b9 100644 --- a/tests/test_sidebar_chat_list_sizing_static.py +++ b/tests/test_sidebar_chat_list_sizing_static.py @@ -1,11 +1,12 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent def test_sidebar_chat_list_uses_content_height_when_collapsed(): - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() desktop = css.split("#sessions-section #session-list {", 1)[1].split("}", 1)[0] mobile = css.split("#sessions-section #session-list {", 2)[2].split("}", 1)[0] diff --git a/tests/test_skill_deeplink_static.py b/tests/test_skill_deeplink_static.py index b391c6677..d7985c3b1 100644 --- a/tests/test_skill_deeplink_static.py +++ b/tests/test_skill_deeplink_static.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -102,7 +103,7 @@ def test_markdown_flattens_legacy_notes_more_details(): def test_markdown_expands_note_and_skill_more_links_without_details(): markdown_src = (ROOT / "static/js/markdown.js").read_text() - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert "function extractMoreListPayloads" in markdown_src assert "ody-more-(notes|skills|memories|events|sessions)" in markdown_src @@ -119,7 +120,7 @@ def test_markdown_expands_note_and_skill_more_links_without_details(): def test_terminal_skill_listing_uses_clickable_bounded_formatter(): agent_src = (ROOT / "src/agent_loop.py").read_text() - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert "_qwen_skills_terminal_summary = _skills_list_summary_from_tool_output(" in agent_src assert '.msg-ai .body a[href^="#skill-"]' in style_src @@ -127,7 +128,7 @@ def test_terminal_skill_listing_uses_clickable_bounded_formatter(): def test_list_links_share_compact_typography(): - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert 'a.chat-link[href^="#events-more-"]' in style_src assert "font-size: 11px;" in style_src @@ -145,7 +146,7 @@ def test_open_skills_also_persists_skill_list(): def test_memory_deeplink_scrolls_and_flashes_even_when_filtered(): memory_src = (ROOT / "static/js/memory.js").read_text() - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert "function _resetMemoryDeepLinkFilters()" in memory_src assert "activeCategory = 'all';" in memory_src @@ -158,7 +159,7 @@ def test_memory_deeplink_scrolls_and_flashes_even_when_filtered(): def test_skill_and_memory_links_use_polished_chat_link_style(): - style_src = (ROOT / "static/style.css").read_text() + style_src = app_css() assert '.msg-ai .body a[href^="#skill-"]' in style_src assert '.msg-ai .body a[href^="#memory-"]' in style_src diff --git a/tests/test_stream_completion_scroll_stability.py b/tests/test_stream_completion_scroll_stability.py index 752c107be..dc360a774 100644 --- a/tests/test_stream_completion_scroll_stability.py +++ b/tests/test_stream_completion_scroll_stability.py @@ -1,11 +1,12 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") UI = (ROOT / "static/js/ui.js").read_text(encoding="utf-8") -STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8") +STYLE = app_css() def test_terminal_and_canonical_renders_preserve_chat_scroll_anchor(): diff --git a/tests/test_stylesheet_test_hygiene.py b/tests/test_stylesheet_test_hygiene.py new file mode 100644 index 000000000..550a6c59a --- /dev/null +++ b/tests/test_stylesheet_test_hygiene.py @@ -0,0 +1,54 @@ +"""Tests must reason about the whole cascade, not one file of it. + +``static/style.css`` is being decomposed. A test that reads that file alone, +or builds a synthetic page linking only that file, silently loses every rule +that has moved: it keeps passing while covering less. Both mistakes existed +and are cheap to detect, so this fails on either. +""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SELF = Path(__file__).name + +# The helper module and the manifest/snapshot tests are about the stylesheet +# set itself, so naming the file is the point rather than a mistake. +ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"} + +_DIRECT_READ = re.compile(r'["\']static/style\.css["\']|"static"\s*/\s*"style\.css"') +_LONE_LINK = re.compile(r']*href="/static/style\.css') + + +def _test_sources(): + return [p for p in sorted((ROOT / "tests").glob("*.py")) if p.name not in ALLOWED] + + +def test_sources_are_discoverable() -> None: + """Guard the guard: a layout change must not make this vacuous.""" + assert len(_test_sources()) > 100 + + +def test_no_test_reads_style_css_as_the_whole_cascade() -> None: + offenders = [ + p.name for p in _test_sources() + if _DIRECT_READ.search(p.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + "read the cascade with tests.helpers.stylesheets.app_css() instead of " + f"static/style.css alone: {offenders}" + ) + + +def test_no_synthetic_page_links_style_css_alone() -> None: + offenders = [ + p.name for p in _test_sources() + if _LONE_LINK.search(p.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + "build synthetic pages with tests.helpers.stylesheets.stylesheet_link_tags() " + f"so they get every stylesheet index.html loads: {offenders}" + ) diff --git a/tests/test_theme_sidebar_mobile.py b/tests/test_theme_sidebar_mobile.py index 9830adcb9..fa0aa0c5e 100644 --- a/tests/test_theme_sidebar_mobile.py +++ b/tests/test_theme_sidebar_mobile.py @@ -1,11 +1,10 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css -STYLE = Path(__file__).resolve().parents[1] / "static" / "style.css" - - +STYLE_TEXT = app_css() def test_mobile_sidebar_uses_the_sidebar_theme_surface(): - css = STYLE.read_text() + css = STYLE_TEXT mobile_drawer = css[css.index("/* Sidebar overlays chat on mobile */"):css.index("/* Backdrop behind sidebar */")] assert "background: var(--sidebar-bg, var(--panel)) !important;" in mobile_drawer assert "background: var(--panel) !important;" not in mobile_drawer diff --git a/tests/test_toast_ui_polish.py b/tests/test_toast_ui_polish.py index ef6ff1c39..c94528d6d 100644 --- a/tests/test_toast_ui_polish.py +++ b/tests/test_toast_ui_polish.py @@ -1,6 +1,7 @@ """Regression coverage for transient toast polish and accurate welcome tips.""" from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parent.parent @@ -9,7 +10,7 @@ ROOT = Path(__file__).resolve().parent.parent def test_notification_history_does_not_clutter_navigation_or_capture_toasts(): html = (ROOT / "static/index.html").read_text(encoding="utf-8") ui = (ROOT / "static/js/ui.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert 'id="rail-notifications"' not in html assert 'id="sidebar-notifications-btn"' not in html @@ -32,7 +33,7 @@ def test_action_hint_is_part_of_action_button_and_close_is_grouped_beside_it(): def test_welcome_tips_are_plain_and_brief(): html = (ROOT / "static/index.html").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "Tip: Ctrl+K searches chats." in html assert "Tip: Ctrl+Alt+B toggles the sidebar." in html diff --git a/tests/test_tool_approval_frontend_routing.py b/tests/test_tool_approval_frontend_routing.py index 8b2f66d9a..3983b6330 100644 --- a/tests/test_tool_approval_frontend_routing.py +++ b/tests/test_tool_approval_frontend_routing.py @@ -1,5 +1,6 @@ from pathlib import Path import re +from tests.helpers.stylesheets import app_css def test_tool_approval_bypasses_polymorphic_send_button_actions(): @@ -22,7 +23,7 @@ def test_tool_approval_bypasses_polymorphic_send_button_actions(): def test_ask_user_card_has_no_close_button_and_chat_scale_text(): root = Path(__file__).resolve().parents[1] renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert "closeBtn.className = 'modal-close ask-user-close';" not in renderer assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" not in renderer @@ -64,7 +65,7 @@ def test_ask_user_card_has_no_close_button_and_chat_scale_text(): def test_scroll_bottom_button_uses_dropdown_caret_glyph(): root = Path(__file__).resolve().parents[1] html = (root / "static/index.html").read_text(encoding="utf-8") - styles = (root / "static/style.css").read_text(encoding="utf-8") + styles = app_css() assert 'class="scroll-nav-caret"' in html assert "▾" in html diff --git a/tests/test_tool_header_icons_static.py b/tests/test_tool_header_icons_static.py index 5479ea368..e9e6d2e87 100644 --- a/tests/test_tool_header_icons_static.py +++ b/tests/test_tool_header_icons_static.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] def test_tool_header_has_semantic_icons_for_live_and_saved_calls(): chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8") renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8") - css = (ROOT / "static/style.css").read_text(encoding="utf-8") + css = app_css() assert "manage_calendar" in renderer assert "manage_memory" in renderer diff --git a/tests/test_visual_research_mode.py b/tests/test_visual_research_mode.py index bdcd6da37..53c175c3c 100644 --- a/tests/test_visual_research_mode.py +++ b/tests/test_visual_research_mode.py @@ -5,6 +5,7 @@ import pytest from src.deep_research import CATEGORY_PROMPTS, _infer_research_category from src.tool_schemas import FUNCTION_TOOL_SCHEMAS from src.visual_report import _standard_visual_variant +from tests.helpers.stylesheets import app_css ROOT = Path(__file__).resolve().parents[1] @@ -20,7 +21,7 @@ def _tool_schema(name: str) -> dict: def test_visual_research_mode_is_removed_from_ui_prompt_and_tool_schema(): panel_source = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8") - style_source = (ROOT / "static/style.css").read_text(encoding="utf-8") + style_source = app_css() routes_source = (ROOT / "routes/research/research_routes.py").read_text(encoding="utf-8") category_schema = _tool_schema("trigger_research")["parameters"]["properties"]["category"] rounds_schema = _tool_schema("trigger_research")["parameters"]["properties"]["max_rounds"] From 03d55dfe3cb321d287ba687a61312ebd794451e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Tue, 29 Sep 2026 17:37:25 +0200 Subject: [PATCH 03/18] refactor(css): split style.css into ordered fragments static/style.css was 47,530 lines. It is now nine files under static/css/: tokens.css holds the :root custom properties, light theme and density classes, and style-part-01..08 carry the rest in their original order. Nothing was rewritten; every line moved verbatim and the order in index.html reproduces the original file byte for byte. Cut points are brace-depth zero and outside block comments, so no rule or comment is split. All 47,531 source lines are accounted for across the fragments. The computed-style baseline recorded before the split is reproduced exactly, which is the evidence that the cascade is unchanged rather than an argument that it should be. Three things the split broke and this fixes: - The harness self-test swapped two conflicting .attach-strip blocks inside style.css to prove the digest is order-sensitive. It rewrote one hardcoded URL, so with the file gone it silently measured an unmodified page. It now searches every stylesheet, rewrites whichever holds the pair, and fails loudly if none does. - Four tests asserted the cache-bust token by matching /static/style.css?v=. They check the real invariant now, that every app stylesheet shares one token with app.js, through stylesheet_cache_version(). - The three panel stylesheets carried their own tokens, so a browser could hold half an old cascade and half a new one. All app stylesheets now bust together. --- static/app.js | 2 +- static/css/style-part-01.css | 6012 ++ static/css/style-part-02.css | 6011 ++ static/css/style-part-03.css | 6010 ++ static/css/style-part-04.css | 6011 ++ static/css/style-part-05.css | 6011 ++ static/css/style-part-06.css | 6011 ++ static/css/style-part-07.css | 6012 ++ static/css/style-part-08.css | 5371 ++ static/css/tokens.css | 178 + static/index.html | 20 +- static/style.css | 47530 ---------------- static/sw.js | 20 +- tests/css_snapshot/capture.mjs | 25 +- tests/helpers/stylesheets.py | 22 + tests/test_compare_shuffle_visual_static.py | 5 +- tests/test_gallery_visual_static.py | 4 +- ...st_notes_mobile_checklist_toggle_static.py | 5 +- tests/test_research_history_expansion.py | 5 +- 19 files changed, 47709 insertions(+), 47556 deletions(-) create mode 100644 static/css/style-part-01.css create mode 100644 static/css/style-part-02.css create mode 100644 static/css/style-part-03.css create mode 100644 static/css/style-part-04.css create mode 100644 static/css/style-part-05.css create mode 100644 static/css/style-part-06.css create mode 100644 static/css/style-part-07.css create mode 100644 static/css/style-part-08.css create mode 100644 static/css/tokens.css delete mode 100644 static/style.css diff --git a/static/app.js b/static/app.js index 8c04bfd47..0555bf21e 100644 --- a/static/app.js +++ b/static/app.js @@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2 import tasksModule from './js/tasks.js?v=20260914taskmodel1'; import calendarModule from './js/calendar.js?v=20260914emailsource11'; import notesModule from './js/notes.js?v=20260911notesselectioncancel1'; -import adminModule from './js/admin.js?v=20260921chatgptusage2'; +import adminModule from './js/admin.js?v=20260929csssplit'; import settingsModule from './js/settings.js?v=20260912writingstyle3'; // Eagerly bind unified minimize/restore behavior across all tool modals. import './js/modalManager.js'; diff --git a/static/css/style-part-01.css b/static/css/style-part-01.css new file mode 100644 index 000000000..7e4250bcb --- /dev/null +++ b/static/css/style-part-01.css @@ -0,0 +1,6012 @@ +/* Fragment 2/9 of the former static/style.css. + * + * This is a mechanical, order-preserving slice: rules were moved verbatim + * and this file's position in the order of static/index.html is what + * keeps the cascade identical. Reordering the links, or moving a rule between + * fragments, can change which of two competing declarations wins. + * + * static/index.html and the PRECACHE list in static/sw.js must list every + * fragment in the same order. tests/test_static_stylesheet_manifest.py and + * tests/test_css_computed_style_snapshot.py both fail if that drifts. + */ +#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; } +.pdf-export-overlay .modal-content { max-height: 86vh; } + +/* ── Background Patterns ── */ + +:root { --bg-effect-intensity: 1; } + +/* Canvas-based effects — single source of truth for intensity */ +#synapse-canvas, #rain-canvas, #constellations-canvas, +#perlin-flow-canvas, #petals-canvas, #sparkles-canvas, +#embers-canvas, #starfield-depth-canvas, #ascii-fireflies-background { + opacity: var(--bg-effect-intensity, 1); +} + +body.bg-pattern-dots { + background-image: radial-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px); + background-size: 20px 20px; background-attachment: fixed; +} +body.bg-pattern-synapse { + /* CSS grid as base, canvas pulses overlay */ + background-image: linear-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px); + background-size: 24px 24px; background-attachment: fixed; +} +body.memory-disabled.bg-pattern-synapse { + background-image: none; +} +body.memory-disabled #synapse-canvas { + display: none; +} +body.bg-pattern-perlin-flow, +body.bg-pattern-petals, +body.bg-pattern-sparkles { + /* canvas-only backgrounds */ +} +body.bg-pattern-starfield-depth, +body.bg-pattern-ascii-fireflies { + /* rendered by their fixed background layer */ +} +#ascii-fireflies-background { + position: fixed; + inset: 0; + z-index: 0; + box-sizing: border-box; + margin: 0; + padding: 14px 16px; + overflow: hidden; + pointer-events: none; + white-space: pre; + color: var(--bg-effect-color, var(--fg)); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1; + letter-spacing: 0; +} + + +/* ── Layout ── */ + + /* Top bar — session meta + incognito, single row */ + .chat-top-bar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + z-index: 2; + padding: 5px 0 0; + min-height: 25px; + box-sizing: border-box; + } + .chat-top-bar::after { + content: none; + } + body.sidebar-collapsed.hamburger-left .chat-top-bar { + padding-left: 38px; + } + body.sidebar-collapsed.hamburger-right .chat-top-bar { + padding-right: 38px; + } + .incognito-indicator { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid var(--accent); + color: var(--accent); + cursor: pointer; + padding: 4px; + width: 26px; + height: 26px; + box-sizing: border-box; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s, left 0.15s; + z-index: 1; + opacity: 0.7; + } + body.sidebar-collapsed.hamburger-left .incognito-indicator { + left: 12px; + } + .incognito-indicator:hover { + opacity: 1; + background: color-mix(in srgb, var(--accent) 20%, transparent); + } + + .chat-new-btn { + position: absolute; + right: 7px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--fg); + opacity: 0.6; + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.08s, background 0.08s, left 0.15s, right 0.15s; + flex-shrink: 0; + z-index: 1; + } + .chat-new-btn:hover { + opacity: 1; + color: var(--accent); + } + /* Flip new-chat and incognito when sidebar is on the right */ + body:has(.sidebar.right-side) .chat-new-btn { + right: auto; + left: 12px; + } + body:has(.sidebar.right-side) .incognito-indicator { + left: auto; + right: 12px; + } + body.sidebar-collapsed.hamburger-right .incognito-indicator { + right: 42px; + } + + /* Session meta — sits at top of chat area, scrolls with content */ + .chat-meta-overlay { + position: absolute; + left: 44px; + right: 44px; + top: 50%; + transform: translateY(calc(-50% - 2px)); + font-size: 0.75em; + line-height: 1; + /* 70% mix keeps the chat title clearly above the WCAG AA 4.5:1 + contrast threshold (40% only reached ~2.8:1). */ + color: color-mix(in srgb, var(--fg) 70%, transparent); + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + pointer-events: none; + min-width: 0; + max-width: calc(100% - 88px); + overflow: hidden; + } + .chat-meta-overlay > * { + pointer-events: auto; + flex-shrink: 0; + } + .chat-meta-overlay #current-meta { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + min-width: 0; + flex: 0 1 auto; + max-width: min(56vw, 520px); + line-height: 1.25; + } + .chat-top-bar:has(.incognito-indicator:not([style*="display:none"]):not([style*="display: none"])) .chat-meta-overlay { + left: 54px; + right: 44px; + max-width: calc(100% - 98px); + } + body:has(.sidebar.right-side) .chat-top-bar:has(.incognito-indicator:not([style*="display:none"]):not([style*="display: none"])) .chat-meta-overlay { + left: 44px; + right: 54px; + } + .chat-meta-overlay:hover { + color: color-mix(in srgb, var(--fg) 75%, transparent); + } + /* Offline model rows */ + .models-row-offline { + opacity: 0.4; + pointer-events: none; + } + .models-row-offline .model-chat-btn { + pointer-events: auto; + } + /* Offline badge next to endpoint name */ + .endpoint-offline-badge { + color: var(--danger, #f44); + font-size: 0.8em; + margin-left: 4px; + opacity: 0.7; + } + .chat-meta-overlay:empty, + .chat-meta-overlay:not(:has(#current-meta:not(:empty))) { + display: none; + } + .export-dropdown-wrap { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin-left: -4px; + margin-right: -20px; + } + .export-dl-btn { + background: none; border: none; + color: inherit; + cursor: pointer; + padding: 4px 5px; border-radius: 4px; + display: flex; align-items: center; + transition: color 0.08s, background 0.08s; + } + .export-dl-btn > svg { color: var(--accent, var(--red)); } + .export-dl-btn:hover { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + } + .export-dropdown-menu { + display: none; + position: fixed; + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; padding: 4px; min-width: 120px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + z-index: 300; + color: var(--fg); + } + .export-dropdown-menu.open { display: block; } + .export-dropdown-item { + padding: 6px 8px; font-size: 11px; cursor: pointer; + border-radius: 6px; color: var(--fg); white-space: nowrap; + display: flex; align-items: center; gap: 10px; + transition: background 0.1s; + } +.export-dropdown-item:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); } +.export-settings-text-only .dropdown-icon { display: none; } + #export-compact-btn { + display: none !important; + } + #export-compact-btn .dropdown-icon { + display: none; + } + .export-compact-pill { + display: inline; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + line-height: inherit; + } + .export-dropdown-item .dropdown-icon { + width: 14px; height: 14px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; opacity: 0.5; + } + /* On mobile the chat-header export dropdown was cramped — items + were 11px font with 6px padding. Bump to readable touch targets: + larger min-width, taller rows, bigger icons + text. */ + @media (max-width: 768px) { + .export-dropdown-menu { + min-width: 200px; + padding: 6px; + border-radius: 10px; + } + .export-dropdown-item { + padding: 12px 14px; + font-size: 14px; + gap: 12px; + min-height: 44px; + } + #export-compact-btn { + margin: 5px 6px 7px; + min-height: 38px; + padding: 9px 14px; + } + .export-dropdown-item .dropdown-icon { + width: 18px; height: 18px; opacity: 0.7; + } + .export-dropdown-item .dropdown-icon svg { + width: 18px; height: 18px; + } + } + .sidebar { + width: 240px; + background: var(--sidebar-bg, var(--panel)); + border-right: 1px solid var(--border); + transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + min-height: 0; + margin: 0; + padding: 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); + border: 1px solid color-mix(in srgb, var(--fg) 11%, transparent); + position: relative; + } + .sidebar-resize-handle { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 10; + background: transparent; + } + .sidebar-resize-handle:hover, + .sidebar-resize-handle.dragging { + background: var(--accent); + opacity: 0.6; + } + .sidebar.right-side .sidebar-resize-handle { + right: auto; + left: -3px; + } + .sidebar.resizing { + transition: none; + user-select: none; + } + .sidebar.right-side { + order: 2; + margin: 0; + border-right: none; + border-left: 1px solid var(--border); + } + .sidebar.hidden { + /* !important so it beats the inline width init.js restores from storage — + otherwise the width never changes and only opacity animates, making the + collapse look instant. With this, width animates from the inline value + down to 0 via the .sidebar width transition. */ + width: 0 !important; + padding: 0 !important; + border: none; + overflow: hidden; + opacity: 0; + } + /* ===== Sidebar User Bar ===== */ + .sidebar-user-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 12px; + flex-shrink: 0; + gap: 4px; + min-height: 48px; + } + + .user-bar-left { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + cursor: pointer; + padding: 6px 8px; + border-radius: 8px; + transition: background 0.15s; + } + + .user-bar-left:hover { + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + + .user-bar-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: color-mix(in srgb, var(--fg) 12%, transparent); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: var(--fg); + opacity: 0.7; + flex-shrink: 0; + text-transform: uppercase; + } + + #user-bar-avatar { position: relative; left: -4px; } + + .user-bar-name { + font-size: 9.75px; + font-weight: 500; + color: var(--fg); + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .user-bar-actions { + display: flex; + gap: 1px; + flex-shrink: 0; + } + + .user-bar-btn { + background: none; + border: none; + color: var(--fg); + opacity: 0.35; + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.12s, background 0.12s; + } + + .user-bar-btn:hover { + opacity: 1; + background: color-mix(in srgb, var(--fg) 8%, transparent); + } + + + .sidebar.hidden .sidebar-user-bar { + display: none; + } + + /* Sticky sidebar header — logo, never scrolls */ + .sidebar-header { + display: flex; + align-items: center; + justify-content: flex-end; /* right-align when sidebar is on left */ + gap: 8px; + padding: 15px 10px 0 40px; /* top padding aligns logo with fixed hamburger */ + flex-shrink: 0; + min-height: 40px; + border: none !important; + box-shadow: none !important; + position: relative; + z-index: 3; + background: var(--sidebar-bg, var(--panel)); + } + .sidebar-hamburger { + display: none !important; /* external #hamburger-btn is the only toggle */ + } + .sidebar-inner { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior-y: none; + scrollbar-width: none; + display: flex; + flex-direction: column; + gap: 0; + padding: 10px 8px 8px; + min-height: 0; + transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); + } + .sidebar-brand { + display: flex; + align-items: center; + flex-shrink: 0; + min-height: 24px; + } + .sidebar-brand-title { + font-size: 1rem; + font-weight: 600; + line-height: 1.35; + color: var(--brand-color, var(--red)); + white-space: nowrap; + user-select: none; + position: relative; + top: 0; + left: -10px; + } + .sidebar-sep { + display: none; + } + #sidebar-search-btn, + #sidebar-new-chat-btn { + margin: 0; + padding: 8px 8px; + } + #sessions-section { + margin-top: -1px; + min-height: 0; + flex: 0 0 auto; + } + /* Keep a large chat history from pushing the rest of the sidebar below + the fold. The chat rows scroll independently; the sections beneath + them stay reachable in the main sidebar scroll area. */ + #sessions-section #session-list { + max-height: min(42vh, 420px); + flex: 0 0 auto; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + min-height: 0; + } + #tools-section { + margin-top: 1px; + } + + @media (max-width: 768px) { + #sessions-section { + flex: 0 0 auto !important; + overflow: visible !important; + } + #sessions-section #session-list { + max-height: min(34vh, 300px) !important; + flex: 0 0 auto !important; + overflow-y: auto !important; + overflow-x: hidden !important; + } + } + #tools-section .list-item { + padding: 8px 8px; + } + .sidebar.right-side .sidebar-header { + justify-content: flex-start; + padding-left: 10px; + padding-right: 40px; + } + .sidebar.right-side .sidebar-inner { + padding: 8px; + } + .sidebar.right-side .sidebar-brand { + justify-content: flex-start; + padding: 2px 30px 4px 4px; + } + .sidebar.right-side .sidebar-brand-title { + margin-left: 10px; + } + /* Fixed hamburger — always visible, toggles sidebar */ + .hamburger-btn { + position: fixed; + top: 12px; + left: 9px; + right: auto; + z-index: 210; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--hamburger-color, var(--fg)); + cursor: pointer; + opacity: 0.5; + -webkit-tap-highlight-color: transparent; + outline: none; + transition: opacity 0.15s; + padding: 0; + display: flex; + } + body.hamburger-right .hamburger-btn { + left: auto; + right: 9px; + } + .mobile-new-chat-btn { + display: none; + } + .hamburger-btn:hover { + opacity: 1; + } + /* Icon rail — mini sidebar that replaces the wide .sidebar when it's + hidden (mutually exclusive — see sidebar-layout.js:57). Fullscreen + panels reserve this strip of width via `left: var(--icon-rail-w)` + so the rail stays visible without needing a z-index hack (which + used to cover the fixed-position hamburger button). */ + .icon-rail { + width: 48px; + flex-shrink: 0; + background: var(--panel); + border-right: 1px solid var(--border); + display: none; + flex-direction: column; + align-items: center; + padding: 48px 4px 8px 4px; + gap: 4px; + margin: 0; + position: relative; + box-sizing: border-box; + /* Allow hover labels (e.g. the rail-new-chat "New" tooltip) to extend + outside the 48px column. overflow:hidden was clipping them. */ + overflow: visible; + } + body.sidebar-collapsed:not(.hamburger-only) .icon-rail, + html.ody-sidebar-mini .icon-rail { + display: flex; + } + html.ody-sidebar-mini .sidebar, + html.ody-sidebar-off .sidebar { + width: 0 !important; + min-width: 0 !important; + opacity: 0; + overflow: hidden; + pointer-events: none; + padding: 0 !important; + border: 0 !important; + } + html.ody-sidebar-off .icon-rail { + display: none !important; + } + .rail-resize-handle { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 10; + background: transparent; + } + .rail-resize-handle:hover, + .rail-resize-handle.dragging { + background: var(--accent); + opacity: 0.6; + } + .icon-rail.right-side .rail-resize-handle { + right: auto; + left: -3px; + } + .icon-rail.right-side { + order: 2; + margin: 0; + border-right: none; + border-left: 1px solid var(--border); + } + .icon-rail-divider { + width: 24px; + height: 1px; + background: var(--border); + margin: 4px 0; + } + .icon-rail-btn { + position: relative; + width: 34px; + height: 34px; + border: none; + background: transparent; + color: var(--accent, var(--red)); + font-size: 16px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.5; + transition: opacity 0.08s, background 0.08s; + } + .rail-notes-badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: color-mix(in srgb, var(--accent) 85%, var(--bg)); + color: var(--bg); + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + box-sizing: border-box; + pointer-events: none; + box-shadow: 0 0 0 1px var(--bg); + } + .rail-notes-badge.fired { + background: var(--red); + animation: rail-notes-pulse 1.6s ease-in-out infinite; + } + @keyframes rail-notes-pulse { + 0%, 100% { box-shadow: 0 0 0 1px var(--bg), 0 0 0 0 color-mix(in srgb, var(--red) 50%, transparent); } + 50% { box-shadow: 0 0 0 1px var(--bg), 0 0 0 4px color-mix(in srgb, var(--red) 15%, transparent); } + } + /* Main sidebar notes button — dot when a reminder has fired */ + .tool-notes-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--red); + /* Match the Deep Research badge: right-aligned (auto) with the same + 4px left nudge, so both sidebar buttons' dots line up identically. */ + margin-left: auto; + position: relative; + left: -4px; + flex-shrink: 0; + align-self: center; + animation: rail-notes-pulse 1.6s ease-in-out infinite; + pointer-events: none; + } + /* Individual card — subtle accent border tint when a reminder has fired */ + .note-card.note-card-reminder-due .note-card-reminder { + background: color-mix(in srgb, var(--red) 22%, transparent); + color: var(--red); + font-weight: 600; + } + .icon-rail-btn:hover { opacity: 1; background: color-mix(in srgb, var(--accent) 12%, transparent); } + .icon-rail-btn.active-section { opacity: 1; background: color-mix(in srgb, var(--color-accent) 15%, transparent); } + /* Unified "minimized" indicator for any rail/sidebar button whose modal is held open */ + .rail-minimized { position: relative; } + .rail-minimized::after { + content: ''; position: absolute; + /* Default for inline spans like #email-section-title — sit just to the + right of the element so it never overlaps the icon. */ + top: 50%; right: -10px; transform: translateY(-50%); + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent, var(--red)); + box-shadow: 0 0 0 2px var(--bg); + pointer-events: none; + animation: rail-min-pulse 2s ease-in-out infinite; + } + /* Compact icon-rail buttons: top-right corner of the icon */ + .icon-rail-btn.rail-minimized::after { top: 4px; right: 4px; transform: none; } + /* Sidebar list-items are wider — anchor right-aligned vertically centered */ + .list-item.rail-minimized::after { top: 50%; right: 8px; transform: translateY(-50%); } + #tool-memory-btn.rail-minimized::after { right: 12px; } + /* Per-user nudge: the listed tools' minimized dot sits 2px further + in from the right edge so it doesn't look glued to the border. */ + #tool-theme-btn.rail-minimized::after, + #tool-tasks-btn.rail-minimized::after, + #tool-notes-btn.rail-minimized::after, + #tool-library-btn.rail-minimized::after, + #tool-gallery-btn.rail-minimized::after, + #tool-compare-btn.rail-minimized::after, + #tool-calendar-btn.rail-minimized::after { right: 12px; } + @media (max-width: 768px) { + /* On mobile the list-items are taller (touch-sized), so the tabbed-down + pulsing dot reads a hair low and right. Email uses its own dot and is + already aligned — only the tool list-item dots need the nudge: + 4px left (right 8 → 12) and 2px up. */ + .list-item.rail-minimized::after { + right: 13px; + transform: translateY(calc(-50% - 2px)); + } + #tool-memory-btn.rail-minimized::after { right: 17px; } + } + @keyframes rail-min-pulse { + 0%,100% { opacity: 1; } + 50% { opacity: 0.4; } + } + /* Compact minimize button for modal headers — matches the close + button's round control so the two read as a paired set. The + header's h4 carries margin-right:auto, which groups minimize + close + on the right; this button just needs a small gap before close. */ + .modal-minimize-btn { + background: transparent; + color: color-mix(in srgb, var(--fg) 58%, transparent); + border: 1px solid transparent; + cursor: pointer; + width: 24px; + height: 24px; + padding: 0; + margin-left: 0; + margin-right: 4px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; + } + .modal-minimize-btn:hover { + background: color-mix(in srgb, var(--fg) 10%, transparent); + border-color: color-mix(in srgb, var(--fg) 12%, transparent); + color: var(--fg); + } + .modal-minimize-btn svg { + transform: translateY(2px); + } + .modal.modal-minimized { display: none !important; } + /* Window tile snap ghost (desktop only) */ + #tile-ghost { + position: fixed; pointer-events: none; z-index: 9000; + background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + border: 2px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + border-radius: 8px; + opacity: 0; transform: scale(0.96); + transition: left 0.12s ease, top 0.12s ease, width 0.12s ease, height 0.12s ease, opacity 0.12s, transform 0.12s; + } + #tile-ghost.visible { opacity: 1; transform: scale(1); } + /* Bottom dock — chip per minimized modal */ + #minimized-dock { + position: fixed; bottom: var(--composer-clearance, 12px); left: 50%; transform: translateX(-50%); + display: flex; gap: 6px; flex-wrap: wrap; + max-width: calc(100vw - 24px); + padding: 4px; + z-index: 100; + pointer-events: none; + } + .minimized-dock-chip { + pointer-events: auto; + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 8px 6px 10px; + background: var(--panel, var(--bg)); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--fg); font-family: inherit; font-size: 12px; + cursor: grab; touch-action: none; user-select: none; + box-shadow: 0 4px 14px rgba(0,0,0,0.35); + transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.15s, border-color 0.15s; + animation: dock-chip-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both; + } + .minimized-dock-chip:hover { + background: color-mix(in srgb, var(--accent, var(--red)) 12%, var(--panel, var(--bg))); + border-color: color-mix(in srgb, var(--accent, var(--red)) 40%, var(--border)); + } + .minimized-dock-chip:active { cursor: grabbing; } + .minimized-dock-chip.dragging { + cursor: grabbing; z-index: 1000; + box-shadow: 0 8px 24px rgba(0,0,0,0.55); + transition: none; + opacity: 0.95; + } + /* Whole-dock drag (grabbed an edge chip) */ + #minimized-dock.dock-dragging { cursor: grabbing; } + #minimized-dock.dock-dragging .minimized-dock-chip { + transition: none; + box-shadow: 0 8px 24px rgba(0,0,0,0.45); + } + /* Subtle visual cue that edge chips drag the whole dock */ + #minimized-dock .minimized-dock-chip:first-child:not(:only-child), + #minimized-dock .minimized-dock-chip:last-child:not(:only-child) { + box-shadow: 0 4px 14px rgba(0,0,0,0.35), + inset 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 25%, transparent); + } + .minimized-dock-chip svg { opacity: 0.7; flex-shrink: 0; } + .minimized-dock-label { white-space: nowrap; } + + /* Mobile: chips are icon-only round pills. Tap to restore, drag toward + the trash zone at top-center to close. touch-action:none lets the + pointermove listener claim the gesture instead of the page scroll. */ + @media (max-width: 768px) { + .minimized-dock-chip { + width: 40px; height: 40px; + padding: 0 !important; + border-radius: 50% !important; + justify-content: center; + position: relative; + overflow: visible; + touch-action: none; + } + .minimized-dock-chip svg { width: 18px; height: 18px; opacity: 0.9; } + .minimized-dock-label, + .minimized-dock-x { display: none !important; } + .minimized-dock-chip.chip-free-drag { + box-shadow: 0 8px 22px rgba(0,0,0,0.55), + 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 50%, transparent) !important; + } + /* Long-press hint — chip swells and settles while the detach timer + counts down so the user feels feedback before the bubble peels out + of the chain. Returns to scale(1) at the end so there's no visual + jump when the timer fires and the chip becomes a free-drag puck. */ + .minimized-dock-chip.chip-long-press { + background: + radial-gradient(circle at 30% 25%, color-mix(in srgb, #fff 28%, transparent), transparent 36%), + linear-gradient(135deg, + color-mix(in srgb, var(--accent, var(--red)) 34%, var(--panel, var(--bg))), + color-mix(in srgb, #7dd3fc 26%, var(--panel, var(--bg))) 52%, + color-mix(in srgb, #f0abfc 22%, var(--panel, var(--bg)))); + border-color: color-mix(in srgb, var(--accent, var(--red)) 72%, #fff 12%) !important; + animation: chip-long-press-pulse 0.82s ease-in-out infinite; + z-index: 10030; + } + .minimized-dock-chip.chip-long-press::before { + content: ''; + position: absolute; + inset: -96px; + border-radius: inherit; + background: + radial-gradient(circle, + color-mix(in srgb, var(--accent, var(--red)) 42%, transparent) 0 18%, + color-mix(in srgb, #7dd3fc 34%, transparent) 34%, + color-mix(in srgb, #f0abfc 30%, transparent) 50%, + transparent 72%); + pointer-events: none; + z-index: -1; + animation: chip-long-press-ripple 0.82s ease-out infinite; + } + @keyframes chip-long-press-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.42); + box-shadow: 0 14px 42px rgba(0,0,0,0.66), + 0 0 0 16px color-mix(in srgb, var(--accent, var(--red)) 42%, transparent), + 0 0 72px color-mix(in srgb, #7dd3fc 44%, transparent), + 0 0 118px color-mix(in srgb, #f0abfc 32%, transparent); } + } + @keyframes chip-long-press-ripple { + 0% { opacity: 0.92; transform: scale(0.08); } + 72% { opacity: 0.28; transform: scale(3.6); } + 100% { opacity: 0; transform: scale(5.4); } + } + /* Chip whose modal is currently open — accent ring so the user can + tell at a glance which floating bubble belongs to the visible + modal. Tap it to minimize. */ + .minimized-dock-chip.chip-active { + border-color: var(--accent, var(--red)) !important; + box-shadow: 0 4px 14px rgba(0,0,0,0.35), + 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); + } + .minimized-dock-chip.chip-active svg { opacity: 1; } + } + /* Magnetic close zone — slides in from the side opposite the chip's + starting position so it never overlaps the dock. Always accent + color, larger than the chips, snappy spring transition. */ + #dock-trash-zone { + position: fixed; + left: 50%; + width: 88px; height: 88px; + border-radius: 50%; + --trash-zone-color: var(--accent, var(--red, #e53935)); + background: var(--trash-zone-color); + color: #fff; + display: flex; align-items: center; justify-content: center; + pointer-events: none; + opacity: 0; + z-index: 9000; + box-shadow: 0 8px 28px color-mix(in srgb, var(--trash-zone-color) 55%, transparent); + transition: opacity 0.18s ease, + transform 0.26s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.18s ease; + } + /* Off-screen start position depends on which side the chip is on so + the X slides in from the opposite edge with a snappy overshoot. */ + #dock-trash-zone[data-side="top"] { transform: translateX(-50%) translateY(-180%) scale(0.7); } + #dock-trash-zone[data-side="bottom"] { transform: translateX(-50%) translateY(180%) scale(0.7); } + #dock-trash-zone.visible { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } + #dock-trash-zone.engaged { + transform: translateX(-50%) translateY(0) scale(var(--trash-zone-scale, 1.22)); + box-shadow: 0 0 0 14px color-mix(in srgb, var(--trash-zone-color) 22%, transparent), + 0 12px 36px color-mix(in srgb, var(--trash-zone-color) 60%, transparent); + animation: trash-zone-breathe 1.35s ease-in-out infinite; + } + /* Whirlpool ring — fades in when chip is in capture range and spins + continuously, then bursts on drop. */ + #dock-trash-zone .whirlpool { + position: absolute; inset: -8px; + border-radius: 50%; + border: 2px solid transparent; + border-top-color: color-mix(in srgb, var(--trash-zone-color) 95%, transparent); + border-right-color: color-mix(in srgb, var(--trash-zone-color) 65%, transparent); + border-bottom-color: color-mix(in srgb, var(--trash-zone-color) 30%, transparent); + opacity: 0; + pointer-events: none; + animation: whirlpool-spin 0.85s linear infinite; + transition: opacity 0.15s ease; + } + #dock-trash-zone.engaged .whirlpool { opacity: 1; } + #dock-trash-zone.dropping .whirlpool { + opacity: 1; + animation: whirlpool-burst 0.36s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } + /* When a chip is swirling into the trash X, its inline `rotate(720deg)` + drags every child + ::after badge along with it — the count/dot pill + spinning looks chaotic. Fade those out fast at the start of the close + so visually only the icon glyph rotates. */ + .minimized-dock-chip.chip-trashing > :not(svg), + .minimized-dock-chip.chip-trashing::after, + .minimized-dock-chip.chip-trashing::before { + opacity: 0 !important; + transition: opacity 0.16s ease-out !important; + } + @keyframes whirlpool-spin { to { transform: rotate(360deg); } } + @keyframes trash-zone-breathe { + 0%, 100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--trash-zone-color) 16%, transparent), 0 10px 30px color-mix(in srgb, var(--trash-zone-color) 48%, transparent); } + 50% { box-shadow: 0 0 0 16px color-mix(in srgb, var(--trash-zone-color) 25%, transparent), 0 14px 38px color-mix(in srgb, var(--trash-zone-color) 68%, transparent); } + } + @keyframes whirlpool-burst { + 0% { transform: rotate(0deg) scale(1); opacity: 1; } + 60% { transform: rotate(540deg) scale(1.5); opacity: 0.6; } + 100% { transform: rotate(900deg) scale(2.2); opacity: 0; } + } + + /* Email chip badges: + - email-lib-modal chip: "1" badge when an email is expanded + inside (JS sets data-has-expanded). + - email-reader-* chips: auto-numbered 1, 2, 3 … via CSS counter, + so multiple opened-email windows are visually distinguishable. */ + .minimized-dock-chip[data-modal-id="email-lib-modal"], + .minimized-dock-chip[data-modal-id^="email-reader-"] { + position: relative; + } + .minimized-dock-chip[data-modal-id^="email-reader-"][data-tab-num]::after { + content: attr(data-tab-num); + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: var(--accent, var(--red)); + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 16px; + text-align: center; + border-radius: 8px; + box-shadow: 0 0 0 2px var(--bg); + pointer-events: none; + } + .minimized-dock-chip[data-modal-id="email-lib-modal"][data-email-unread-label]::after { + content: attr(data-email-unread-label); + position: absolute; + top: -6px; + right: 10px; + height: 16px; + padding: 0 6px; + background: var(--accent, var(--red)); + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 16px; + white-space: nowrap; + border-radius: 8px; + box-shadow: 0 0 0 2px var(--bg); + pointer-events: none; + } + + .minimized-dock-x { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; border-radius: 50%; + font-size: 14px; line-height: 1; opacity: 0.4; + margin-left: 3px; + } + .minimized-dock-x:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 12%, transparent); } + @keyframes dock-chip-in { + from { opacity: 0; transform: translateY(20px) scale(0.85); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .rail-new-chat svg { transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } + .rail-new-chat:hover svg { transform: rotate(90deg); } + /* Sleek hover labels for the mini sidebar. Use a real child span instead + of ::before/::after so status dots and minimized indicators keep working. */ + .rail-hover-label { + position: absolute; + left: 100%; + top: 50%; + margin-left: 6px; + transform: translateY(-50%) translateX(-6px); + opacity: 0; + pointer-events: none; + white-space: nowrap; + font-size: 10px; + font-weight: 600; + color: var(--fg); + background: var(--panel); + padding: 2px 6px; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + z-index: 20; + transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .icon-rail-btn:hover .rail-hover-label { + opacity: 1; + transform: translateY(-50%) translateX(0); + } + #rail-admin, + #rail-settings { color: var(--fg); } + .rail-separator { width: 20px; height: 1px; background: var(--border); margin: 4px auto; } + .rail-dynamic { + position: relative; + } + .rail-dynamic::after { + content: ''; + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent, var(--red)); + opacity: 0.7; + } + /* ── Sidebar sections (flat layout) ── */ + .section { padding: 0; border: none; background: none; border-radius: 0; box-shadow: none; margin: 0; } + + /* Section header row — identical sizing to .list-item */ + .section-header-flex { + display: flex; align-items: center; gap: 6px; + padding: 8px 8px; margin: 1px 0; border-radius: 4px; + background: transparent; cursor: pointer; + transition: background 0.08s; + height: 29px; + box-sizing: border-box; + } + .section-header-flex:hover { background: color-mix(in srgb, var(--red) 8%, transparent); } + .section-header-flex::after { content: none; } + .section-header-flex h4::before { content: none; } + + /* Section title text — same font as .list-item .grow */ + .section-header-flex h4, + .section-header-flex .section-title { + flex: 1; cursor: pointer; user-select: none; + display: flex; align-items: center; gap: 6px; + margin: 0; padding: 0; border: none; + font-size: 10px; font-weight: 400; font-family: inherit; + /* 1.3 (not 1) so Fira Code's tall glyph box isn't vertically clipped in + Chromium/Edge — mirrors the .list-item fix. The title is flex-centred + in a fixed-height (29px) header, so this adds headroom without reflow. */ + line-height: 1.3; letter-spacing: 0; text-transform: none; + color: var(--fg); + } + .section-icon, + .sidebar-action-icon { + flex-shrink: 0; + stroke: var(--accent, var(--red)); + position: relative; + left: -1px; + color: var(--accent, var(--red)); + } + /* Shared notification dot for sidebar section titles. Single source of + truth so chats / email / assistant / future-section dots all sit at + the same offset from their label. */ + .sidebar-notif-dot { + display: inline-block; + width: 6px; + height: 6px; + /* Push to the right edge of the flex section-title so chats / email + / assistant dots all line up vertically in the same column + instead of trailing right after each (differently-sized) label. */ + margin-left: auto; + border-radius: 50%; + background: var(--accent, var(--red)); + flex-shrink: 0; + vertical-align: middle; + } + /* The email notification gets a soft breathing glow so new-mail catches + the eye without being shouty. Vertical alignment stays with the + inherited .sidebar-notif-dot rule so it lines up with chats/assistant. */ + #email-unread-dot.sidebar-notif-dot { + animation: email-notif-breathe 2.2s ease-in-out infinite; + /* Nudge in from the far-right edge so it doesn't crowd the corner. */ + margin-right: 4px; + /* Tiny vertical nudge to center with the email label. */ + position: relative; + top: 0.1px; + } + @media (prefers-reduced-motion: reduce) { + #email-unread-dot.sidebar-notif-dot { animation: none; } + } + @media (max-width: 768px) { + /* Nudge the sidebar notification dot up 1px on mobile so it lines + up with the bigger section titles. vertical-align:middle drifts + a hair low against the larger touch-friendly text. */ + .sidebar-notif-dot { + position: relative; + top: -1px; + } + /* Cookbook's sidebar status dot carries an inline top:-1px, so override + with the ID + !important to nudge it 2px left / 1px up on mobile. */ + #cookbook-notif-dot { + left: -1px !important; + top: -2px !important; + } + } + #sidebar-new-chat-btn svg { + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + } + #sidebar-new-chat-btn:hover svg { + transform: rotate(90deg); + } + + /* Sort/select buttons in header — compact */ + .section-header-flex > div { display: flex; align-items: center; gap: 2px; } + .section-header-btn { + all: unset; + cursor: pointer; opacity: 0.4; padding: 1px 3px; border-radius: 4px; + display: inline-flex; align-items: center; justify-content: center; + transition: opacity 0.08s, background 0.08s; + } + .section-header-btn:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 7%, transparent); } + .section-header-btn.active { opacity: 0.9; color: var(--accent); } + .section-header-btn svg { width: 12px; height: 12px; } + #session-sort-btn { transform: translateX(2px); } + + /* Chats library — grid icon, hover-reveal so the header only toggles + collapse. Uses !important to win over .list-item-plus-btn's + opacity:1!important (the email-style plus button forces always-on, + which we don't want here — the manage button should fade until the + user actually hovers the section). */ + #sessions-section .chats-manage-btn { + opacity: 0.4 !important; + transition: opacity 0.12s, background 0.08s; + } + #sessions-section .section-header-flex:hover .chats-manage-btn, + #sessions-section .chats-manage-btn:hover, + #sessions-section .chats-manage-btn:focus-visible { + opacity: 0.85 !important; + } + #sessions-section .chats-manage-btn:hover, + #sessions-section .chats-manage-btn:focus-visible { + opacity: 1 !important; + } + /* No hover background on the Chats manage button — the inherited + section-header-btn rule was painting a tinted box behind the + 'manage' label as it slid in. */ + #sessions-section .chats-manage-btn, + #sessions-section .chats-manage-btn:hover, + #sessions-section .chats-manage-btn:focus-visible { + background: transparent !important; + } + @media (hover: none) { + #sessions-section .chats-manage-btn { opacity: 0.35 !important; } + #sessions-section .chats-manage-btn:active { opacity: 1 !important; } + } + /* Pull the 'manage' label out of email's absolute-positioning trick + so it sits in the flex flow and expands the button's clickable + area as it slides in. Otherwise hovering the visible 'manage' + text would actually be outside the button's bounding box. */ + #sessions-section .chats-manage-btn .list-item-plus-label { + position: static; + transform: none; + max-width: 0; + overflow: hidden; + padding-right: 0; + transition: max-width 0.2s ease, opacity 0.18s ease, padding-right 0.18s ease; + } + #sessions-section .chats-manage-btn:hover .list-item-plus-label, + #sessions-section .chats-manage-btn:focus-visible .list-item-plus-label, + #sessions-section .section-header-flex:hover .chats-manage-btn .list-item-plus-label { + max-width: 80px; + padding-right: 4px; + } + + /* Collapse chevron */ + .section-collapse-btn { + all: unset; + cursor: pointer; display: inline-flex; align-items: center; padding: 0 2px; border-radius: 4px; + } + .section-collapse-btn:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } + .section-collapse-chevron { display: inline-flex; opacity: 0.4; transition: transform 0.2s, opacity 0.15s; } + .section-collapse-btn:hover .section-collapse-chevron { opacity: 0.6; } + .section.collapsed .section-collapse-chevron { transform: rotate(-90deg); opacity: 0.4; } + .section-header-flex:has(.section-header-btn) .section-collapse-btn { display: none; } + .section.collapsed .section-collapse-btn { display: inline-flex !important; } + + /* Collapsed state */ + .section.collapsed > *:not(h4):not(.section-title):not(.section-header-flex) { display: none !important; } + .section.collapsed .section-header-flex { margin-bottom: 0; } + .section.collapsed .section-header-btn { display: none; } + .section.collapsed { cursor: pointer; } + + /* Domino expand: every time a section goes from .collapsed → open + (toggleCollapse in section-management.js adds .section-just-expanded + for ~700ms), the .list-item children cascade in one after another, + same feel as the chat input's tools menu. Each row springs in + from a tiny offset below + scaled-down, staggered by nth-child. */ + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header) { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; + } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(1) { animation-delay: 0.04s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(2) { animation-delay: 0.08s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(3) { animation-delay: 0.12s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(4) { animation-delay: 0.16s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(5) { animation-delay: 0.20s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(6) { animation-delay: 0.24s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(7) { animation-delay: 0.28s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(8) { animation-delay: 0.32s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(9) { animation-delay: 0.36s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(10) { animation-delay: 0.40s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(11) { animation-delay: 0.44s; } + .section.section-just-expanded :is(.list-item, .models-row, .date-section-header):nth-child(12) { animation-delay: 0.48s; } + @keyframes section-domino-in { + 0% { opacity: 0; transform: translateY(8px) translateX(-4px) scale(0.92); } + 60% { opacity: 1; } + 100% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + } + + /* Domino collapse: when toggleCollapse goes open → closed, JS adds + .section-just-collapsing for ~530ms before flipping in .collapsed. + During that window the items fade/slide DOWN one after another so + you see them peel off instead of vanishing as a block. Uses + nth-last-child so the BOTTOM item leaves first and the cascade + rolls upward — mirrors the "stacked deck" feeling of the open + animation reversed. */ + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header) { + animation: section-domino-out 0.22s ease-in forwards; + } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(1) { animation-delay: 0.00s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(2) { animation-delay: 0.025s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(3) { animation-delay: 0.05s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(4) { animation-delay: 0.075s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(5) { animation-delay: 0.10s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(6) { animation-delay: 0.125s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(7) { animation-delay: 0.15s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(8) { animation-delay: 0.175s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(9) { animation-delay: 0.20s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(10) { animation-delay: 0.225s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(11) { animation-delay: 0.25s; } + .section.section-just-collapsing :is(.list-item, .models-row, .date-section-header):nth-last-child(12) { animation-delay: 0.275s; } + @keyframes section-domino-out { + 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); } + } + @keyframes spin { to { transform: rotate(360deg); } } + .row { display:flex; gap:6px; align-items:center; } + .list-item:hover, + .models-row:hover { + background: color-mix(in srgb, var(--red) 8%, transparent); + border-color: var(--red); + } + #models.models-refreshing { + opacity: 0.72; + transition: opacity 0.16s ease; + } + /* Disabled tool — dimmed to signal its feature is turned off globally */ + .list-item.tool-disabled { + opacity: 0.4; + } + .list-item.tool-disabled:hover { + opacity: 0.7; + } + /* Session bulk select mode */ + .session-bulk-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--border); + background: var(--sidebar-bg, var(--panel)); + position: sticky; + top: 0; + z-index: 3; + font-size: 11px; + } + .session-bulk-bar.hidden { display: none; } + #session-select-all-dot { + display: inline-block; + position: relative; + top: -2px; + } + .session-bulk-btn { + background: none; + border: none; + border-radius: 4px; + color: var(--fg); + padding: 4px; + font-family: inherit; + cursor: pointer; + opacity: 0.4; + transition: opacity 0.1s; + display: inline-flex; + align-items: center; + justify-content: center; + } + .session-bulk-btn:hover { opacity: 1; } + .session-bulk-btn-danger { color: var(--red); opacity: 0.5; } + .session-bulk-btn-danger:hover { opacity: 1; } + /* Checkbox in select mode */ + .session-select-cb { + accent-color: var(--accent, var(--red)); + margin: 0 4px 0 0; + flex-shrink: 0; + cursor: pointer; + } + + .session-icon, + .model-icon { + flex-shrink: 0; + opacity: 0.35; + display: inline-flex; + align-items: center; + } + .session-icon.has-docs { + color: inherit; + opacity: 0.5; + } + .session-star { + width: 10px; height: 10px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, var(--fg) 22%, transparent); + flex-shrink: 0; + position: relative; + } + .session-star.processing { + animation: none; + } + .session-star.provider-logo.processing svg { + animation: none; + } + .session-star.notify { + animation: none; + opacity: 1; + /* Completed runs keep the provider slot but turn it into a compact + success check, so the logo does not simply appear to vanish. */ + width: 14px; height: 14px; + background: var(--color-success, #4caf50); + border-color: var(--color-success, #4caf50); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + } + /* The dot doubles as the provider-logo holder; hide that logo in the + done state so the solid notif dot doesn't collide with the SVG behind it. */ + .session-star.notify svg { display: none; } + .session-star.notify::after { + content: '\2713'; + font-family: system-ui, sans-serif; + font-size: 10px; + font-weight: 700; + line-height: 1; + transform: translateY(-0.5px); + } + .session-fav { + flex-shrink: 0; + cursor: pointer; + color: var(--red); + opacity: 0.6; + display: inline-flex; + align-items: center; + transition: opacity 0.1s; + } + .session-fav:hover { + opacity: 1; + } + .list-item.active-session { + background: color-mix(in srgb, var(--red) 10%, transparent); + border-color: var(--red); + border-left-color: var(--red); + } + .list-item .provider-logo { opacity: 0.4 !important; } + .list-item:has(.session-star.processing) .provider-logo { opacity: 1 !important; } + .list-item.stream-complete .provider-logo { opacity: 1 !important; } + .list-item.active { + background: color-mix(in srgb, var(--red) 10%, transparent); + border-color: var(--red); + border-left-color: var(--red); + } + /* Only show the red focus ring for keyboard navigation (Tab). Mouse + clicks shouldn't leave a sticky red outline on a sidebar item. */ + .list-item:focus { outline: none; } + .list-item:focus-visible { + outline: none; + border-color: var(--red, var(--color-error)); + box-shadow: 0 0 0 1px var(--red, var(--color-error)); + } + /* Drag handles hidden by default, shown via body.rearrange-mode */ + .drag-handle, .item-drag-handle, .folder-drag-handle { display: none; } + body.rearrange-mode .drag-handle, + body.rearrange-mode .item-drag-handle, + body.rearrange-mode .folder-drag-handle { display: inline; } + + /* Drag sorting styles for list items */ + .item-drag-handle { + cursor: grab; + opacity: 0.4; + font-size: 10px; + padding: 0 4px; + user-select: none; + letter-spacing: -2px; + transition: opacity 0.15s; + } + .item-drag-handle:hover { + opacity: 1; + color: var(--red); + } + .item-drag-handle:active { + cursor: grabbing; + } + .list-item.dragging, + .models-row.dragging, + .session-folder.dragging { + opacity: 0.95; + box-shadow: 0 8px 24px color-mix(in srgb, var(--red) 30%, transparent); + border-color: var(--red); + background: var(--panel); + cursor: grabbing; + } + .session-item.dragging-to-folder { + opacity: 0.55; + border-color: var(--red); + box-shadow: 0 4px 14px color-mix(in srgb, var(--red) 22%, transparent); + } + .list-item.dragging .item-drag-handle, + .models-row.dragging .item-drag-handle, + .session-folder.dragging .folder-drag-handle { + opacity: 1; + color: var(--red); + } + /* ── Model category grouping ── */ + .models-category-header { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; cursor: pointer; + font-size: 0.85em; font-weight: 600; + color: color-mix(in srgb, var(--fg) 55%, transparent); + border-radius: 4px; user-select: none; + margin-top: 4px; + transition: opacity 0.08s, background 0.08s, color 0.08s; + } + .models-category-header:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 4%, transparent); } + .models-category-header .folder-count { font-weight: 400; opacity: 0.5; font-size: 0.9em; } + .models-endpoint-label { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; cursor: pointer; + font-size: 0.85em; font-weight: 600; + color: color-mix(in srgb, var(--fg) 55%, transparent); + border-radius: 4px; user-select: none; + transition: opacity 0.08s, background 0.08s; + } + .models-endpoint-label:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 4%, transparent); } + .models-endpoint-label .folder-count { font-weight: 400; opacity: 0.5; } + .models-group-content.indented { + padding-left: 4px; + } + .models-group-content.indented .models-row { border-bottom: 1px solid color-mix(in srgb, var(--fg) 7%, transparent); } + .models-group-content.indented .models-row:last-child { border-bottom: none; } + /* ── Session folders ── */ + .session-folder { margin: 2px 0; } + .session-folder-header { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; cursor: pointer; + font-size: 0.88em; font-weight: 600; + color: color-mix(in srgb, var(--fg) 55%, transparent); + border-radius: 4px; user-select: none; + transition: opacity 0.08s, background 0.08s; + } + .session-folder-header:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 4%, transparent); } + .session-folder-header .folder-count { font-weight: 400; opacity: 0.5; } + .session-folder-header.drag-over { + outline: 2px dashed var(--red); background: color-mix(in srgb, var(--red) 13%, transparent); opacity: 1; + } + .session-folder.dragging-folder { opacity: 0.4; } + .folder-toggle { font-size: 0.7em; width: 10px; text-align: center; } + .folder-name { font-weight: inherit; flex: 1; } + .folder-count { font-size: 0.85em; opacity: 0.5; } + .folder-drag-handle { + cursor: grab; opacity: 0.3; font-size: 0.8em; padding: 0 2px; + transition: opacity 0.08s; + } + .session-folder-header:hover .folder-drag-handle { opacity: 0.7; } + .folder-delete-btn { + background: none; border: none; color: var(--fg); opacity: 0; + cursor: pointer; font-size: 1.1em; padding: 0 2px; line-height: 1; + min-height: 0; height: auto; + transition: opacity 0.08s, color 0.08s; + } + .session-folder-header:hover .folder-delete-btn { opacity: 0.5; } + .folder-delete-btn:hover { opacity: 1 !important; color: var(--color-error); } + .session-folder-content { padding-left: 4px; } + .session-folder-content .list-item { border-bottom: 1px solid color-mix(in srgb, var(--fg) 7%, transparent); } + .session-folder-content .list-item:last-child { border-bottom: none; } + .session-show-more-btn { + display: block; + width: 100%; + background: none; + border: none; + color: var(--fg); + opacity: 0.4; + font-size: 0.8em; + padding: 6px 8px; + cursor: pointer; + text-align: center; + transition: opacity 0.15s; + } + .session-show-more-btn:hover { opacity: 0.8; } + .drag-folder-placeholder { + height: 4px; margin: 2px 0; border-radius: 2px; + background: var(--red); opacity: 0.6; + } + .unfiled-drop-zone { + min-height: 8px; border-radius: 4px; transition: all 0.15s; + } + .unfiled-drop-zone.drag-over { + min-height: 24px; outline: 2px dashed var(--red); + background: color-mix(in srgb, var(--red) 9%, transparent); + } + + /* Mobile swipe-to-delete */ + .swipe-delete-action { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 60px; + background: color-mix(in srgb, var(--color-error) 12%, transparent); + color: var(--color-error); + display: flex; + align-items: center; + justify-content: center; + border-radius: 0 4px 4px 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + cursor: pointer; + z-index: 1; + } + .swipe-delete-action::before { + content: ''; + display: block; + width: 18px; + height: 18px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%23ff4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 6 5 6 21 6'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + } + .swipe-delete-action:active { + background: color-mix(in srgb, var(--color-error) 22%, transparent); + } + .drag-placeholder { + background: color-mix(in srgb, var(--red) 10%, transparent); + border: 2px dashed color-mix(in srgb, var(--color-accent) 40%, transparent); + border-radius: 6px; + margin: 4px 0; + transition: height 0.15s ease; + } + .list-item, + .models-row { + display: flex; + gap: 6px; + align-items: center; + padding: 3px 8px; + margin: 0; + border-radius: 4px; + border: none; + line-height: 1.3; + font-size: 13px; + background: transparent; + transition: background 0.08s; + cursor: pointer; + touch-action: pan-y; + } + .list-item button { + margin: 0; + height: 24px; + padding: 0 8px; + font-size: 9px; + } + /* Inline "+" action on a tool/section row (Library → new document, + Email → compose). Sizing forced + min-* zeroed so the mobile + touch-target rule (44px) can't inflate it. Selector intentionally + NOT scoped to `.list-item` so the same class also styles buttons + sitting in `.section-header-flex` (e.g. #email-compose-btn). */ + .list-item-plus-btn { + all: unset; + box-sizing: border-box; + flex-shrink: 0; + position: relative; + left: 4px; + display: inline-flex !important; + align-items: center; + justify-content: center; + height: 14px !important; + min-height: 0 !important; + width: auto !important; + min-width: 0 !important; + padding: 0 5px !important; + border-radius: 4px; + color: var(--fg); + /* Always visible at rest. On hover (devices that have it) the + spins + 90° and "new" reveals to its right — neat expand affordance. */ + opacity: 1 !important; + cursor: pointer; + gap: 0; + overflow: visible; + z-index: 1; + transition: background 0.12s, color 0.12s, gap 0.18s ease; + } + .list-item-plus-btn svg.list-item-plus-icon { + width: 13px; height: 13px; + transition: transform 0.22s ease; + } + /* Legacy fallback for plus-btns without the `.list-item-plus-icon` class. */ + .list-item-plus-btn svg:not(.list-item-plus-icon) { width: 13px; height: 13px; } + /* Label is absolutely positioned to the LEFT of the icon so the + stays + put — it just appears beside it on hover instead of pushing it. The + button's intrinsic width remains the icon, so neighbours don't shift. */ + .list-item-plus-label { + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-50%); + padding-right: 5px; + opacity: 0; + pointer-events: none; + white-space: nowrap; + font-size: 9.5px; + line-height: 1; + letter-spacing: 0.02em; + transition: opacity 0.18s ease, transform 0.22s ease; + } + @media (hover: hover) { + .list-item-plus-btn:hover .list-item-plus-icon { + transform: rotate(90deg); + } + .list-item-plus-btn:hover .list-item-plus-label { + opacity: 1; + transform: translateY(-50%) translateX(0); + pointer-events: auto; + } + .list-item-plus-btn .list-item-plus-label { + transform: translateY(-50%) translateX(6px); + } + } + .list-item-plus-btn:hover { + background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + color: var(--accent, var(--red)); + } + .list-item span { + color: var(--fg); + font-size: 9.75px; + } + .grow { + flex: 1; + overflow: hidden; + text-overflow: clip; + white-space: nowrap; + } + input, textarea, button, select { + background:var(--bg); + color:var(--fg); + border:1px solid var(--border); + font-family:inherit; + padding:6.9px; + border-radius: 4px; + transition: background 0.08s, border-color 0.08s, color 0.08s, opacity 0.08s; + } + input:hover, textarea:hover, button:hover, select:hover { + border-color: var(--fg); + background-color: var(--panel); + } + :root.light input, + :root.light textarea, + :root.light button, + :root.light select { + background:#eaeaea; + color-scheme: light; + } + input[type="text"] { height:32px; width:100%; } + textarea { width:100%; min-height:32px; height:auto; max-height:30lh; overflow-y:auto; resize:none; } + button { height:32px; padding:0 10px; } + #chat-form button[type="submit"] { height:38px; } + select { + height:32px; + color-scheme: dark; + background-color: var(--select-bg); + color: var(--select-fg); + } + select option, + select optgroup { + background-color: var(--select-option-bg); + color: var(--select-option-fg); + } + select option:checked { + background-color: var(--select-option-active-bg); + color: var(--select-option-fg); + } + :root.light select { color-scheme: light; } + .chat-container { + flex:1; + display:flex; + flex-direction:column; + padding:0 16px; + overflow:hidden; + position:relative; + min-height:0; + min-width:0; + margin-top:8px; + margin-bottom: 0; + transition: margin-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1), + margin-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1); + } + .chat-meta { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-bottom:6px; } + .chat-history { + display:flex; + flex-direction:column; + flex:1; + overflow-y:auto; + overflow-x:hidden; + overscroll-behavior-y: none; + margin-bottom:8px; + white-space:normal; + min-height:0; + /* Streaming completion replaces the live turn with its persisted DOM. + We preserve the scroll position explicitly; native anchoring would + otherwise fight that replacement and create a visible bounce. */ + overflow-anchor: none; + --chat-max: 800px; + padding-left: max(0px, calc((100% - var(--chat-max)) / 2)); + padding-right: max(12px, calc((100% - var(--chat-max)) / 2 + 12px)); + } + .chat-history > * { + flex: 0 0 auto; + } + .chat-pull-refresh { + --pull-refresh-y: 0px; + --pull-refresh-progress: 0; + position: absolute; + top: 8px; + left: 50%; + z-index: 48; + width: 34px; + height: 34px; + display: none; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 94%, transparent); + box-shadow: 0 8px 20px color-mix(in srgb, #000 16%, transparent); + opacity: calc(0.25 + (var(--pull-refresh-progress) * 0.75)); + pointer-events: none; + transform: translate(-50%, calc(-42px + var(--pull-refresh-y))) scale(calc(0.82 + (var(--pull-refresh-progress) * 0.18))); + transition: opacity 0.16s ease, transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.16s ease; + } + .chat-pull-refresh.is-visible, + .chat-pull-refresh.is-refreshing { + display: inline-flex; + } + .chat-pull-refresh.is-ready, + .chat-pull-refresh.is-refreshing { + border-color: color-mix(in srgb, var(--accent, var(--red)) 58%, transparent); + color: var(--accent, var(--red)); + } + .chat-pull-refresh .spinner-whirlpool { + margin: 0 !important; + width: 18px !important; + height: 18px !important; + } + .chat-pull-refresh canvas { + display: block; + } + #email-lib-modal .email-pull-refresh { + top: 112px; + z-index: 4; + } + /* Sortable Cookbook column headers had no visual cue, so users couldn't tell + a header was clickable (the Newest sort on the Model column was invisible). + Show a pointer + hover highlight, and underline the active sort column. */ + .hwfit-header .hwfit-sortable { cursor: pointer; transition: color .12s; } + .hwfit-header .hwfit-sortable:hover { color: var(--fg); text-decoration: underline dotted; } + .hwfit-header .hwfit-sort-active { color: var(--fg); font-weight: 600; } + /* Welcome screen — centered in available space above input bar */ + #welcome-screen { + position:absolute; + top:40%; + left:50%; + transform:translate(-50%,-50%); + display:flex; + flex-direction:column; + align-items:center; + text-align:center; + pointer-events:none; + animation: welcome-enter 0.4s ease-out both; + transition: top 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, transform 0.3s ease; + } + #welcome-screen .welcome-tip, + #welcome-screen .welcome-sub, + #welcome-screen .welcome-version { + transition: opacity 0.25s ease, max-height 0.25s ease, margin 0.25s ease; + max-height: 60px; + overflow: hidden; + } + /* The tip is a full sentence that wraps to 4-5 lines on narrow phones, + where the welcome block shrink-wraps small; the shared 60px ceiling + (sized for the one-line sub/version) clipped its last line. Give the tip + a taller ceiling so it isn't truncated. Kept above the max-height:650px + block below so that rule's max-height:0 still collapses it on short + viewports. */ + #welcome-screen .welcome-tip { + max-height: 120px; + } + @media (max-height: 650px) { + #welcome-screen { top: 28%; } + #welcome-screen .welcome-tip { opacity: 0; max-height: 0; margin: 0; } + #welcome-screen .welcome-version { margin-top: 6px; } + .incognito-btn { margin-top: 8px; } + } + @media (max-height: 500px) { + #welcome-screen { top: 22%; } + #welcome-screen .welcome-name { font-size: 1.4rem; margin-bottom: 0; } + #welcome-screen .welcome-sub { opacity: 0; max-height: 0; margin: 0; } + .incognito-btn { margin-top: 4px !important; } + } + @media (max-height: 380px) { + #welcome-screen { opacity: 0; pointer-events: none; } + } + #welcome-screen.hidden { display:none; } + #welcome-screen.kb-hidden { + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%); + transition: opacity 0.16s ease; + } + @media (max-width: 768px) { + #welcome-screen { top: 42%; } + #welcome-screen .welcome-name { margin-bottom: 2px; } + #welcome-screen .welcome-sub { margin-bottom: 0; } + #welcome-screen .incognito-btn { margin-top: 6px; } + } + #welcome-screen .welcome-name { + font-size:2.2rem; + font-weight:700; + background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg))); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing:0.03em; + margin-bottom:10px; + } + .welcome-boat { + width: 1.8rem; + height: 1.8rem; + margin-right: 0.4rem; + vertical-align: -0.15em; + color: var(--brand-color, var(--red)); + } + #welcome-screen .welcome-sub { + font-size:0.85rem; + color:color-mix(in srgb, var(--fg) 60%, transparent); + opacity:0.6; + line-height:1.5; + max-width:300px; + } + #welcome-screen .welcome-version { + font-size:0.7rem; + opacity:0.25; + margin-top:12px; + pointer-events:none; + user-select:none; + } + #welcome-screen .welcome-tip { + font-size:0.75rem; + color:var(--fg); + opacity:0.2; + margin-top:24px; + max-width:320px; + line-height:1.4; + } + #welcome-screen .welcome-tip.nobody-session-note { + display:flex; + flex-direction:column; + gap:2px; + } + /* Incognito toggle — on welcome screen */ + .incognito-btn { + pointer-events: auto; + background: none; + border: 1px solid var(--border); + color: var(--fg); + opacity: 0.25; + cursor: pointer; + padding: 6px 12px; + border-radius: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.15s; + margin-top: 16px; + font-family: inherit; + font-size: 11px; + max-width: min(160px, 70vw); + } + .incognito-btn:hover { + opacity: 0.6; + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + .incognito-btn.active { + opacity: 1; + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + } + .incognito-btn.active .eye-open { display: none; } + .incognito-btn.active .eye-blinded { display: inline !important; } + .incognito-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* When welcome is active, push input bar up toward center */ + .chat-container .chat-input-bar { + transition: margin 0.3s ease, max-width 0.3s ease; + } + .chat-container.welcome-active .chat-input-bar { + margin-bottom:30vh; + margin-left:auto; + margin-right:auto; + max-width: 800px; + width: 100%; + } + @media (max-width:768px) { + .chat-meta-overlay { + left: 36px; + right: 36px; + } + #welcome-screen .welcome-name { font-size:1.8rem; } + #welcome-screen .welcome-sub { font-size:0.8rem; } + .chat-container.welcome-active .chat-input-bar { + margin-bottom:0; + margin-left:0; + margin-right:0; + position: relative; + z-index: 20; + } + .chat-container.welcome-active:has(.chat-input-bar:focus-within) #welcome-screen, + .chat-container.welcome-active:has(textarea#message:not(:placeholder-shown)) #welcome-screen { + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%); + transition: opacity 0.16s ease; + } + .chat-container.welcome-active:has(.input-icon-btn.expanded) #welcome-screen, + .chat-container.welcome-active:has(.send-btn.newchat-expanded) #welcome-screen, + .chat-container.welcome-active:has(#model-picker-wrap:not(.picker-auto-hidden) .model-picker-menu:not(.hidden)) #welcome-screen { + opacity: 0.45; + transform: translate(-50%, -76%) scale(0.94); + } + .chat-container.welcome-active:has(.input-icon-btn.expanded) #welcome-screen .welcome-tip, + .chat-container.welcome-active:has(.send-btn.newchat-expanded) #welcome-screen .welcome-tip, + .chat-container.welcome-active:has(#model-picker-wrap:not(.picker-auto-hidden) .model-picker-menu:not(.hidden)) #welcome-screen .welcome-tip { + opacity: 0.14; + } + .chat-container.welcome-active textarea#message { + max-height: min(34vh, 150px); + } + } + @media (max-width: 420px) { + .chat-meta-overlay { + left: 34px; + right: 34px; + } + .chat-meta-overlay #session-cost-display, + .chat-meta-overlay .chat-meta-count { + display: none !important; + } + #welcome-screen { + width: calc(100vw - 36px); + } + #welcome-screen .welcome-name, + #welcome-screen .welcome-sub, + #welcome-screen .welcome-tip { + max-width: 100%; + } + .incognito-btn { + padding: 6px 9px; + max-width: 120px; + } + } + @media (max-width: 340px) { + .incognito-btn .incognito-label { + display: none; + } + .incognito-btn { + width: 30px; + height: 30px; + padding: 0; + border-radius: 999px; + } + } + .msg { + margin: 8px 0; + position: relative; + display: flex; + flex-direction: column; + width: fit-content; + max-width: 85%; + min-width: 80px; + border-radius: 12px; + padding: 10px 12px; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + animation: msg-enter 0.3s ease-out both; + } + .msg-user { + align-items: flex-end; + margin-left: auto; + margin-right: 8px; + background: var(--user-bubble-bg, color-mix(in srgb, var(--fg) 8%, var(--bg))); + border: 1px solid var(--bubble-border, var(--border)); + border-radius: 18px 18px 0 18px; + align-self: flex-end; + width: fit-content; + max-width: 85%; + min-width: 80px; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + } + .msg-user:has(textarea) { + width: 85%; + } + .msg-ai { + align-items: flex-start; + margin-right: auto; + margin-left: 8px; + background: var(--ai-bubble-bg, var(--panel)); + border: 1px solid var(--bubble-border, var(--border)); + border-radius: 18px 18px 18px 0; + align-self: flex-start; + width: 85%; + max-width: 85%; + min-width: 80px; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + transition: min-height 0.25s ease-out; + } + .msg .role { + font-weight: 600; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .msg .role::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--model-dot, color-mix(in srgb, var(--fg) 30%, transparent)); + flex-shrink: 0; + } + .msg .role.has-logo::before { display: none; } + .role-provider-logo { display: inline-flex; align-items: center; flex-shrink: 0; } + .role-provider-logo svg { width: 12px; height: 12px; } + .msg-user .role { + color: color-mix(in srgb, var(--fg) 60%, transparent); + } + .msg-user .role::before { + background: color-mix(in srgb, var(--fg) 40%, transparent); + } + .user-mode-pill { + display: inline-flex; + align-items: center; + justify-content: center; + height: 15px; + min-width: 15px; + padding: 0 3px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--fg) 14%, transparent); + background: color-mix(in srgb, var(--fg) 6%, transparent); + color: color-mix(in srgb, var(--fg) 58%, transparent); + font-size: 10px; + font-weight: 600; + line-height: 15px; + text-transform: lowercase; + } + .user-mode-pill svg { + display: block; + width: 11px; + height: 11px; + } + .user-mode-pill[data-auto="true"]::after { + content: 'auto'; + margin-left: 4px; + opacity: 0.65; + font-weight: 500; + } + .msg .body { + width: 100%; + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.5; + font-size: 0.95em; + margin: 0; + overflow: hidden; + max-width: 100%; + } + .msg .body > * { + margin-top: 8px; + margin-bottom: 8px; + } + .msg .body > *:first-child { + margin-top: 0; + } + .msg .body > *:last-child { + margin-bottom: 0; + } + .msg .body p:empty { + display: none; + } + .msg-user .body { + color: var(--fg); + } + .chat-queued-bubble-host { + order: 2147483647; + display: flex; + flex-direction: column; + align-items: stretch; + flex: 0 0 auto; + width: 100%; + min-width: 0; + } + .msg-user.msg-user-queued { + opacity: 0.62; + cursor: pointer; + animation: none; + border: 1px dashed color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + background: color-mix(in srgb, var(--accent, var(--red)) 8%, var(--input-bg, var(--panel))); + transition: opacity 0.12s ease, border-color 0.12s ease, transform 0.12s ease; + } + .msg-user.msg-user-queued:hover { + opacity: 0.9; + border-color: color-mix(in srgb, var(--accent, var(--red)) 85%, transparent); + transform: translateY(-1px); + } + .msg-user.msg-user-queued .queued-pill { + display: inline-flex; + align-items: center; + gap: 3px; + height: 14px; + padding: 0 5px; + margin-left: 5px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent); + color: var(--accent, var(--red)); + background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent); + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + vertical-align: 1px; + } + .msg-user.msg-user-queued .queued-pill svg { + flex: 0 0 auto; + opacity: 0.9; + } +.msg-ai .body { + color: var(--fg); +} +.model-endpoint-add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 7px; + padding: 2px 7px; + border: 1px solid color-mix(in srgb, var(--red) 34%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--red) 8%, transparent); + color: var(--red); + font: inherit; + font-size: 0.78em; + line-height: 1.45; + cursor: pointer; + vertical-align: 1px; +} +.model-endpoint-add-btn:hover { + background: color-mix(in srgb, var(--red) 14%, transparent); + border-color: color-mix(in srgb, var(--red) 55%, var(--border)); +} +.model-endpoint-add-btn:disabled { + cursor: default; + opacity: 0.72; +} +.model-endpoint-add-btn.added { + color: var(--color-save-green, #4caf50); + border-color: color-mix(in srgb, var(--color-save-green, #4caf50) 45%, var(--border)); + background: color-mix(in srgb, var(--color-save-green, #4caf50) 9%, transparent); +} +.task-completed-marker { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 7px 0 2px; + padding: 5px 9px; + border: 1px solid color-mix(in srgb, var(--color-save-green, #4caf50) 42%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--color-save-green, #4caf50) 9%, transparent); + color: var(--color-save-green, #4caf50); + font-size: 0.86em; + font-weight: 600; +} +.task-completed-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + border-radius: 50%; + background: color-mix(in srgb, var(--color-save-green, #4caf50) 18%, transparent); + flex: 0 0 auto; +} +.rag-sources { + margin-top: 12px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + font-size: 11px; + } + .rag-sources summary { + cursor: pointer; + color: var(--red); + font-weight: bold; + font-size: 11px; + } + .rag-source-item { + margin-top: 8px; + padding: 6px; + background: color-mix(in srgb, var(--fg) 4%, transparent); + border-radius: 4px; + border-left: 2px solid var(--red); + } + .rag-similarity { + color: color-mix(in srgb, var(--fg) 50%, transparent); + font-size: 10px; + margin-left: 6px; + } + .rag-snippet { + color: color-mix(in srgb, var(--fg) 65%, transparent); + font-size: 11px; + margin-top: 4px; + white-space: pre-wrap; + max-height: 60px; + overflow: hidden; + } + .rag-file-delete { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: color-mix(in srgb, var(--fg) 50%, transparent); + cursor: pointer; + font-size: 10px; + padding: 1px 5px; + } + .rag-file-delete:hover { color: var(--fg); border-color: var(--fg); } + .rag-file-size { + color: color-mix(in srgb, var(--fg) 50%, transparent); + font-size: 10px; + margin-left: 4px; + } + .rag-upload-zone { + border: 2px dashed var(--border); + border-radius: 6px; + padding: 12px; + text-align: center; + color: color-mix(in srgb, var(--fg) 45%, transparent); + font-size: 11px; + cursor: pointer; + transition: border-color 0.2s; + } + .rag-upload-zone.dragover { + border-color: var(--red); + color: var(--red); + } + .msg .timestamp { + font-size: 10px; + color: color-mix(in srgb, var(--fg) 45%, transparent); + margin-top: 4px; + text-align: right; + opacity: 0.7; + } + .msg-user .timestamp { + color: color-mix(in srgb, var(--fg) 72%, transparent); + } + pre { + overflow: hidden; + padding: 6px 4px 2px 4px; + border: 1px solid var(--border); + background: var(--bg); + position: relative; + line-height: 1.4; + font-size: 0.95em; + font-family: 'Fira Code', 'Courier New', monospace; + min-height: 38px; + max-width: 100%; + margin: 8px 0; + white-space: pre-wrap; + word-break: break-word; + border-radius: 4px; + min-height: 20px; + min-width: 80px; + } + + /* Ensure code block headers are only slightly larger than regular text */ + pre h1, pre h2, pre h3, pre h4, pre h5, pre h6 { + font-size: 1.1em; + font-weight: bold; + margin: 8px 0 4px 0; + color: var(--fg); + } + .code-lang { + font-size:0.8em; + color:color-mix(in srgb, var(--fg) 60%, transparent); + display:block; + margin-bottom:2px; + font-style:italic; + } + code { font-family:inherit; } + .loading { color:var(--red); font-style:italic; } + #chat-form { display:none; } + + /* Unified chat input bar */ + .chat-input-bar { + background: var(--input-bg, var(--panel)); + border: 1px solid var(--input-border, var(--border)); + border-radius: 16px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 800px; + margin-left: auto; + margin-right: auto; + width: 100%; + transition: border-color 0.16s ease, background 0.16s ease; + } + body.plan-mode-active .chat-input-bar { + border-style: dashed; + border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + background: color-mix(in srgb, var(--accent, var(--red)) 8%, var(--input-bg, var(--panel))); + } + .chat-input-top { + width: 100%; + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + .plan-mode-status { + position: absolute; + right: 0; + bottom: 2px; + z-index: 245; + display: inline-flex; + align-items: center; + gap: 7px; + height: 22px; + padding: 0 6px 0 8px; + border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--accent, var(--red)) 12%, var(--input-bg, var(--panel))); + color: var(--accent, var(--red)); + font-size: 10px; + font-weight: 700; + line-height: 1; + transition: opacity 0.22s ease, transform 0.22s ease; + } + .plan-mode-status[hidden] { + display: none !important; + } + .plan-mode-status.plan-mode-status-autohide { + opacity: 0; + pointer-events: none; + transform: translateY(-4px) scale(0.96); + } + .plan-mode-status-toggle { + width: 26px; + height: 14px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent); + cursor: pointer; + position: relative; + flex-shrink: 0; + } + .plan-mode-status-toggle span { + position: absolute; + top: 2px; + right: 2px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent, var(--red)); + } + .plan-mode-status-toggle:hover { + background: color-mix(in srgb, var(--accent, var(--red)) 30%, transparent); + } + .chat-input-top > .model-picker-wrap { + position: relative; + grid-column: 2; + grid-row: 1; + top: 0; + right: auto; + z-index: 250; + transform-origin: top right; + transition: opacity 0.22s ease, transform 0.22s ease; + will-change: opacity, transform; + } + body.plan-mode-active .chat-input-top > .model-picker-wrap { + opacity: 0; + pointer-events: none; + transform: translateY(-4px) scale(0.96); + } + .chat-input-top > .model-picker-wrap.model-picker-autohide, + .chat-input-top > .model-picker-wrap.picker-auto-hidden { + display: none !important; + opacity: 0; + pointer-events: none; + transform: translateY(-4px) scale(0.96); + } + .ghost-text-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + pointer-events: none; + white-space: pre-wrap; + overflow-wrap: break-word; + font-family: inherit; + font-size: 14px; + line-height: 1.5; + padding: 0; + border: 1px solid transparent; + color: transparent; + z-index: 1; + } + .ghost-text-overlay .ghost-suggestion { + color: color-mix(in srgb, var(--fg) 30%, transparent); + } + .chat-input-bar textarea#message { + grid-column: 1; + grid-row: 1; + min-width: 0; + width: 100%; + background: transparent; + border: none; + outline: none; + resize: none; + font-size: 14px; + line-height: 1.5; + color: var(--fg); + min-height: 24px; + max-height: 200px; + padding: 0; + font-family: inherit; + transition: height 0.12s ease-out; + } + .chat-input-bar textarea#message::placeholder { + color: color-mix(in srgb, var(--fg) 35%, transparent); + } + .chat-input-bottom { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 4px; + } + /* Progressive shrinkage: as the chat input bar gets narrow, sacrifice + chrome before the typing area. First the Agent/Chat toggle drops out, + then the model picker — textarea + send button always stay. Container + queries so it responds to *bar width*, not viewport width (the bar + can be in a split pane). */ + .chat-input-bar { container-type: inline-size; container-name: chatbar; } + @container chatbar (max-width: 340px) { + .chat-input-right .mode-toggle { display: none !important; } + } + .chat-input-left { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; + min-width: 0; + flex: 1; + } + /* Collapsible buttons shrink away; collapsed ones disappear */ + .chat-input-left > .input-icon-btn { + flex-shrink: 0; + } + .chat-input-left > .input-icon-btn.toolbar-collapsed { + display: none !important; + } + /* Always keep overflow wrapper visible and leftmost */ + .chat-input-left > .overflow-wrapper { + flex-shrink: 0; + position: relative; + z-index: 1; + } + .input-divider { + width: 1px; + height: 16px; + background: var(--border); + opacity: 0.4; + margin: 0 2px; + flex-shrink: 0; + } + .chat-input-right { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; + } + /* Reasoning effort pill / dropdown in composer */ + .reasoning-effort-wrap { + position: relative; + display: inline-flex; + align-items: center; + } + .reasoning-effort-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--fg) 4%, transparent); + color: var(--fg); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + user-select: none; + transition: background 0.15s, border-color 0.15s; + } + .reasoning-effort-btn:hover { + background: color-mix(in srgb, var(--fg) 8%, transparent); + border-color: color-mix(in srgb, var(--fg) 20%, transparent); + } + .reasoning-effort-chevron { + opacity: 0.5; + transition: transform 0.15s; + flex-shrink: 0; + } + .reasoning-effort-btn[aria-expanded="true"] .reasoning-effort-chevron { + transform: rotate(180deg); + } + .reasoning-effort-menu { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + min-width: 140px; + background: var(--bg-surface, var(--bg)); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + padding: 4px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 2px; + } + .reasoning-effort-menu.hidden { + display: none !important; + } + .reasoning-effort-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-radius: 5px; + font-size: 12px; + color: var(--fg); + cursor: pointer; + background: transparent; + border: none; + text-align: left; + width: 100%; + transition: background 0.12s; + } + .reasoning-effort-option:hover { + background: color-mix(in srgb, var(--fg) 8%, transparent); + } + .reasoning-effort-option.active { + color: var(--accent); + font-weight: 600; + } + @container chatbar (max-width: 480px) { + .reasoning-effort-prefix { display: none; } + } + @media (max-width: 480px) { + .reasoning-effort-prefix { display: none; } + } + .input-icon-btn { + background: none; + border: none; + color: var(--fg); + opacity: 0.5; + cursor: pointer; + padding: 6px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s, color 0.15s; + position: relative; + } + .input-icon-btn:hover { + opacity: 0.8; + background: color-mix(in srgb, var(--accent) 8%, transparent); + } + .input-icon-btn.active, + .input-icon-btn.expanded { + opacity: 1; + color: var(--fg); + background: color-mix(in srgb, var(--fg) 9%, transparent); + } + .plan-mode-btn { + width: 28px; + height: 28px; + padding: 0; + flex-shrink: 0; + top: -2px; + } + .plan-mode-btn.active { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + } + /* GitHub-style task lists (- [ ] / - [x]) */ + li.task-item { + list-style: none; + margin-left: -1.2em; + display: flex; + align-items: flex-start; + gap: 8px; + } + li.task-item .task-check { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin-top: 3px; + border-radius: 4px; + border: 1.5px solid color-mix(in srgb, var(--fg) 45%, transparent); + box-sizing: border-box; + position: relative; + } + li.task-item.task-done .task-check { + background: var(--accent); + border-color: var(--accent); + } + li.task-item.task-done .task-check::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid var(--bg); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + li.task-item.task-done .task-text { + opacity: 0.6; + text-decoration: line-through; + } + .plan-inline-actions { + display: flex; + align-items: center; + gap: 7px; + margin-top: 10px; + } + .plan-inline-execute, + .plan-inline-clear { + height: 26px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--fg) 13%, transparent); + background: transparent; + color: var(--fg); + font: inherit; + font-size: 11px; + padding: 0 9px; + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; + } + .plan-inline-execute { + background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent); + border-color: color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); + color: var(--accent, var(--red)); + font-weight: 650; + } + .plan-review-panel { + position: fixed; + right: 24px; + bottom: 92px; + z-index: 500; + width: min(520px, calc(100vw - 32px)); + max-height: min(560px, calc(100vh - 120px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border)); + border-radius: 8px; + background: var(--panel); + color: var(--fg); + box-shadow: 0 14px 40px color-mix(in srgb, #000 32%, transparent); + } + .plan-review-panel[hidden] { display: none !important; } + .plan-review-panel-header, + .plan-review-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + } + .plan-review-panel-header { + justify-content: space-between; + border-bottom: 1px solid var(--border); + } + .plan-review-panel-header h3 { + margin: 0; + font-size: 13px; + line-height: 1.2; + } + .plan-review-close { + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + color: var(--fg); + font-size: 19px; + line-height: 1; + cursor: pointer; + opacity: 0.65; + } + .plan-review-close:hover { opacity: 1; } + .plan-review-content { + margin: 0; + padding: 12px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.55; + font-family: inherit; + } + .plan-review-actions { + border-top: 1px solid var(--border); + } + .plan-review-execute, + .plan-review-clear { + height: 28px; + padding: 0 9px; + border: 1px solid color-mix(in srgb, var(--fg) 14%, transparent); + border-radius: 6px; + background: transparent; + color: var(--fg); + font: inherit; + font-size: 11px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + } + .plan-review-execute { + border-color: color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); + background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent); + color: var(--accent, var(--red)); + font-weight: 650; + } + /* While the menu is open the chevron stays in its highlighted state + — don't run the opacity fade transition so we never flash from + 0.5 → hover-1.0 → drop-back. The state holds steady. */ + .input-icon-btn.expanded { transition: none; } + /* Accent color for Research, Compare toolbar indicators */ + #research-toggle-btn.active, + .tool-indicator.active { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); + } + #workspace-indicator-btn.active { + opacity: 1; + color: var(--fg); + background: color-mix(in srgb, var(--fg) 9%, transparent); + } + #workspace-indicator-btn.active:hover { + opacity: 1; + background: color-mix(in srgb, var(--fg) 9%, transparent); + } + #workspace-indicator-btn.active > svg:not(.tool-indicator-x), + #workspace-indicator-btn.active > span { + opacity: 1; + } + #workspace-indicator-btn .workspace-indicator-icon { + opacity: 1 !important; + color: currentColor !important; + stroke: currentColor !important; + flex-shrink: 0; + } + #workspace-indicator-btn > svg:first-of-type { + opacity: 1 !important; + color: currentColor !important; + stroke: currentColor !important; + } + /* Research button glow while actively running */ + #research-toggle-btn.research-running { + opacity: 1 !important; + color: var(--red); + animation: research-pulse 2s ease-in-out infinite; + } + .tool-indicator-x { + margin-left: 2px; + opacity: 0.4; + flex-shrink: 0; + transition: opacity 0.15s; + } + .tool-indicator:hover .tool-indicator-x { + opacity: 1; + } + /* Character indicator — hide name text below 768px, icon always visible */ + @media (max-width: 768px) { + #character-indicator-name { display: none; } + } + + /* Document indicator — hidden by default, shown when docs exist */ + #doc-indicator-btn { display: none !important; } + #doc-indicator-btn.visible { display: inline-flex !important; } + /* On mobile, the minimized-dock chip replaces this indicator — keep + it hidden regardless of `.visible`. */ + @media (max-width: 768px) { + #doc-indicator-btn, + #doc-indicator-btn.visible { display: none !important; } + } + .doc-indicator-active { + color: var(--accent, var(--accent-primary, var(--red))) !important; + opacity: 1 !important; + } + #doc-indicator-btn.active { + color: var(--accent, var(--accent-primary, var(--red))) !important; + opacity: 1 !important; + background: color-mix(in srgb, var(--fg) 9%, transparent) !important; + } + #overflow-doc-btn.has-docs, + #overflow-doc-btn.active { + color: var(--accent, var(--red)); + opacity: 1; + } + #overflow-doc-btn.has-docs.active { + background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent); + } + .send-btn { + /* Prefer the theme accent (--red). A stored custom `--send-btn-bg` + override only kicks in if it's set to a non-white value — guards + against ChatGPT-style themes where the stored override leaked + to white and rendered the send button invisible. */ + background: var(--send-btn-bg, var(--red)); + color: #fff; + border: none; + border-radius: 8px; + min-width: 32px; + width: 32px; + height: 32px !important; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.25s, border-color 0.25s, color 0.25s, width 0.3s cubic-bezier(0.34, 1, 0.64, 1), padding 0.3s, border-radius 0.3s, opacity 0.1s; + flex-shrink: 0; + overflow: hidden; + } + /* Instant feedback while the send handler is spinning up before streaming begins */ + .send-btn.send-pending { + opacity: 0.55; + pointer-events: none; + animation: send-pending-pulse 0.9s ease-in-out infinite; + } + @keyframes send-pending-pulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 0.85; } + } + /* Send button icon transitions — send mode forces no animation */ + .send-btn[data-mode="send"] svg { animation: none !important; } + /* Spin out: + rotates away, then arrow spins in via anim-spin */ + .send-btn.anim-spin-swap svg { animation: btn-spin-out 0.15s ease-in forwards; } + .send-btn.anim-spin-swap .send-btn-label { opacity: 0; transition: opacity 0.1s; } + @keyframes btn-spin-out { + 0% { transform: rotate(0deg) scale(1); opacity: 1; } + 100% { transform: rotate(90deg) scale(0); opacity: 0; } + } + .send-btn.anim-spin svg { animation: btn-spin-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } + .send-btn.anim-launch svg { animation: btn-launch 0.3s cubic-bezier(0.6, 0, 0.4, 1) forwards; } + .send-btn.anim-land svg { animation: btn-land 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); } + @keyframes btn-spin-in { + 0% { transform: rotate(-180deg) scale(0); opacity: 0; } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } + } + /* Arrow flies UP and OUT of the button before the stop icon lands in. + Stays opaque most of the way so it reads as a real launch instead of a + fade-in-place. .anim-launch temporarily lifts the button's overflow so + the icon escapes the 32px box. */ + @keyframes btn-launch { + 0% { transform: translateY(0) scale(1); opacity: 1; } + 70% { transform: translateY(-30px) scale(0.95); opacity: 1; } + 100% { transform: translateY(-58px) scale(0.55); opacity: 0; } + } + .send-btn.anim-launch { overflow: visible !important; } + .send-btn.anim-launch svg { transform-origin: 50% 50%; } + @keyframes btn-land { + 0% { transform: translateY(10px) scale(0.3); opacity: 0; } + 100% { transform: translateY(0) scale(1); opacity: 1; } + } + /* Stop button — Processing: Siren pulse, Receiving: Quarter turn spin */ + .send-btn[data-mode="streaming"][data-phase="processing"] svg { + animation: siren-icon 1.5s ease-in-out infinite; + } + .send-btn[data-mode="streaming"][data-phase="receiving"] svg { + animation: quarter-turn 2s cubic-bezier(0.4, 0, 0.2, 1) infinite; + } + @keyframes siren-icon { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(0.8); } + } + @keyframes quarter-turn { + 0%, 15% { transform: rotate(0deg); } + 25%, 40% { transform: rotate(90deg); } + 50%, 65% { transform: rotate(180deg); } + 75%, 90% { transform: rotate(270deg); } + 100% { transform: rotate(360deg); } + } + .send-btn:hover { + background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white)); + color: #fff; + } + .send-btn.mic-mode, + .send-btn.newchat-mode { + /* Idle / new-chat / mic states blend the picked Send Btn color into + the panel so the user's color choice is visible across every state, + not only briefly while typing. */ + background: color-mix(in srgb, var(--send-btn-bg, var(--red)) 30%, var(--panel, #2a2a2a)); + color: var(--fg); + border: 1px solid var(--border); + transition: width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.25s, border-color 0.25s, color 0.25s, border-radius 0.3s; + } + .send-btn.mic-mode:hover, + .send-btn.newchat-mode:hover { + background: color-mix(in srgb, var(--send-btn-hover, var(--send-btn-bg, var(--red))) 85%, var(--panel, #2a2a2a)); + color: #fff; + } + /* Hover: just spin the + 90° (no size change). The "New chat" affordance + is the tooltip (title="New chat") plus the icon rotation. + Gated on data-mode="newchat" so the arrow variant (empty-session state + which keeps the newchat-mode class but shows the send arrow) does NOT + rotate on hover. */ + .send-btn[data-mode="newchat"] svg { + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .send-btn[data-mode="newchat"]:hover svg { + transform: rotate(90deg); + } + /* "New Session" expanding label */ + .send-btn-label { + width: 0; + max-width: 0; + overflow: hidden; + white-space: nowrap; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + opacity: 0; + padding: 0; + margin: 0; + transition: max-width 0.35s cubic-bezier(0.34, 1.2, 0.64, 1), width 0.35s, opacity 0.25s, margin-left 0.25s; + } + .send-btn.newchat-expanded .send-btn-label { + width: auto; + max-width: 50px; + opacity: 1; + margin-left: 4px; + } + .send-btn.newchat-expanded { + width: 68px; + padding: 0 10px 0 8px; + } + .send-btn.recording { + background: var(--red) !important; + color: #fff !important; + border: none !important; + animation: pulse-recording 1.5s infinite; + } + @keyframes pulse-recording { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } + } + /* Mode toggle — Agent / Chat */ + .mode-toggle { + display: flex; + flex-shrink: 0; + height: 28px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + position: relative; + } + .mode-toggle::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 50%; + height: 100%; + background: color-mix(in srgb, var(--fg) 10%, transparent); + border-radius: 9px; + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 0; + } + .mode-toggle.mode-chat::before, + .mode-toggle.mode-right::before { + transform: translateX(100%); + } + .mode-toggle.mode-toggle-three::before { + width: 33.3333%; + } + .mode-toggle.mode-toggle-three.mode-mid::before { + transform: translateX(100%); + } + .mode-toggle.mode-toggle-three.mode-third::before { + transform: translateX(200%); + } + #mode-agent-btn { + border-radius: 10px 0 0 10px; + } + #mode-chat-btn { + border-radius: 0 10px 10px 0; + } + .mode-toggle-btn { + background: none; + border: none; + color: color-mix(in srgb, var(--fg) 40%, transparent); + cursor: pointer; + padding: 0 10px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + transition: color 0.2s; + white-space: nowrap; + height: 100%; + position: relative; + z-index: 1; + } + .mode-toggle-btn:not(.active):hover { + color: color-mix(in srgb, var(--fg) 60%, transparent); + } + .mode-toggle-btn:hover { + background: none !important; + border-color: transparent !important; + } + .mode-toggle-btn.active, + .mode-toggle-btn.active:hover, + .mode-toggle-btn.active:focus { + color: var(--fg) !important; + background: none !important; + border-color: transparent !important; + cursor: default; + } + .mode-toggle-btn + .mode-toggle-btn { + border-left: none; + } + @media (max-width: 768px) { + .mode-toggle.mode-toggle-three [data-llama-mode="unified"] > span { + font-size: 0; + } + .mode-toggle.mode-toggle-three [data-llama-mode="unified"] > span::after { + content: 'Unif'; + font-size: 11px; + } + } + /* Message count badge in the chat-meta header (next to the title). + Auto-hides when empty so a brand-new chat doesn't show "0 msgs". */ + .chat-meta-count { + font-size: inherit; + font-weight: 400; + color: color-mix(in srgb, var(--fg) 35%, transparent); + white-space: nowrap; + user-select: none; + margin-left: 6px; + } + .chat-meta-count:empty { display: none; } + /* Session cost indicator (next to chevron in header) */ + .session-cost-display { + font-size: inherit; + font-weight: 400; + color: color-mix(in srgb, var(--fg) 35%, transparent); + white-space: nowrap; + user-select: none; + margin-left: 7px; + margin-right: 5px; + } + .chat-context-pill { + display: inline-flex; + align-items: center; + gap: 3px; + height: auto; + padding: 0; + margin-left: 7px; + border: 0; + border-radius: 0; + background: transparent; + color: color-mix(in srgb, var(--fg) 35%, transparent); + font-family: inherit; + font-size: 0.7rem; + font-weight: 400; + line-height: 1; + cursor: pointer; + white-space: nowrap; + position: relative; + z-index: 2; + --ctx-stroke: var(--color-muted, #888); + transition: color 0.15s, opacity 0.15s; + } + .chat-context-pill[hidden] { + display: none !important; + } + .chat-context-pill:hover, + .chat-context-pill.open { + color: color-mix(in srgb, var(--fg) 70%, transparent); + --ctx-stroke: var(--ctx-color); + border: 0; + background: transparent; + } + .chat-context-pill.loading { + opacity: 0.55; + } + .chat-context-pill.warn { + color: var(--orange, #f59e0b); + background: transparent; + } + .chat-context-pill.danger { + color: var(--red, #ef4444); + background: transparent; + } + .chat-context-pill svg { + flex-shrink: 0; + } + .chat-context-pill .ctx-ring-pct { + color: currentColor; + font-size: inherit; + font-weight: inherit; + line-height: 1; + } + .chat-context-dot { + display: none; + } + .chat-context-popup { + position: fixed; + z-index: 10060; + width: 238px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + color: var(--fg); + box-shadow: 0 12px 34px color-mix(in srgb, #000 26%, transparent); + font-size: 11px; + line-height: 1.35; + } + .chat-context-popup.warn { + color: var(--orange, #f59e0b); + } + .chat-context-popup.danger { + color: var(--red, #ef4444); + } + .chat-context-popup-title { + font-weight: 650; + margin-bottom: 8px; + color: var(--accent, var(--red)); + } + .chat-context-popup-bar { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--fg) 10%, transparent); + margin-bottom: 6px; + } +.chat-context-popup-fill { + height: 100%; + width: 0; + border-radius: inherit; + background: var(--red, var(--fg)); +} + .chat-context-popup-row { + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 4px; + color: color-mix(in srgb, var(--fg) 70%, transparent); + } + .chat-context-popup-row span:last-child { + color: var(--fg); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 148px; + } + .chat-context-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + } + .chat-context-toggle-copy { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + color: color-mix(in srgb, var(--fg) 76%, transparent); + } + .chat-context-toggle-copy > span:first-child { + white-space: nowrap; + color: var(--accent, var(--red)); + } + .chat-context-toggle-state { + font-size: 10px; + color: color-mix(in srgb, var(--fg) 45%, transparent); + } + .chat-context-toggle { + position: relative; + flex: 0 0 auto; + width: 32px; + height: 18px; + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--fg) 8%, transparent); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, opacity 0.15s; + } + .chat-context-toggle::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 10px; + height: 10px; + border-radius: 50%; + background: color-mix(in srgb, var(--fg) 62%, transparent); + transition: transform 0.15s, background 0.15s; + } + .chat-context-toggle.active { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 18%, transparent); + } + .chat-context-toggle.active::after { + transform: translateX(14px); + background: var(--fg); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 18%, transparent); + } + .chat-context-toggle:disabled { + opacity: 0.6; + cursor: default; + } + .chat-context-threshold-row { + margin-top: 8px; + } + .chat-context-threshold-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 5px; + color: color-mix(in srgb, var(--fg) 70%, transparent); + } + .chat-context-threshold-top span:last-child { + color: var(--fg); + font-variant-numeric: tabular-nums; + } + .chat-context-threshold-slider { + width: 100%; + margin: 0; + } + .chat-context-compact-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1px; + width: 100%; + height: 26px; + margin-top: 9px; + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 600; + } + .chat-context-compact-btn svg { display: block; flex: 0 0 auto; margin-left: -2px; } + .chat-context-compact-btn svg circle:last-child { + stroke: var(--accent, var(--red)); + transition: stroke-dasharray 0.22s ease; + } + .chat-context-compact-btn:hover svg circle:last-child { + stroke-dasharray: 35 0; + } + .chat-context-compact-btn:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); + } + .chat-context-popup, + .chat-context-popup * { + font-size: 10px !important; + } + /* Model picker — input bar drop-up */ + .model-picker-wrap { + position: relative; + flex-shrink: 0; + } + .model-picker-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 21px; + padding: 0 6px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + background: none; + border: 1px solid transparent; + border-radius: 4px; + /* 65% mix lifts the model label above the WCAG AA 4.5:1 threshold + against the dark chat-bar (40% only reached ~2.9:1). */ + color: color-mix(in srgb, var(--fg) 65%, transparent); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s, border-color 0.15s; + } + .model-picker-btn:hover { + border-color: var(--border); + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--fg); + } + .model-picker-btn #model-picker-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + } + .model-picker-btn svg { + flex-shrink: 0; + opacity: 0.5; + } + .model-picker-logo { + display: inline-flex; + align-items: center; + vertical-align: -2px; + } + .model-picker-logo svg { + width: 12px; + height: 12px; + opacity: 0.7; + } + .model-picker-menu { + position: absolute; + bottom: calc(100% + 16px); + right: 0; + z-index: 250; + min-width: 260px; + max-width: 360px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 -4px 20px rgba(0,0,0,0.4); + padding: 6px; + animation: picker-roll-up 0.2s ease-out; + transform-origin: bottom right; + } + .model-picker-menu.closing { + animation: picker-roll-down 0.15s ease-in forwards; + } + .model-picker-menu.hidden { display: none; } + .model-picker-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 30px; + gap: 4px; + align-items: center; + margin-bottom: 4px; + transition: grid-template-columns 0.18s ease, gap 0.18s ease; + } + .model-picker-menu.no-models .model-picker-search-row { + margin-bottom: 0; + } + .model-picker-search-row.searching { + grid-template-columns: minmax(0, 1fr) 0px; + gap: 0; + } + .model-picker-search-row.searching .model-picker-action-btn { + opacity: 0; + transform: translateX(10px) scale(0.88); + pointer-events: none; + } + .model-picker-search-wrap { + position: relative; + min-width: 0; + } + .model-picker-menu input[type="text"] { + width: 100%; + box-sizing: border-box; + padding: 6px 30px 6px 8px; + font-size: 0.82em; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--fg); + font-family: inherit; + outline: none; + min-width: 0; + transition: border-color 0.15s, padding 0.18s ease, background 0.18s ease; + } + .model-picker-menu input[type="text"]:focus { + border-color: var(--red); + } + .model-picker-menu input[type="text"]::placeholder { + color: color-mix(in srgb, var(--fg) 30%, transparent); + } + .model-picker-refresh-btn { + appearance: none; + position: absolute; + top: 50%; + right: 4px; + transform: translateY(-50%); + width: 22px; + height: 22px; + border: 0; + border-radius: 4px; + background: transparent; + color: color-mix(in srgb, var(--fg) 54%, transparent); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + } + .model-picker-refresh-btn:hover, + .model-picker-refresh-btn:focus-visible { + color: var(--fg); + background: color-mix(in srgb, var(--fg) 8%, transparent); + outline: none; + } + .model-picker-refresh-btn:disabled { + opacity: 0.7; + cursor: default; + } + .model-picker-refresh-btn.spinning svg { + animation: model-picker-refresh-spin 0.75s linear infinite; + } + .model-picker-action-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid var(--border); + border-radius: 4px; + background: color-mix(in srgb, var(--fg) 4%, transparent); + color: color-mix(in srgb, var(--fg) 66%, transparent); + cursor: pointer; + padding: 0; + overflow: hidden; + transform: translateX(0) scale(1); + transition: opacity 0.16s ease, transform 0.18s ease, border-color 0.15s, color 0.15s, background 0.15s; + } + .model-picker-action-btn:hover, + .model-picker-action-btn:focus-visible { + border-color: var(--red); + color: var(--fg); + background: color-mix(in srgb, var(--red) 10%, var(--panel)); + outline: none; + } + .model-picker-action-btn.primary { + color: var(--red); + background: color-mix(in srgb, var(--red) 8%, transparent); + } + .model-picker-action-btn svg { + width: 14px; + height: 14px; + transition: transform 0.28s ease; + } + .model-picker-action-btn:hover svg, + .model-picker-action-btn:focus-visible svg { + transform: rotate(90deg); + } + .model-picker-list { + max-height: min(280px, 50dvh); + overflow-y: auto; + } + .model-picker-list.is-loading { + max-height: min(120px, 28dvh); + overflow: hidden; + } + .model-picker-list.is-empty { + max-height: 0; + overflow: hidden; + } + .model-picker-list .model-picker-loading-row { + justify-content: center; + gap: 7px; + min-height: 38px; + padding: 8px 10px; + color: color-mix(in srgb, var(--fg) 58%, transparent); + font-size: 0.82em; + } + .model-picker-list .model-picker-loading-row .ai-spinner-whirlpool { + position: relative; + top: 1px; + } + .model-picker-list .model-switch-item { + display: flex; + align-items: center; + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 0.82em; + color: var(--fg); + gap: 6px; + } + .model-picker-list .model-switch-item:hover { + background: color-mix(in srgb, var(--red) 8%, transparent); + } + .model-picker-list .model-switch-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 5px 8px; + color: color-mix(in srgb, var(--fg) 50%, transparent); + font-size: 0.82em; + } + .model-picker-list .mp-section-label { + font-size: 0.72em; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.4; + padding: 6px 8px 2px; + } + .model-picker-list .mp-section-label:first-child { + padding-top: 2px; + } + /* Model name takes the slack so the endpoint label + favorite dot sit on the right. */ + .model-picker-list .model-switch-item .mp-model-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .model-picker-list .model-switch-item .model-switch-ep { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9em; + opacity: 0.45; + } + /* Keyboard navigation highlight (Arrow keys in the search box). */ + .model-picker-list .model-switch-item.kb-active { + background: color-mix(in srgb, var(--red) 14%, transparent); + } + /* Inline favorite bookmark — always visible (works on touch), active when on. */ + .model-picker-list .mp-fav-dot { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 24px; + margin: -5px -8px -5px 8px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: color-mix(in srgb, var(--fg) 22%, transparent); + font-family: inherit; + font-size: 13px; + line-height: 1; + transition: color 0.15s ease, opacity 0.15s ease, transform 0.12s ease; + -webkit-tap-highlight-color: transparent; + } + .model-picker-list .mp-fav-dot:hover { + color: color-mix(in srgb, var(--fg) 68%, transparent); + } + .model-picker-list .mp-fav-dot:focus-visible { + outline: none; + color: color-mix(in srgb, var(--fg) 68%, transparent); + } + .model-picker-list .mp-fav-dot.active { + color: var(--accent, var(--red)); + opacity: 1; + } + .model-picker-list .mp-fav-dot svg { display: block; } + .model-picker-list .mp-fav-dot.active:hover { + color: var(--accent, var(--red)); + opacity: 0.72; + } + .model-picker-list .mp-fav-dot.pulse { + animation: mpFavPulse 0.32s ease-out; + } + @keyframes mpFavPulse { + 0% { text-shadow: 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 0%, transparent); } + 45% { text-shadow: 0 0 10px color-mix(in srgb, var(--accent, var(--red)) 60%, transparent); } + 100% { text-shadow: 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 0%, transparent); } + } + /* First-run hint when a large catalog has no Recent/Favorites yet. */ + .model-picker-list .mp-empty-hint { + flex-direction: column; + gap: 2px; + padding: 14px 8px; + text-align: center; + } + .model-picker-list .mp-empty-hint .mp-empty-title { + font-size: 1.05em; + color: color-mix(in srgb, var(--fg) 70%, transparent); + } + .model-picker-list .mp-empty-hint .mp-empty-sub { + font-size: 0.92em; + opacity: 0.7; + } + /* Provider group headers */ + .model-picker-list .mp-provider-header { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + cursor: pointer; + font-size: 0.78em; + font-weight: 500; + color: var(--fg); + border-radius: 4px; + user-select: none; + } + .model-picker-list .mp-provider-header:hover { + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + .model-picker-list .mp-provider-chevron { + display: inline-flex; + opacity: 0.4; + transition: transform 0.2s, opacity 0.15s; + flex-shrink: 0; + } + .model-picker-list .mp-provider-header:hover .mp-provider-chevron { + opacity: 0.7; + } + .model-picker-list .mp-provider-chevron.collapsed { + transform: rotate(-90deg); + } + .model-picker-list .mp-provider-name { flex: 1; } + .model-picker-list .mp-provider-count { font-size: 0.85em; opacity: 0.4; } + /* Domino expand (15% faster than sidebar) */ + .mp-provider-group.mp-just-expanded .model-switch-item { + animation: mp-domino-in 0.31s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; + } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(1) { animation-delay: 0.035s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(2) { animation-delay: 0.07s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(3) { animation-delay: 0.105s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(4) { animation-delay: 0.14s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(5) { animation-delay: 0.175s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(6) { animation-delay: 0.21s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(7) { animation-delay: 0.245s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(8) { animation-delay: 0.28s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(9) { animation-delay: 0.315s; } + .mp-provider-group.mp-just-expanded .model-switch-item:nth-child(10) { animation-delay: 0.35s; } + @keyframes mp-domino-in { + 0% { opacity: 0; transform: translateY(6px) scale(0.94); } + 60% { opacity: 1; } + 100% { opacity: 1; transform: translateY(0) scale(1); } + } + /* Comfortable touch targets on phones / narrow screens. */ + @media (hover: none) and (pointer: coarse), (max-width: 768px) { + .model-picker-list .model-switch-item { + padding-top: 8px; + padding-bottom: 8px; + } + .model-picker-list .mp-fav-dot { + width: 30px; + height: 30px; + margin: -7px -8px -7px 8px; + } + } + /* Overflow "+" menu */ + .overflow-wrapper { + position: relative; + display: flex; + align-items: center; + } + .plus-active-dot { + position: absolute; + top: 4px; + right: 2px; + width: 6px; + height: 6px; + background: var(--fg); + border-radius: 50%; + display: none; + } + .overflow-plus-btn.has-active .plus-active-dot { + display: block; + } + .overflow-menu { + position: fixed; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 4px; + min-width: 170px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + z-index: 1000; + /* Container spring-in: the rounded panel scales/grows out of the + chevron's position before the menu items domino in on top. */ + transform-origin: bottom left; + animation: overflow-menu-pop 0.08s ease-out; + } + @keyframes overflow-menu-pop { + 0% { transform: scale(0.96) translateY(2px); opacity: 0; } + 100% { transform: scale(1) translateY(0); opacity: 1; } + } + .overflow-menu.hidden { + display: none; + } + /* Closing state: JS adds `.closing` to play the fold-in animation, + waits for it to finish, then flips to `.hidden`. Container + scales/translates back into the chevron while items peel off from + the top down so the menu collapses into its anchor. */ + .overflow-menu.closing { + animation: overflow-menu-pop-out 0.08s ease-in forwards; + animation-delay: 0s; + } + @keyframes overflow-menu-pop-out { + 0% { transform: scale(1) translateY(0); opacity: 1; } + 100% { transform: scale(0.96) translateY(2px); opacity: 0; } + } + .overflow-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + color: var(--fg); + opacity: 0.7; + cursor: pointer; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + transition: background 0.15s, opacity 0.15s, color 0.15s; + /* Domino-style cascade: each item slides up + fades in with a tiny + delay after the previous one (set via nth-last-child below so the + BOTTOM item appears first and the cascade rolls upward — visually + feels like the menu is "stacking up" from the chevron). The + container itself springs in via .overflow-menu's keyframe. */ + animation: overflow-item-in 0.08s ease-out backwards; + animation-delay: 0s !important; + } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(1) { animation-delay: 0.06s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(2) { animation-delay: 0.10s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(3) { animation-delay: 0.14s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(4) { animation-delay: 0.18s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(5) { animation-delay: 0.22s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(6) { animation-delay: 0.26s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(7) { animation-delay: 0.30s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(8) { animation-delay: 0.34s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(9) { animation-delay: 0.38s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(10) { animation-delay: 0.42s; } + @keyframes overflow-item-in { + 0% { opacity: 0; transform: translateY(10px) translateX(-6px) scale(0.9); } + 60% { opacity: 1; } + 100% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + } + /* Fold-in: items peel off top-down (mirror of the open's bottom-up + cascade) so the menu visibly empties before the container scales + back into the chevron. */ + .overflow-menu.closing .overflow-menu-item { + animation: overflow-item-out 0.06s ease-in forwards; + animation-delay: 0s !important; + } + .overflow-menu.closing .overflow-menu-item:nth-child(1) { animation-delay: 0.00s; } + .overflow-menu.closing .overflow-menu-item:nth-child(2) { animation-delay: 0.02s; } + .overflow-menu.closing .overflow-menu-item:nth-child(3) { animation-delay: 0.04s; } + .overflow-menu.closing .overflow-menu-item:nth-child(4) { animation-delay: 0.06s; } + .overflow-menu.closing .overflow-menu-item:nth-child(5) { animation-delay: 0.08s; } + .overflow-menu.closing .overflow-menu-item:nth-child(6) { animation-delay: 0.10s; } + .overflow-menu.closing .overflow-menu-item:nth-child(7) { animation-delay: 0.12s; } + .overflow-menu.closing .overflow-menu-item:nth-child(8) { animation-delay: 0.14s; } + .overflow-menu.closing .overflow-menu-item:nth-child(9) { animation-delay: 0.16s; } + .overflow-menu.closing .overflow-menu-item:nth-child(10) { animation-delay: 0.18s; } + @keyframes overflow-item-out { + 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.92); } + } + .overflow-menu-item:hover { + opacity: 1; + background: color-mix(in srgb, var(--red) 10%, transparent); + } + #overflow-attach-btn { + position: relative; + font-weight: 600; + } + #overflow-attach-btn svg { + transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .overflow-menu-item.active { + opacity: 1; + color: var(--fg); + } + .overflow-active-dot { + width: 6px; + height: 6px; + background: var(--fg); + border-radius: 50%; + margin-left: auto; + display: none; + flex-shrink: 0; + } + .overflow-menu-item.active .overflow-active-dot { + display: block; + } + .attach-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 0 0 8px; + min-height: 0; + } + .attach-strip:empty { + display: none; + } + .attach-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 6px 0 0; + min-height: 32px; + padding: 2px; + } + .attachment-placeholder { + display: flex; + align-items: center; + color: var(--fg); + opacity: 0.7; + font-style: italic; + padding: 4px 8px; + border-radius: 4px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + .attach-btn { width:32px; display:grid; place-items:center; } + .hidden { display:none; } + .toggle { position:relative; display:inline-block; width:30px; height:16px; vertical-align:middle; } + .toggle input { opacity:0; width:0; height:0; } + .toggle .slider { + position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; + background:color-mix(in srgb, var(--fg) 15%, transparent); transition:background .08s; border-radius:8px; + } + .toggle .slider:before { + position:absolute; content:""; height:12px; width:12px; left:2px; top:2px; + background:var(--panel); border-radius:50%; transform:translateX(0); transition:transform .08s; + box-shadow:0 1px 2px rgba(0,0,0,0.25); + } + .toggle input:checked + .slider { background:var(--red); } + .toggle input:checked + .slider:before { transform:translateX(14px); } + .copy-btn { + position:absolute; top:6px; right:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + font-size:12px; height:24px; padding:0 8px; cursor:pointer; + opacity:0; transition:.15s; + } + .msg:hover .copy-btn { opacity:1; } + .role-timestamp { + font-size:0.7rem; color:var(--color-muted-alt); font-weight:normal; margin-left:6px; + } + .msg-footer { + display:flex; align-items:center; gap:6px; + flex-wrap: wrap; + margin-top:6px; + color:var(--color-muted-alt); font-size:0.75rem; + position: relative; + } + .msg-footer .timestamp { + font-size:0.75rem; color:var(--color-muted-alt); + margin:0; opacity:1; + } + .msg-footer .response-metrics { + font-size:0.75rem; color:var(--color-muted-alt); + transition: color .15s; + } + .msg-footer .response-metrics:hover { color:var(--fg); } + .agent-thread > .agent-thread-footer { + margin-top: 4px; + margin-left: 2px; + } + /* Keep the context ring and percentage together at the footer's left edge. */ + .ctx-ring { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 0; + line-height: 0; + opacity: 0.6; + cursor: default; + transition: opacity 0.15s; + --ctx-stroke: var(--color-muted, #888); + } + .ctx-ring .ctx-ring-pct { + color: var(--color-muted, #888); + transition: color 0.1s ease; + } + .ctx-ring svg circle { + transition: stroke 0.1s ease; + } + .ctx-ring:hover { + opacity: 1; + --ctx-stroke: var(--ctx-color); + } + .ctx-ring:hover .ctx-ring-pct { + color: var(--ctx-color); + } + .ctx-ring-pct { + font-size: 0.7rem; + font-weight: 600; + line-height: 1; + } + /* Context detail popup */ + .ctx-detail-popup { + position: fixed; + z-index: 200; + background: var(--panel, var(--bg)); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + min-width: 220px; + max-width: 280px; + font-size: 0.85rem; + color: var(--fg); + } + .ctx-bar-wrap { + width: 100%; + height: 6px; + background: var(--border); + border-radius: 4px; + overflow: hidden; + } + .ctx-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s; + } + .ctx-compact-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 6px 0; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 0.8rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + } + .ctx-compact-btn:hover { + border-color: var(--accent, var(--red)); + color: var(--accent, var(--red)); + } + .ctx-compact-btn:disabled { + opacity: 0.5; + cursor: default; + } + .compact-wave { + display: inline-block; + color: var(--accent, var(--red)); + letter-spacing: 1px; + font-size: 0.9em; + } + /* Memory-used indicator pill */ + .memory-used-pill { + display: inline-flex; + align-items: center; + background: var(--panel); + border: 1px solid var(--border); + color: var(--fg); + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; + white-space: nowrap; + position: relative; + z-index: 1; + } + .memory-used-pill:hover { opacity: 1; background: var(--border); } + /* Nudge label text 1px down so it visually centers with the icon. */ + .memory-used-pill-text { position: relative; top: 1px; display: inline-block; } + .memory-used-detail { + position: fixed; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + min-width: 200px; + max-width: min(360px, calc(100vw - 16px)); + max-height: 50vh; + overflow-y: auto; + z-index: 300; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + font-size: 0.75rem; + } + .memory-used-row { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 3px 0; + line-height: 1.3; + } + .memory-used-row + .memory-used-row { + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + } + .memory-used-badge { + flex-shrink: 0; + font-size: 0.7rem; + width: 16px; + text-align: center; + } + .memory-used-badge.pinned { color: var(--red); } + .memory-used-badge.recalled { color: var(--fg); opacity: 0.5; } + .memory-used-text { + color: var(--fg); + opacity: 0.85; + } + + .msg-actions { + display:inline-flex; align-items:center; gap:4px; + } + .footer-copy-btn { + background:none; border:none; + color:var(--color-muted-alt); cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + display: inline-flex; align-items: center; justify-content: center; + } + .footer-copy-btn:hover { color:var(--accent); } + .footer-open-gallery-btn { + gap: 4px; + font-size: 11px; + } + /* Delete action — same chrome as copy/download/edit but hover reveals + the destructive red tint so the user can tell it's not a benign op. */ + .footer-delete-btn:hover { color: var(--red); } + .regen-btn { + background:none; border:none; + color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .regen-btn:hover { color:var(--accent); } + .fork-btn { + background:none; border:none; + color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .fork-btn:hover { color:var(--accent); } + .msg-action-btn { + background:none; border:none; + color:var(--muted, var(--color-muted-alt)); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .msg-action-btn:hover { color:var(--accent); } + .msg-action-btn .resend-message-icon { + display:block; + width:13px; + height:13px; + transform:translateY(1px); + } + .msg-action-btn[data-action="shorten"] { position:relative; top:1px; font-size:1.25rem; } + .msg-delete-btn { position:relative; top:1px; } + .msg-delete-btn:hover { color:var(--red); } + .msg-more-btn { + font-size:0.7rem; letter-spacing:0.5px; + } + .msg-overflow-menu { + position:fixed; + background:var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:4px; + box-shadow:0 4px 16px rgba(0,0,0,0.4); + z-index:100; + min-width:150px; + } + .msg-overflow-item { + display:flex; + align-items:center; + gap:6px; + width:100%; + background:none; + border:none; + border-bottom:1px solid color-mix(in srgb, var(--border) 40%, transparent); + color:var(--fg); + font-size:0.8rem; + padding:5px 8px; + border-radius:4px; + cursor:pointer; + text-align:left; + font-family:inherit; + transition:background .1s; + } + .msg-overflow-item:last-child { border-bottom:none; } + .msg-overflow-item:hover { + background:color-mix(in srgb, var(--red) 8%, transparent); + } + .overflow-icon { + width:16px; + text-align:center; + flex-shrink:0; + font-size:1rem; + } + .overflow-icon .resend-message-icon { + display:block; + width:13px; + height:13px; + margin:0 auto; + } + .variant-nav { + display:inline-flex; + align-items:center; + gap:0px; + margin-left:auto; + font-size:0.85em; + font-family:inherit; + opacity:0.6; + } + .variant-divider { + opacity:0.25; + margin:0 4px 0 2px; + } + .variant-tag { + font-size:1.1em; + opacity:0.8; + margin-right:2px; + position:relative; + top:-1px; + } + .variant-tag-scissors { + top:-1px; + } + /* The ✂ "Rewrite shorter" action button glyph sits a touch low against the + other footer icons — nudge it up 2px. */ + .msg-action-btn[data-action="shorten"] { + position: relative; + top: -2px; + } + /* The "?" Explain-simpler glyph also sits slightly low — nudge it up 1px. */ + .msg-action-btn[data-action="explain"] { + position: relative; + top: -1px; + } + .variant-btn { + background:none; + border:none; + color:var(--fg); + cursor:pointer; + padding:4px 6px; + font-size:1em; + font-family:inherit; + opacity:0.7; + line-height:1; + } + .variant-btn:hover { opacity:1; color:var(--fg); } + .variant-btn:disabled { opacity:0.3; cursor:default; } + .variant-num { + background:none; + border:none; + color:var(--fg); + cursor:pointer; + padding:4px 4px; + font-size:1em; + font-family:inherit; + opacity:0.7; + line-height:1; + } + .variant-num:hover { opacity:1; color:var(--fg); } + .variant-num:disabled { opacity:0.5; cursor:default; } + .variant-slash { + opacity:0.4; + font-size:1em; + } + .continue-separator { + display:inline; + opacity:0.35; + font-size:0.85em; + font-style:italic; + } + .stopped-indicator { + color:var(--red); + margin-top:8px; + font-size:0.85em; + opacity:0.8; + display:flex; + align-items:center; + gap:8px; + flex-wrap:wrap; + } + + /* Message edit UI */ + .msg-edit-textarea { + background:var(--bg); + color:var(--fg); + border:1px solid var(--border); + border-radius:6px; + padding:10px; + font-family:inherit; + font-size:inherit; + line-height:1.6; + resize:vertical; + box-sizing:border-box; + } + .msg-edit-textarea:focus { outline:1px solid var(--hl-function, #61afef); border-color:var(--hl-function, #61afef); } + .msg-edit-bar { + display:flex; + gap:8px; + margin-top:6px; + } + .msg-edit-save, .msg-edit-cancel { + background:var(--bg); + color:var(--fg); + border:1px solid var(--border); + border-radius:6px; + padding:4px 14px; + cursor:pointer; + font-size:0.85em; + } + .msg-edit-save:hover { border-color:var(--color-save-green, #4caf50); color:var(--color-save-green, #4caf50); } + .msg-edit-cancel:hover { border-color:var(--red); color:var(--red); } + + /* Edited indicator — similar to stopped-indicator */ + .edited-indicator { + color:var(--fg); + margin-top:8px; + font-size:0.85em; + opacity:0.4; + font-style:italic; + } + .continue-btn { + background:none; + border:none; + color:var(--fg); + opacity:0.5; + cursor:pointer; + font-size:2.6em; + padding:2px 2px 0; + line-height:1; + } + .continue-btn:hover { + opacity:0.8; + } + .continue-btn.resume-btn { + display:inline-flex; + align-items:center; + justify-content:space-between; + gap:8px; + padding:3px 8px 3px 5px; + border:none; + border-radius:5px; + font-size:0.85em; + font-weight:400; + line-height:1.2; + position:relative; + top:2px; + } + .continue-btn.resume-btn:hover { + border:none; + } + .continue-btn.resume-btn .resume-btn-icon { + display:block; + width:16px; + height:16px; + flex:0 0 16px; + margin-left:auto; + } + + /* Round-cap "Continue" affordance — a cohesive centered pill at the chat + bottom (not the bare red in-message stopped style). */ + .rounds-exhausted { + justify-content:center; + gap:12px; + width:fit-content; + max-width:90%; + margin:14px auto 4px; + padding:7px 8px 7px 16px; + border:1px solid var(--border); + border-radius:999px; + background:color-mix(in srgb, var(--fg) 4%, transparent); + opacity:1; + } + .rounds-exhausted .rounds-exhausted-label { + color:color-mix(in srgb, var(--fg) 60%, transparent); + font-size:0.95em; + } + .rounds-exhausted .continue-btn { + font-size:0.9em; + font-weight:600; + opacity:1; + color:var(--bg); + background:var(--accent, var(--red)); + border-radius:999px; + padding:4px 14px; + line-height:1.3; + } + .rounds-exhausted .continue-btn:hover { + opacity:0.88; + } + .ctx-indicator { + display:inline-flex; align-items:center; gap:1px; + font-size:0.75rem; + } + .ctx-popup { + position:fixed; + z-index:250; + background:var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:11px 13px 12px; + font-size:0.78rem; + color:var(--fg); + box-shadow:0 8px 24px rgba(0,0,0,0.4); + min-width:250px; + max-width:min(360px, calc(100vw - 16px)); + line-height:1.45; + } + .ctx-label { + display:block; + width:auto; + color:var(--color-muted-alt); + font-size:0.75rem; + } + .ctx-popup-title { + display:flex; + align-items:center; + gap:6px; + margin-bottom:8px; + padding-bottom:7px; + border-bottom:1px solid color-mix(in srgb, var(--border) 65%, transparent); + font-weight:650; + letter-spacing:.01em; + } + .ctx-popup-title::before { + content:''; + width:6px; + height:6px; + flex:0 0 6px; + border-radius:50%; + background:var(--accent, var(--red)); + box-shadow:0 0 0 3px color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + } + .ctx-stat-section { + display:flex; + flex-direction:column; + gap:4px; + } + .ctx-stat-section + .ctx-stat-section { + margin-top:9px; + padding-top:8px; + border-top:1px solid color-mix(in srgb, var(--border) 45%, transparent); + } + .ctx-stat-row { + display:grid; + grid-template-columns:minmax(0, 1fr) auto; + align-items:baseline; + gap:14px; + min-height:18px; + } + .ctx-stat-row .ctx-label { + white-space:nowrap; + } + .ctx-stat-value { + color:var(--fg); + text-align:right; + font-variant-numeric:tabular-nums; + white-space:nowrap; + } + .edit-btn { + background:none; border:none; + color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .edit-btn:hover { color:var(--accent); } + .edit-textarea { + width:100%; background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + padding:8px; font-family:inherit; font-size:0.95rem; + resize:vertical; outline:none; + min-height:80px; + } + .edit-textarea:focus { border-color:var(--red); } + .edit-save-btn, .edit-cancel-btn { + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + padding:4px 12px; cursor:pointer; font-size:0.8rem; + display:inline-flex; align-items:center; justify-content:center; gap:5px; + } + .edit-save-btn:hover { background:var(--panel); } + .edit-cancel-btn:hover { background:var(--panel); } + pre .copy-code { + position:absolute; right:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .copy-code { top:6px; } + pre .copy-code.bottom { top:auto; bottom:6px; } + pre:hover .copy-code { opacity:0.7; } + pre .copy-code:hover { opacity:1; } + pre .copy-code.copied { + opacity: 1; + color: var(--color-save-green, #4caf50); + border-color: var(--color-save-green, #4caf50); + background: color-mix(in srgb, var(--color-save-green, #4caf50) 18%, var(--bg)); + animation: code-copy-pulse 0.36s cubic-bezier(0.34, 1.56, 0.64, 1); + } + @keyframes code-copy-pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.15); } + 100% { transform: scale(1); } + } + /* Slim text-button variant: swap "Copy" → "✓ Copied" via the + ::before content while still inheriting the green flash + pulse. */ + pre.pre-compact .copy-code.copied::before { content: '✓ Copied'; } + + /* Edit code button — positioned left of copy button */ + pre .edit-code { + position:absolute; right:42px; top:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .edit-code.bottom { top:auto; bottom:6px; } + pre:hover .edit-code { opacity:0.7; } + pre .edit-code:hover { opacity:1; } + /* When the edit-code button is in "save" mode (checkmark), use the + theme accent so it matches the EDITING outline + label that are + also accent-coloured — clearer that this is the confirm action. */ + pre .edit-code.active { + opacity: 1; + color: var(--accent-primary, var(--red)); + border-color: var(--accent-primary, var(--red)); + background: color-mix(in srgb, var(--accent-primary, var(--red)) 12%, var(--bg)); + } + pre .use-code { + position:absolute; right:42px; top:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .use-code.bottom { top:auto; bottom:6px; } + pre:hover .use-code { opacity:0.7; } + pre .use-code:hover { opacity:1; } + pre .use-code.used { + opacity: 1; + color: var(--color-save-green, #4caf50); + border-color: var(--color-save-green, #4caf50); + background: color-mix(in srgb, var(--color-save-green, #4caf50) 18%, var(--bg)); + animation: code-copy-pulse 0.36s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .setup-trigger-link, .setup-clickable-provider, .setup-clickable-code { + transition: color 0.15s ease, opacity 0.15s ease; + } + .setup-trigger-link:hover, + .setup-clickable-provider:hover, + .setup-clickable-code:hover { + color: var(--accent, var(--red)) !important; + opacity: 0.9; + } + + /* Tapping the code body (not a button) toggles the overlay buttons off so + they stop covering the text on touch screens. Tap again to bring back. */ + pre.buttons-hidden .copy-code, + pre.buttons-hidden .edit-code, + pre.buttons-hidden .run-code { opacity:0 !important; pointer-events:none !important; } + + /* Editing state — subtle border on the code block */ + /* Editing state: was a 1px subtle outline that was almost invisible on + mobile, so users couldn't tell their tap-to-edit had actually engaged. + Use the accent colour + a tinted background so it reads at a glance. */ + pre.editing { + outline: 2px solid var(--accent-primary, var(--red)); + outline-offset: -2px; + background: color-mix(in srgb, var(--accent-primary, var(--red)) 6%, var(--bg)) !important; + } + pre.editing code.editing { outline:none; cursor:text; } + pre.editing::before { + content: 'EDITING'; + position: absolute; top: 0; left: 0; + padding: 2px 8px; + font-size: 9px; font-weight: 700; letter-spacing: 0.5px; + background: var(--accent-primary, var(--red)); + color: #fff; + border-radius: 0 0 4px 0; + z-index: 2; + pointer-events: none; + } + + /* Run code button — positioned left of edit button */ + pre .run-code { + position:absolute; right:78px; top:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .run-code.bottom { top:auto; bottom:6px; } + pre:hover .run-code { opacity:0.7; } + pre .run-code:hover { opacity:1; color:var(--hl-function, #61afef); border-color:var(--hl-function, #61afef); } + + /* Compact (single-line) code blocks: slim buttons so the row doesn't + double the height of a 1-line bash. Copy is text ("Copy" → "✓ Copied"), + Run and Edit keep their icons but at smaller sizes. Edit swaps to + a "Save" text label when its .active state is on. */ + pre.pre-compact { padding-right: 200px; min-height: 0; } + pre.pre-compact .copy-code, + pre.pre-compact .edit-code, + pre.pre-compact .run-code { + height: 20px; + padding: 0; + font-size: 10px; + font-weight: 500; + line-height: 20px; + top: 3px; + } + /* Copy: text-only, hide SVG */ + pre.pre-compact .copy-code { + width: auto; + padding: 0 8px; + right: 6px; + gap: 0; + } + pre.pre-compact .copy-code svg { display: none; } + pre.pre-compact .copy-code::before { content: 'Copy'; } + pre.pre-compact .copy-code.copied::before { content: '✓ Copied'; } + /* Edit: icon + "Edit" label, swap to "Save" when editing */ + pre.pre-compact .edit-code { + width: auto; + padding: 0 8px 0 6px; + gap: 3px; + right: 64px; + } + pre.pre-compact .edit-code svg { width: 12px; height: 12px; } + pre.pre-compact .edit-code::after { content: 'Edit'; } + pre.pre-compact .edit-code.active::after { content: 'Save'; } + /* Run: icon + "Run" label */ + pre.pre-compact .run-code { + width: auto; + padding: 0 8px 0 6px; + gap: 3px; + right: 126px; + } + pre.pre-compact .run-code svg { width: 12px; height: 12px; } + pre.pre-compact .run-code::after { content: 'Run'; } + /* Bottom-positioned slim buttons (when pre is near the top of the + viewport, the existing JS toggles .bottom to flip them down). */ + pre.pre-compact .copy-code.bottom, + pre.pre-compact .edit-code.bottom, + pre.pre-compact .run-code.bottom { top: auto; bottom: 3px; } + + /* Touch devices: no hover, so always show copy/run/edit buttons */ + @media (hover: none) { + pre .copy-code { opacity:0.7; } + pre .edit-code { opacity:0.7; } + pre .run-code { opacity:0.7; } + } + + /* Code runner output panel */ + .code-runner-output { + position:relative; + border:1px solid var(--border); border-top:2px solid var(--hl-function, #61afef); + border-radius:0 0 4px 4px; + background:var(--bg); + margin:-4px 0 8px 0; + padding:8px 12px; + max-height:400px; + overflow:auto; + } + + /* Chat-native visual explanations render in an isolated preview. */ + .chat-svg-visual { + width: 100%; + max-width: 760px; + margin: 12px 0 16px; + } + .chat-svg-preview { + display: block; + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + } + .code-runner-pre { + margin:0; padding:0; + font-family:'Fira Code', 'Courier New', monospace; + font-size:0.9em; line-height:1.5; + white-space:pre-wrap; word-break:break-word; + color:var(--fg); + background:none !important; + border:none !important; + } + .code-runner-error { color:var(--red); } + .code-runner-loading { font-style:italic; color:var(--red); padding:4px 0; } + .code-runner-close { + position:absolute; top:4px; right:4px; + background:none; border:none; color:var(--fg); + cursor:pointer; opacity:0.5; font-size:14px; padding:2px 6px; + } + .code-runner-close:hover { opacity:1; } + /* Labeled copy pill — pinned top-right INSIDE the run-output panel, not + in a separate footer. Panel is position:relative so absolute works. */ + .code-runner-copy-inline { + position: absolute; top: 6px; right: 32px; /* sits LEFT of the X close (top:4 right:4 ~24px wide) */ + z-index: 2; + background: var(--panel); color: var(--fg); + border: 1px solid var(--border); border-radius: 6px; + padding: 3px 10px; font-size: 11px; cursor: pointer; + display: inline-flex; align-items: center; + transition: border-color 0.15s, color 0.15s, background 0.15s; + } + .code-runner-copy-inline:hover { + border-color: var(--accent-primary, var(--red)); + color: var(--accent-primary, var(--red)); + } + /* Reserve room on the right so the output text doesn't slide under + either the Copy pill or the Close X. Applies to both the chat run + panel AND the document panel (doc-run-output reuses the same children). */ + .code-runner-output, + .doc-run-output { padding-right: 110px; } + + .toast { + position:fixed; + top: 16px; right: 16px; + left: auto; bottom: auto; + background:var(--panel); color:var(--fg); + border:1px solid color-mix(in srgb, var(--accent) 30%, transparent); + border-left: 3px solid var(--accent); + padding:9px 9px 9px 10px; border-radius:8px; font-size:12px; opacity:0; + /* Off-screen to the right by default; .show slides to 0; + removing .show transitions to -120% (off-screen left). */ + transform: translateX(120%); + transition: opacity .35s cubic-bezier(0.22, 1, 0.36, 1), + transform .45s cubic-bezier(0.22, 1, 0.36, 1); + /* Toasts must stay above the chat-context settings popup so a setting + change is visible while that popup remains open. */ + z-index: 10070; pointer-events: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + backdrop-filter: blur(12px); + max-width: min(420px, calc(100vw - 32px)); + min-width: min(240px, calc(100vw - 32px)); + min-height: 40px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + column-gap: 8px; + box-sizing: border-box; + } + .toast.show { opacity:1; transform: translateX(0); } + .toast:not(:has(.toast-checkmark, .toast-whirlpool, .toast-error-icon)) { + grid-template-columns: minmax(0, 1fr) auto; + } + .toast-message { + min-width: 0; + padding: 3px 0 2px; + position: relative; + top: 0; + line-height: 1.35; + overflow-wrap: anywhere; + } + .toast.toast-ai-reply-progress .toast-message { + top: 2px; + } + .toast.toast-ai-reply-result .toast-message { + top: 0; + } + .toast.toast-sending .toast-message { + top: 2px; + } + .toast.toast-message-sent .toast-message { + top: 4px; + } + .toast-actions { + display: inline-flex; + align-items: center; + align-self: start; + gap: 3px; + margin-left: 2px; + } + .toast-action-btn { + min-height: 26px; + padding: 3px 7px 3px 9px; + border: 1px solid color-mix(in srgb, var(--fg) 34%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--fg) 4%, transparent); + color: var(--fg); + cursor: pointer; + font: inherit; + font-weight: 600; + line-height: 1; + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 5px; + transition: border-color 0.15s ease, background 0.15s ease; + } + .toast-action-btn:hover { + border-color: color-mix(in srgb, var(--accent) 65%, var(--border)); + background: color-mix(in srgb, var(--accent) 10%, transparent); + } + .toast-action-icon { display: inline-flex; align-items: center; } + .toast-action-hint { + padding: 2px 4px; + border: 0; + border-radius: 3px; + background: color-mix(in srgb, var(--fg) 9%, transparent); + color: inherit; + font: 500 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.2px; + opacity: 0.62; + text-transform: uppercase; + pointer-events: none; + } + .toast .toast-checkmark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-top: 5px; + margin-right: 0; + position: relative; + top: 4px; + color: var(--accent, var(--red)); + vertical-align: -3px; + transform: scale(0.65); + opacity: 0; + animation: toastCheckPop 360ms cubic-bezier(0.2, 0.9, 0.25, 1.25) forwards; + } + .toast .toast-checkmark svg polyline { + stroke-dasharray: 24; + stroke-dashoffset: 24; + animation: toastCheckDraw 420ms ease-out 120ms forwards; + } + .toast .toast-whirlpool { + position: relative; + top: 4px; + } + @keyframes toastCheckPop { + 0% { opacity: 0; transform: scale(0.65); } + 65% { opacity: 1; transform: scale(1.16); } + 100% { opacity: 1; transform: scale(1); } + } + @keyframes toastCheckDraw { + to { stroke-dashoffset: 0; } + } + .toast-close-btn { + margin-left: 0; + padding: 0; + width: 22px; + height: 22px; + line-height: 1; + border: none; + background: none; + color: var(--fg); + opacity: 0.5; + cursor: pointer; + font-size: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + pointer-events: auto; + flex-shrink: 0; + transition: transform 0.22s ease, opacity 0.15s ease, background 0.15s ease; + } + .toast-error-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + margin-top: 4px; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 10px; + font-weight: 800; + line-height: 1; + } + .toast-close-btn:hover { + opacity: 1; + transform: rotate(90deg); + background: color-mix(in srgb, var(--fg) 8%, transparent); + } + .toast.exiting { + opacity: 0; + transform: translateX(-120%); + } + .toast.error { + border-color: color-mix(in srgb, var(--color-error) 40%, transparent); + border-left-color: var(--color-error); + color: var(--color-error); + } + /* When the notes panel is docked to the right, the toast enters from the + panel side. ui.js supplies the exact right edge and available width. */ + body:has(.notes-pane.modal-right-docked) .toast { + transform: translateX(-120%); + } + body:has(.notes-pane.modal-right-docked) .toast.show { transform: translateX(0); } + body:has(.notes-pane.modal-right-docked) .toast.exiting { transform: translateX(120%); } + /* Notes-only panel-aware placement. Documents and Email retain the normal + top-right toast position. */ + .toast.toast-panel-offset { transform: translateX(120%); } + .toast.toast-panel-offset.show { transform: translateX(0); } + .toast.toast-panel-offset.exiting { transform: translateX(-120%); } + @media (max-width: 768px) { + .toast { + top: 12px; + right: 12px; + max-width: calc(100vw - 24px); + /* Receive touches so the swipe-to-dismiss gesture works (desktop keeps + pointer-events:none so the toast never blocks clicks). Horizontal pan + only, so it doesn't fight page scroll. */ + pointer-events: auto; + touch-action: pan-x; + } + .toast-action-hint { display: none; } + } + + .stop-btn { + position:absolute; top:2px; right:2px; + background:var(--panel); + color:var(--fg); + border:1px solid var(--fg); + font-family:inherit; + font-size:1em; line-height:1; padding:2px 5px; + cursor:pointer; + } + .small-note { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-top:4px; } + .row-end { justify-content:flex-end; } + .model-chat-btn { + height:32px; padding:0 10px; margin-left:auto; + } + /* Nudge the "+ Chat" label down 1px to sit centered in the button. */ + .model-chat-btn-label { + position: relative; + top: 1px; + } + .openai-row { + display:flex; align-items:center; gap:6px; + } + .models-row { + display:flex; align-items:center; gap:6px; border:1px solid var(--border); padding:4px; margin:4px 0; border-radius: 4px; + } + .models-row .grow, + .models-row select { + flex:1; + display:flex; + align-items:center; + font-size: 9.75px; + } + .model-fav-btn { + width: 8px; height: 8px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, var(--fg) 22%, transparent); + flex-shrink: 0; + cursor: pointer; + transition: all 0.15s; + position: relative; + margin-left: 4px; + } + .model-fav-btn::before { + content: ''; + position: absolute; + top: -10px; left: -10px; right: -10px; bottom: -10px; + } + .model-fav-btn:hover { + border-color: var(--fg); + background: color-mix(in srgb, var(--fg) 27%, transparent); + transform: scale(1.3); + } + .model-fav-btn.active { + background: var(--fg); + border-color: var(--fg); + } + .model-fav-btn.active:hover { + opacity: 0.6; + } + .model-search-input { + width: 100%; + padding: 6px 10px; + margin-bottom: 4px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--fg); + font-family: inherit; + font-size: 0.8rem; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s; + } + .model-search-input:focus { + border-color: var(--red); + } + .model-search-input::placeholder { + color: color-mix(in srgb, var(--fg) 30%, transparent); + } + .models-row button { + font-size: 9px; + height: 24px; + padding: 0 8px; + } + @media (max-width:768px){ + .box { max-height:none; } + .chat-container { padding:10px; flex:1; margin-top:0; padding-top:42px; min-height:0; } + .scroll-nav-btn { width:44px; height:44px; font-size:14px; margin-bottom:0; } + .send-btn { width:48px; height:48px !important; border-radius:12px; } + .send-btn svg { width:22px; height:22px; } + #compare-toggle-btn { display:none !important; } + .section[draggable] { -webkit-user-drag:none; } + .drag-handle, .item-drag-handle, .folder-drag-handle { display:none !important; } + /* Sidebar overlays chat on mobile */ + .sidebar { + position: fixed !important; + top: 0; bottom: 0; left: 0; + z-index: 400; + width: 80% !important; + max-width: 340px; + box-shadow: 4px 0 20px rgba(0,0,0,0.5); + transition: transform 0.25s ease, opacity 0.25s ease !important; + opacity: 1 !important; + overflow: visible !important; + /* Force opaque background — Firefox mobile renders the + backdrop-filter+background combo as semi-transparent on + the slide-out drawer, letting the chat input model picker + label show through the sidebar. */ + /* Keep the drawer tied to the Sidebar color picker. This used to + force --panel on mobile, leaving only the header responsive to + --sidebar-bg. */ + background: var(--sidebar-bg, var(--panel)) !important; + backdrop-filter: none !important; + } + .sidebar.hidden { + width: 80% !important; + transform: translateX(-100%); + pointer-events: none; + overflow: hidden !important; + } + .sidebar.right-side.hidden { + transform: translateX(100%); + } + .sidebar.right-side { + left: auto; right: 0; + box-shadow: -4px 0 20px rgba(0,0,0,0.5); + } + html.ody-sidebar-off .sidebar, + html.ody-mobile-startup-sidebar-hidden .sidebar { + transform: translateX(-100%) !important; + pointer-events: none !important; + opacity: 0 !important; + } + html.ody-sidebar-off .sidebar.right-side, + html.ody-mobile-startup-sidebar-hidden .sidebar.right-side { + transform: translateX(100%) !important; + } + html.ody-sidebar-off .icon-rail, + html.ody-mobile-startup-sidebar-hidden .icon-rail { + display: none !important; + } + /* Backdrop behind sidebar */ + #sidebar-backdrop { + position: fixed; + inset: 0; + z-index: 390; + background: rgba(0,0,0,0.4); + opacity: 0; + pointer-events: none; + transition: opacity 0.35s ease; + } + #sidebar-backdrop.visible { opacity: 1; pointer-events: auto; } + /* Elastic overscroll on sidebar */ + .sidebar-inner { + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: auto; + overflow-y: scroll !important; + padding-bottom: 40px; + } + .sidebar:not(.hidden) { + overscroll-behavior: auto; + } + /* Sidebar header — room for hamburger */ + .sidebar-header { + padding: 18px 12px 8px 12px; + min-height: 44px; + } + .sidebar-brand-title { + font-size: 1.2rem; + left: 0 !important; + } + /* Sidebar inner — bigger spacing */ + .sidebar-inner { + padding: 12px 10px 12px !important; + gap: 4px !important; + } + /* Section headers — match list item sizing */ + .section-header-flex { + padding: 12px 10px !important; + height: 48px !important; + border-radius: 8px; + box-sizing: border-box; + } + .section-header-flex h4, + .section-header-flex .section-title { + font-size: 14px !important; + gap: 11px !important; + } + .section-icon, + .sidebar-action-icon { + width: 16px !important; + height: 16px !important; + left: 0 !important; + } + /* List items — bigger touch targets, bigger text */ + .list-item { + padding: 12px 10px !important; + min-height: 48px; + font-size: 14px; + border-radius: 8px; + gap: 10px !important; + } + .list-item .grow { + font-size: 14px !important; + } + /* Search, New Chat & Assistant */ + #sidebar-search-btn, + #sidebar-new-chat-btn, + .sidebar-assistant-entry { + padding: 12px 10px !important; + min-height: 48px !important; + } + #sidebar-search-btn .grow, + #sidebar-new-chat-btn .grow, + .sidebar-assistant-entry .grow { + font-size: 14px !important; + left: 0 !important; + } + /* Section separator — more breathing room */ + .section { + margin-top: 4px; + border-radius: 8px; + } + /* Wave accent — slightly bigger on mobile */ + .section-header-flex h4::before { + height: 18px; + } + /* Compact top bar on mobile — align with sidebar header */ + .chat-top-bar { + padding: 1px 8px; + min-height: 14px; + margin-top: -31px; + padding-top: 31px; + } + .chat-top-bar .chat-new-btn { display: none; } + .chat-meta-overlay { + font-size: 0.65em; + left: 50% !important; + right: auto !important; + top: 50%; + transform: translate(-50%, -50%); + position: absolute; + width: min(calc(100vw - 104px), 320px); + max-width: calc(100vw - 104px); + overflow: hidden; + justify-content: center; + } + .chat-meta-overlay #current-meta { + max-width: 100%; + } + .chat-meta-overlay .export-dl-btn { display: inline-flex; } + #export-compact-btn { + position: relative; + left: -2px; + } + /* Incognito — smaller on mobile */ + .incognito-btn { + padding: 4px 10px; + font-size: 10px; + } + /* Keep the active Nobody indicator inside the chat header on mobile. + Pinning it next to the hamburger made it collide with the title/splash + on tight screens and detached it from the chat state it represents. */ + .incognito-indicator { + display: none !important; + } + /* Icon rail on mobile — hidden by default, shown in mini-sidebar state */ + .icon-rail { display: none !important; } + .icon-rail.mobile-mini { + display: flex !important; + position: fixed; + top: 0; bottom: 0; left: 0; + z-index: 200; + width: 48px; + box-shadow: 2px 0 12px rgba(0,0,0,0.4); + } + .icon-rail.mobile-mini.right-side { + left: auto; right: 0; + box-shadow: -2px 0 12px rgba(0,0,0,0.4); + } + /* Chat bubbles — AI stretches full width, user stays compact */ + .msg { font-size: 0.85em; } + .msg .body { font-size: 0.9em; } + .msg-user { max-width: 90% !important; margin-left: auto; margin-right: 4px; } + .msg-ai, .agent-thread { width: 100% !important; max-width: 100% !important; margin: 8px 0; } + /* Prevent inner scrollable elements from trapping vertical scroll */ + .msg pre, + .agent-tool-output pre, + .agent-thread-cmd, + .msg details { + overflow-y: hidden !important; + max-height: none !important; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-y pan-x; + } + /* Models row — match list items */ + .models-row { + padding: 12px 10px; + min-height: 48px; + } + /* Input icon buttons — bigger touch targets */ + .input-icon-btn { + padding: 10px; + min-width: 44px; + min-height: 44px; + } + /* Tool indicators — icon only on mobile (hide text, keep x) */ + .tool-indicator > span { + display: none !important; + } + .tool-indicator .tool-indicator-x { + display: inline-block !important; + opacity: 0.6; + } + /* Hamburger — always right on mobile */ + .hamburger-btn { + width: 44px; + height: 44px; + top: 6px; + left: auto !important; + right: 4px !important; + -webkit-tap-highlight-color: transparent; + } + .hamburger-btn:hover, + .hamburger-btn:active { + background: none !important; + border: none !important; + box-shadow: none !important; + } + /* New chat — always left on mobile */ + .mobile-new-chat-btn { + display: flex; + position: fixed; + top: 12px; + left: 8px; + z-index: 210; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--fg); + cursor: pointer; + opacity: 0.5; + padding: 0; + } + /* Modal close button — bigger on mobile */ + .close-btn, + .modal-close { + min-width: 44px; + min-height: 44px; + width: 44px; + height: 44px; + font-size: 14px; + } + /* Code block buttons — slightly bigger touch targets */ + pre .copy-code, + pre .edit-code, + pre .run-code { + width: 44px; + height: 44px; + } + /* Touch-friendly targets for small buttons */ + .export-dl-btn { + min-width: 44px; + min-height: 44px; + } + .section-header-btn { + min-width: 44px; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } + /* 44×44 touch buttons starting from right:6 ends at 50. ~8px gap is + enough to be distinguishable without spreading them too far apart. */ + pre .edit-code { + right: 58px; + } + pre .run-code { + right: 110px; + } + /* Dropdowns — bigger touch targets on mobile */ + .dropdown-item-compact { + padding: 12px 12px !important; + font-size: 14px !important; + min-height: 44px; + gap: 10px !important; + } + .dropdown-item-compact .dropdown-icon { + width: 18px !important; + height: 18px !important; + } + .dropdown-item-compact .dropdown-icon svg { + width: 16px !important; + height: 16px !important; + } + .dropdown, + .session-dropdown { + padding: 6px !important; + border-radius: 12px !important; + } + /* Safe area padding for notched devices */ + .chat-input-bar { + padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + } + /* Mode toggle — larger touch targets */ + .mode-toggle { + height: 34px; + border-radius: 12px; + } + .mode-toggle::before { + border-radius: 11px; + } + .mode-toggle-btn { + padding: 0 14px; + font-size: 12px; + min-height: 34px; + } + #mode-agent-btn { border-radius: 12px 0 0 12px; } + #mode-chat-btn { border-radius: 0 12px 12px 0; } + /* Diff mode — stack toolbar, bigger buttons */ + .diff-toolbar { + padding: 8px 12px; + gap: 6px; + flex-wrap: wrap; + } + .diff-toolbar-btn { + padding: 6px 12px; + font-size: 12px; + min-height: 36px; + } + .diff-chunk-btn { + width: 28px; + height: 28px; + font-size: 14px; + } + /* Document/email/gallery/research library — full-height bottom-sheet + on mobile. Keep the rounded top corners + border-top so they match + the cookbook/calendar/compare look instead of looking like raw + full-bleed panels. */ + .doclib-modal-content, + .gallery-modal-content { + width: 100vw !important; + max-width: 100vw !important; + /* vh fallback, dvh override so the modal adapts to mobile + URL-bar show/hide. Order matters: later same-specificity rule + wins, so dvh must come after vh. */ + max-height: 100vh !important; + max-height: 100dvh !important; + height: 100vh; + height: 100dvh; + border-radius: 14px 14px 0 0 !important; + border: none !important; + border-top: 1px solid var(--border) !important; + box-shadow: none !important; + padding: 6px !important; + padding-bottom: env(safe-area-inset-bottom, 6px) !important; + margin: 0 !important; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + #email-lib-modal .doclib-grid { + max-height: none; + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + /* Library modal layout: pin the header + tab strip, give the active + panel (admin-card) the remaining height, and let the grid scroll + internally. The load-more button sits below the grid as a sibling + inside admin-card so it stays visible at the bottom of the panel + without competing with the grid for scroll. */ + #doclib-modal .doclib-modal-content { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; + } + #doclib-modal .modal-header, + #doclib-modal .lib-tabs { + flex: 0 0 auto !important; + } + #doclib-modal .modal-body { + flex: 1 1 0 !important; + min-height: 0 !important; + overflow: hidden !important; + } + #doclib-modal .admin-card { + flex: 1 1 0 !important; + min-height: 0 !important; + } + /* :not(:has(.doclib-card-expanded)) scopes these to the normal list + state — when a document card is expanded, the existing expand-state + rules take over (grid claims flex:1 with overflow:hidden so the + expanded card can scroll itself). */ + #doclib-modal .doclib-grid:not(:has(.doclib-card-expanded)) { + flex: 1 1 0 !important; + min-height: 0 !important; + max-height: none !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + } + #doclib-modal .doclib-load-more { + margin: 8px auto 12px !important; + } + /* Squeeze a little more height for the preview content by tightening + the spacing inside an expanded doc card on mobile. */ + #doclib-modal .doclib-card.doclib-card-expanded, + #email-lib-modal .doclib-card.doclib-card-expanded, + #memory-modal .doclib-card.doclib-card-expanded { + gap: 2px !important; + padding: 4px 6px !important; + height: 100% !important; + } + /* Skills preview kept stopping at partial height because the flex + chain (modal → tabs → panel → admin-card → grid → card) wouldn't + reliably hand the card a full-height parent. But the skills LIST + already scrolls correctly inside #skills-list, so that grid box + already has the right bounded height (the area below the tabs). + Anchor the expanded card to THAT box with position:absolute so it + fills the list area exactly — header + tabs stay visible. + + NOTE: position:relative here is UNCONDITIONAL (not gated behind + :has(.doclib-card-expanded)). Firefox mobile builds without :has() + support never applied the gated rule, so the absolute card lost its + anchor and only filled ~50% — while Chromium/desktop-narrow worked. + Relative-when-collapsed is harmless (no abs children then). */ + #memory-modal #skills-list.doclib-grid { + position: relative !important; + } + #memory-modal .doclib-card.skill-card.doclib-card-expanded { + position: absolute !important; + inset: 0 !important; + /* Height is set in JS (skills.js _fillSkillCardHeight) as an explicit + px value — Firefox does NOT treat inset:0 stretch OR height:100% + (against the flex-sized grid) as a definite height, so grid/flex + children never filled. An explicit px height is unambiguous. */ + margin: 0 !important; + padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px)) !important; + background: var(--bg) !important; + /* Flex column. JS (_fillSkillCardHeight) sets EXPLICIT px heights on + the preview +
; in a flex column an explicit-height item
+           (flex:0 0 auto + height) is honoured. (Grid was worse here — the
+           collapsed 1fr track overrode the preview's explicit height.) */
+        display: flex !important;
+        flex-direction: column !important;
+        overflow: hidden !important;
+        border: none !important;
+        border-radius: 0 !important;
+        box-sizing: border-box !important;
+      }
+      /* Preview is the 1fr grid track — give it a definite-height flex column
+         so the 
 (flex:1) fills and the footer pins at the bottom. */
+      #memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview {
+        display: flex !important;
+        flex-direction: column !important;
+        min-height: 0 !important;
+        overflow: hidden !important;
+      }
+      /* The card now fills the screen, but a base rule (.skill-card...
+         .doclib-card-preview { flex: 0 1 auto }) sizes the preview to its
+         CONTENT — so a medium SKILL.md left the preview at ~half the card
+         (the "only 50%" the debug confirmed: card was full, content wasn't).
+         Force the preview AND the 
 to FILL the card. Extra .skill-card
+         in the selector out-specifies both the base and the generic mobile
+         rule so this wins without ambiguity. */
+      #memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      #memory-modal .doclib-card.skill-card.doclib-card-expanded .skill-md-pre {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      /* Flatten the expanded doc/email card on mobile — drop the inner
+         border + background so it doesn't read as a "subwindow" inside the
+         modal (the chat preview doesn't have that nested-card look). The
+         body prefix beats the desktop email rule later in the file that
+         paints a 2px accent border + box-shadow with !important. */
+      body #doclib-modal .doclib-card.doclib-card-expanded,
+      body #email-lib-modal .doclib-card.doclib-card-expanded,
+      body #memory-modal .doclib-card.doclib-card-expanded {
+        background: transparent !important;
+        border: none !important;
+        border-radius: 0 !important;
+        box-shadow: none !important;
+      }
+      /* Same layout pattern the chat preview uses: preview itself clips,
+         the 
 (or PDF iframe) inside owns the scroll, and the action
+         bar pins to the bottom with extra bottom padding so it floats
+         above the iOS safe-area / home-indicator strip. */
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview {
+        padding: 4px 6px 20px !important;
+        flex: 1 1 auto !important;
+        /* The preview lives inside an already bounded expanded card. Giving
+           it a viewport-sized minimum pushes its footer below that card,
+           where overflow:hidden clips it. The text child owns scrolling. */
+        min-height: 0 !important;
+        overflow: hidden !important;
+        display: flex !important;
+        flex-direction: column !important;
+        border-top: none !important;
+      }
+      /* The "Load more" button is flex-shrink:0 and sits in the panel AFTER the
+         grid, so even when a card is expanded it reserves ~40px at the panel
+         bottom — shrinking the visible grid and clipping the action bar by that
+         much (the "black bar"). The global collapse rule's max-height:0 can't
+         remove a flex-shrink:0 item, so hide it outright on expand. */
+      #doclib-modal [data-doclib-panel="documents"]:has(.doclib-card-expanded) .doclib-load-more,
+      #doclib-modal [data-doclib-panel="documents"]:has(.doclib-card-expanded) .doclib-inline-load-more {
+        display: none !important;
+      }
+      /* Small bottom padding now that the load-more no longer steals space —
+         the action bar sits low, just clearing the home-indicator safe area. */
+      #doclib-modal [data-doclib-panel="documents"] .doclib-card.doclib-card-expanded .doclib-card-preview {
+        padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)) !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .doclib-card-pdf-frame,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-body,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview .skill-md-editor {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+        overflow-y: auto !important;
+        -webkit-overflow-scrolling: touch;
+        /* Strip the code-box visual treatment so the 
 / reader body
+           doesn't read as a "sub-window" inside the modal. */
+        background: transparent !important;
+        border: none !important;
+        box-shadow: none !important;
+        padding: 0 !important;
+        margin: 0 !important;
+        border-radius: 0 !important;
+      }
+      /* The skill editor textarea keeps a light frame on mobile (it's an
+         input, not read-only text) — override the strip-down above. */
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview .skill-md-editor {
+        background: var(--bg) !important;
+        border: 1px solid var(--border) !important;
+        padding: 8px !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre code,
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview code.hljs,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre code,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview code.hljs,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre code {
+        background: transparent !important;
+        border: none !important;
+        padding: 0 !important;
+        box-shadow: none !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions {
+        padding: 6px 4px 0 !important;
+        margin-top: 4px !important;
+        flex-shrink: 0 !important;
+      }
+      /* Email reader on mobile inherits the library modal's horizontal
+         padding (the .doclib-modal-content default — 6px). No extra
+         overrides for the modal-content / modal-body / admin-card / grid
+         chain; we just clean up the inner email reader header/body
+         padding so the From / To lines align with the email subject. */
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-header,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-atts,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-body {
+        padding-left: 6px !important;
+        padding-right: 6px !important;
+      }
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-body {
+        margin-left: -6px !important;
+        margin-right: -6px !important;
+        padding-left: 12px !important;
+        padding-right: 12px !important;
+        background: var(--panel) !important;
+        box-shadow: inset 0 1px 0 color-mix(in srgb, var(--border) 65%, transparent) !important;
+      }
+      /* Mobile email-reader header: meta on the left, actions on the right
+         (two stacked rows). The two .email-reader-actions-row siblings
+         render as their own flex-row strips so primary (reply/reply-all/
+         forward) sits above secondary (AI/summary/more), instead of
+         flattening into one line that collided with the recipient chips. */
+      #email-lib-modal .email-reader-header,
+      .email-reader-tab-modal .email-reader-header,
+      .email-window-modal .email-reader-header {
+        padding: 8px 8px !important;
+        gap: 6px !important;
+        flex-direction: row !important;
+        /* Top-align meta + actions on mobile. With the two-row action
+           cluster (Summary/More above Reply/Forward/AI), align-items:
+           center pulled the meta's From:/To: rows down into the visual
+           middle of the header, leaving them visibly off-axis. */
+        align-items: flex-start !important;
+      }
+      #email-lib-modal .email-reader-actions,
+      .email-reader-tab-modal .email-reader-actions,
+      .email-window-modal .email-reader-actions {
+        display: flex !important;
+        flex-direction: column !important;
+        align-items: flex-end !important;
+        gap: 4px !important;
+        flex-shrink: 0 !important;
+        margin-left: auto !important;
+        order: 0 !important;
+      }
+      #email-lib-modal .email-reader-actions-row,
+      .email-reader-tab-modal .email-reader-actions-row,
+      .email-window-modal .email-reader-actions-row {
+        display: flex !important;
+        flex-direction: row !important;
+        flex-wrap: nowrap !important;
+        align-items: center !important;
+        justify-content: flex-end !important;
+        gap: 4px !important;
+      }
+      /* Meta on mobile: From / To / Cc each on their own row, label
+         tight to chips, chips scroll horizontally per row. */
+      #email-lib-modal .email-reader-meta,
+      .email-reader-tab-modal .email-reader-meta,
+      .email-window-modal .email-reader-meta {
+        display: flex !important;
+        flex-direction: column !important;
+        gap: 2px !important;
+        min-width: 0 !important;
+        width: 100% !important;
+      }
+      #email-lib-modal .email-reader-meta-row,
+      .email-reader-tab-modal .email-reader-meta-row,
+      .email-window-modal .email-reader-meta-row {
+        display: flex !important;
+        grid-template-columns: none !important;
+        align-items: center !important;
+        gap: 6px !important;
+        padding-right: 0 !important;
+        min-width: 0 !important;
+      }
+      #email-lib-modal .email-reader-meta-row .recipient-chips,
+      .email-reader-tab-modal .email-reader-meta-row .recipient-chips,
+      .email-window-modal .email-reader-meta-row .recipient-chips {
+        display: inline-flex !important;
+        flex-wrap: nowrap !important;
+        overflow-x: auto !important;
+        overflow-y: hidden !important;
+        max-width: 100% !important;
+        scrollbar-width: none !important;
+        gap: 4px !important;
+      }
+      #email-lib-modal .email-reader-meta-row .recipient-chips::-webkit-scrollbar,
+      .email-reader-tab-modal .email-reader-meta-row .recipient-chips::-webkit-scrollbar,
+      .email-window-modal .email-reader-meta-row .recipient-chips::-webkit-scrollbar {
+        display: none !important;
+      }
+      #email-lib-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn,
+      .email-reader-tab-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn,
+      .email-window-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn {
+        width: auto !important;
+        height: 44px !important;
+        flex: 0 0 auto !important;
+        display: inline-flex !important;
+        flex-direction: column !important;
+        align-items: center !important;
+        justify-content: center !important;
+        gap: 3px !important;
+        padding: 4px 2px !important;
+      }
+      #email-lib-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn svg,
+      .email-reader-tab-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn svg,
+      .email-window-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn svg {
+        width: 16px !important;
+        height: 16px !important;
+      }
+      /* Gallery-style label under each button — only on mobile. The
+         global rule below the @media block hides them on desktop. */
+      #email-lib-modal .reader-btn-label,
+      .email-reader-tab-modal .reader-btn-label,
+      .email-window-modal .reader-btn-label {
+        display: inline-block !important;
+        font-size: 8.5px !important;
+        font-weight: 500 !important;
+        line-height: 1 !important;
+        letter-spacing: 0.02em !important;
+        opacity: 0.75 !important;
+        white-space: nowrap !important;
+      }
+      /* List-view cards keep the framed look from their own .doclib-card /
+         .memory-item base styles — no extra grid padding here, so the
+         spacing matches the documents/library tab exactly. */
+      /* Tighten the top of the email sheet — modal header + description +
+         account row + toolbar were stacking with full desktop spacing and
+         pushed the email subjects way down. */
+      #email-lib-modal .modal-header {
+        padding: 4px 8px !important;
+        min-height: 0 !important;
+      }
+      #email-lib-modal .modal-header h4 {
+        /* Match the other tool headers (base .modal-header h4 = 1rem); was
+           13px, which read noticeably smaller than Calendar/Tasks/etc. */
+        font-size: 1rem !important;
+        line-height: 1.2 !important;
+      }
+      #email-lib-modal .modal-body {
+        gap: 4px !important;
+      }
+      #email-lib-modal .admin-card {
+        gap: 4px !important;
+      }
+      #email-lib-modal .admin-card > .doclib-desc {
+        display: none !important;
+      }
+      /* When an email is expanded the list-mode toolbar siblings are
+         already hidden by the existing :has rule. Hide the modal-header
+         entirely AND zero out the modal-content top padding so the email
+         reader claims the full sheet height. The swipe-down gesture (and
+         the dock chip) still dismiss the modal. */
+      #email-lib-modal:has(.doclib-card-expanded) .modal-header,
+      #email-lib-modal.email-reading .modal-header {
+        display: none !important;
+      }
+      #email-lib-modal:has(.doclib-card-expanded) .doclib-modal-content,
+      #email-lib-modal.email-reading .doclib-modal-content {
+        padding-top: 0 !important;
+      }
+      #email-lib-modal:has(.doclib-card-expanded) .modal-body,
+      #email-lib-modal.email-reading .modal-body {
+        gap: 0 !important;
+      }
+      /* Flatten the From / To bar so it doesn't read as a cut-off framed
+         strip with a hard background change. Remove the background, drop
+         the border-bottom to a faint divider, and let it blend with the
+         sheet. */
+      #email-lib-modal .email-reader-header {
+        background: transparent !important;
+        border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent) !important;
+      }
+      #email-lib-modal .email-card-nav-btn {
+        padding: 6px 10px !important;
+        min-width: 40px !important;
+        height: 38px !important;
+      }
+      #email-lib-modal .email-card-nav-btn svg {
+        width: 18px !important;
+        height: 18px !important;
+      }
+      /* Nudge the prev/next arrow cluster ~4px to the right so it sits
+         comfortably out at the edge instead of crowding the subject text. */
+      #email-lib-modal .email-card-nav-arrows {
+        transform: translateX(4px);
+      }
+      /* Done check — extend the actual TAP area via padding + negative
+         margin (a transparent ::before doesn't change the parent's hit
+         box). Layout stays the same height as library cards because the
+         margin cancels the padding visually, but the clickable region is
+         ~37px square. */
+      #email-lib-modal .email-card-done {
+        position: relative !important;
+        z-index: 5 !important;
+        pointer-events: auto !important;
+        touch-action: manipulation !important;
+        padding: 12px !important;
+        margin: -12px !important;
+      }
+      #email-lib-modal .email-card-done svg {
+        width: 13px !important;
+        height: 13px !important;
+      }
+      /* Make sure no overlay or pseudo intercepts the tap. */
+      #email-lib-modal .email-card-done * { pointer-events: none; }
+      /* Tighten the From / To meta bar and the email body — they had
+         desktop padding (10–14px) that wasted real estate on phones. */
+      #email-lib-modal .email-reader-header {
+        padding: 6px 4px !important;
+        gap: 4px !important;
+      }
+      #email-lib-modal .email-reader-meta {
+        font-size: 11px !important;
+      }
+      #email-lib-modal .email-reader-meta-row strong {
+        min-width: 28px !important;
+      }
+      #email-lib-modal .email-reader-atts {
+        padding: 6px 4px !important;
+      }
+      #email-lib-modal .email-reader-body {
+        padding: 8px 4px !important;
+      }
+      /* Make sure the grid claims the full admin-card height when a doc
+         is expanded — the existing rule sets flex:1, but on mobile we also
+         need a hard height fallback because the parent uses dvh which
+         desktop-tuned selectors don't always trickle through. */
+      #doclib-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid,
+      #memory-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid {
+        height: 100% !important;
+      }
+      /* Documents need the same bounded flex chain as skills when a card is
+         opened. Without this, the grid falls back to its list layout and the
+         expanded preview only grows to its content height on mobile. */
+      #doclib-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid {
+        display: flex !important;
+        flex: 1 1 0 !important;
+        flex-direction: column !important;
+        min-height: 0 !important;
+        overflow: hidden !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded {
+        flex: 1 1 auto !important;
+        height: 100% !important;
+        min-height: 0 !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-pdf-frame {
+        height: auto !important;
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+      }
+      /* Skills modal: keep the header + tab strip visible; the expanded
+         card fills the skills-list area below them via position:absolute
+         (see the #skills-list rule above). Just make sure the tab-panel +
+         its card give the list its full height. */
+      #memory-modal .memory-tab-panel[data-memory-panel="skills"] > .admin-card:has(.doclib-card-expanded) {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      .doclib-card-header {
+        padding: 10px 8px;
+        gap: 4px;
+      }
+      .doclib-card-session,
+      .doclib-card-time {
+        display: none;
+      }
+      .doclib-card-expanded-actions {
+        flex-wrap: nowrap;
+      }
+      /* Keep the footer buttons identical to the chat/research footers on
+         mobile too — those have no mobile enlargement, so neither should
+         these (otherwise the doc footer reads in a larger/different font). */
+      .doclib-card-action-btn {
+        font-size: 10px;
+        padding: 3px 8px;
+      }
+      /* On phones, keep Delete explicit while the other secondary actions
+         stay available from the kebab. */
+      #doclib-modal .doclib-card-expanded-actions > .doclib-expanded-archive-btn,
+      #doclib-modal .doclib-card-expanded-actions .doclib-expanded-clone-btn,
+      #doclib-modal .doclib-card-expanded-actions .doclib-expanded-export-btn {
+        display: none !important;
+      }
+      #doclib-modal .doclib-card-expanded-actions > .doclib-expanded-delete-btn {
+        display: inline-flex !important;
+        order: 0;
+        left: 0 !important;
+        margin-left: 0;
+      }
+      #doclib-modal .doclib-card-expanded-actions > .doclib-action-group {
+        order: 2;
+        margin-left: 6px !important;
+        min-width: 0;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions {
+        box-sizing: border-box;
+        width: 100%;
+        max-width: 100%;
+        min-width: 0;
+        overflow: visible;
+      }
+      #doclib-modal .doclib-card-expanded-actions .doclib-expanded-open-btn {
+        min-height: 30px;
+        padding: 5px 10px;
+        max-width: 100%;
+      }
+      #doclib-modal .doclib-card-expanded-actions > .doclib-expanded-mobile-more {
+        display: inline-flex;
+        order: 1;
+        width: 32px;
+        min-width: 32px;
+        min-height: 30px;
+        margin-left: auto;
+        padding: 0;
+      }
+      /* Chat top bar — adjusted for reduced height */
+      /* Suggestion nav — bigger touch targets */
+      .doc-suggestion-nav-btn {
+        padding: 6px 8px;
+        font-size: 18px;
+      }
+      .doc-suggestion-close {
+        padding: 10px 12px;
+        margin: -10px -12px;
+      }
+    }
+    #mobile-backdrop, #mobile-menu-btn { display:none !important; }
+    #sidebar-backdrop { display:none !important; }
+    /* ----- Loading spinner ----- */
+    @keyframes spin {
+      to { transform: rotate(360deg); }
+    }
+    .spinner {
+      width: 24px;
+      height: 24px;
+      margin: 8px auto;
+      border: 3px solid var(--border);
+      border-top-color: var(--red);
+      border-radius: 50%;
+      animation: spin 0.9s linear infinite;
+    }
+    /* Inline spinner for buttons */
+    .btn-spinner {
+      display: inline-block;
+      width: 12px;
+      height: 12px;
+      border: 2px solid transparent;
+      border-top-color: currentColor;
+      border-radius: 50%;
+      animation: spin 1s linear infinite;
+      margin-right: 6px;
+    }
+    .search-status {
+      font-size: 0.85em;
+      color: var(--red);
+      margin-top: 4px;
+      padding: 4px;
+      border-left: 2px solid var(--red);
+      background: color-mix(in srgb, var(--red) 5%, transparent);
+    } 
+    /* Loading indicator for messages */
+    .loading-indicator {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 10px;
+    }
+    .loading-dots {
+      display: flex;
+      gap: 4px;
+    }
+    .loading-dot {
+      width: 6px;
+      height: 6px;
+      background-color: var(--red);
+      border-radius: 50%;
+    }
diff --git a/static/css/style-part-02.css b/static/css/style-part-02.css
new file mode 100644
index 000000000..a3ceff612
--- /dev/null
+++ b/static/css/style-part-02.css
@@ -0,0 +1,6011 @@
+/* Fragment 3/9 of the former static/style.css.
+ *
+ * This is a mechanical, order-preserving slice: rules were moved verbatim
+ * and this file's position in the  order of static/index.html is what
+ * keeps the cascade identical. Reordering the links, or moving a rule between
+ * fragments, can change which of two competing declarations wins.
+ *
+ * static/index.html and the PRECACHE list in static/sw.js must list every
+ * fragment in the same order. tests/test_static_stylesheet_manifest.py and
+ * tests/test_css_computed_style_snapshot.py both fail if that drifts.
+ */
+    .loading-dot:nth-child(1) {
+      animation: loading-bounce 1.4s infinite ease-in-out both;
+    }
+    .loading-dot:nth-child(2) {
+      animation: loading-bounce 1.4s infinite ease-in-out both;
+      animation-delay: -0.32s;
+    }
+    .loading-dot:nth-child(3) {
+      animation: loading-bounce 1.4s infinite ease-in-out both;
+      animation-delay: -0.64s;
+    }
+    .session-loading-state {
+      min-height: 100%;
+      display: flex;
+      flex-direction: column;
+      justify-content: flex-start;
+      gap: 12px;
+      color: var(--fg);
+      font-size: 12px;
+      pointer-events: none;
+      position: relative;
+      padding: 16px 14px 24px;
+      box-sizing: border-box;
+      overflow: hidden;
+    }
+    .session-loading-state::before { content: none; }
+    .session-skeleton-bubble {
+      width: min(78%, 680px);
+      border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
+      border-radius: 10px;
+      background: color-mix(in srgb, var(--panel, var(--bg)) 72%, transparent);
+      padding: 10px 12px 12px;
+      box-shadow: 0 8px 22px rgba(0, 0, 0, 0.14);
+    }
+    .session-skeleton-bubble.is-user {
+      align-self: flex-end;
+      width: min(64%, 520px);
+    }
+    .session-skeleton-bubble.is-ai {
+      align-self: flex-start;
+    }
+    .session-skeleton-line {
+      position: relative;
+      overflow: hidden;
+      background: color-mix(in srgb, var(--fg) 10%, transparent);
+      border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
+      border-radius: 999px;
+    }
+    .session-skeleton-line {
+      height: 10px;
+      margin-top: 8px;
+      opacity: 0.62;
+    }
+    .session-skeleton-line::after {
+      content: '';
+      position: absolute;
+      inset: -1px;
+      transform: translateX(-120%);
+      background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--fg) 14%, transparent), transparent);
+      animation: session-skeleton-shimmer 1.25s ease-in-out infinite;
+    }
+    .session-skeleton-bubble:nth-child(2) .session-skeleton-line::after { animation-delay: 0.12s; }
+    .session-skeleton-bubble:nth-child(3) .session-skeleton-line::after { animation-delay: 0.24s; }
+    .session-skeleton-bubble:nth-child(4) .session-skeleton-line::after { animation-delay: 0.36s; }
+    @keyframes session-skeleton-shimmer {
+      100% { transform: translateX(120%); }
+    }
+    @media (max-width: 768px) {
+      .session-loading-state { padding: 12px 10px 18px; gap: 10px; }
+      .session-skeleton-bubble { width: 86%; padding: 9px 10px 11px; border-radius: 9px; }
+      .session-skeleton-bubble.is-user { width: 72%; }
+      .session-skeleton-line { height: 9px; margin-top: 7px; }
+    }
+    @keyframes loading-bounce {
+      0%, 80%, 100% {
+        transform: scale(0);
+      }
+      40% {
+        transform: scale(1);
+      }
+    }
+    /* Modal styling */
+    .modal {
+      position:fixed;
+      top:0; left:0; width:100%; height:100%;
+      background:none;
+      display:flex; align-items:center; justify-content:center;
+      z-index:250;
+      backdrop-filter:none;
+      pointer-events:none;
+    }
+    /* Cookbook always sits above Gallery so the "Serve a model in
+       Cookbook…" flow (and any other "open Cookbook from inside another
+       modal") is visible no matter which modal was opened first. */
+    #cookbook-modal { z-index: 260; }
+    .modal.hidden { display:none; }
+
+    /* Tool windows open centered in the CHAT AREA (the space right of the
+       sidebar + icon rail), rather than the full viewport.
+       We narrow the overlay to the chat area so its flex-centering lands the
+       window there; fullscreen / docked states use position:fixed so they
+       escape this narrowed overlay and still fill the screen. The
+       --sidebar-w / --icon-rail-w vars track collapse state live, so when a
+       window (e.g. Cookbook) hides the sidebar this naturally re-centers.
+       Desktop only — on mobile these are full-screen sheets. */
+    @media (min-width: 769px) {
+      #calendar-modal,
+      #gallery-modal,
+      #tasks-modal,
+      #memory-modal,
+      #doclib-modal,
+      #compare-model-overlay,
+      #research-overlay,
+      #theme-modal,
+      #settings-modal,
+      #email-lib-modal {
+        left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px));
+        width: calc(100% - (var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)));
+        box-sizing: border-box;
+        /* Slide in sync with the sidebar's 0.25s collapse/expand so the
+           centered window glides instead of jumping when the nav toggles. */
+        transition: left 0.25s ease, width 0.25s ease;
+      }
+    }
+    .modal-content {
+      background:var(--panel);
+      border:1px solid var(--border);
+      width:min(520px, 92vw); max-height:85vh; padding:10px;
+      box-sizing:border-box; font-size:14px;
+      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+      letter-spacing: -0.015em;
+      display:flex; flex-direction:column;
+      position:relative;
+      overflow-y:auto;
+      border-radius:10px;
+      box-shadow:0 8px 32px rgba(0,0,0,0.45);
+      pointer-events:auto;
+      animation: modal-enter 0.25s ease-out both;
+    }
+    .memory-modal-content,
+    .tasks-modal-content,
+    .preset-modal-content,
+    #cookbook-modal .modal-content,
+    #theme-popup,
+    .doclib-modal-content,
+    .gallery-modal-content {
+      container-type: inline-size;
+    }
+    .modal-header {
+      display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;
+      cursor:grab; user-select:none;
+      /* Pin the header (with its close button) to the top of the
+         scrollable modal-content so users can always dismiss the modal
+         even after scrolling far down — especially important on mobile
+         where the modal can be taller than the viewport. */
+      position: sticky;
+      top: 0;
+      z-index: 5;
+      /* Inherit the modal-content's background so the header matches the
+         panel body. Many tool modals set their content to var(--bg) inline
+         while this header was hard-coded to var(--panel) — making the title
+         strip a different shade in every theme. `inherit` tracks whatever
+         the content uses (var(--bg) or the default var(--panel)) and stays
+         opaque, so the sticky header still masks scrolled content. */
+      background-color: inherit;
+    }
+    .modal-header:active { cursor:grabbing; }
+    /* Edge/corner window resize (windowResize.js). While a resize is in
+       progress, suppress text selection and force the active resize cursor
+       across the whole document so it does not flicker as the pointer passes
+       over child elements mid-drag. */
+    body.window-resizing-active { user-select:none !important; }
+    body.window-resizing-active * { cursor:inherit !important; }
+    /* Suppress only TRANSITIONS while resizing so the edge tracks the cursor
+       crisply. We deliberately do NOT toggle `animation` here: toggling
+       animation off→on re-triggers the modal open-animation (a scale-in) on
+       mouseup, which both mis-measures the final size and visibly "pops" the
+       window. windowResize.js instead kills the one-shot open animation inline
+       once, in begin(). */
+    .window-resizing {
+      transition:none !important;
+    }
+    /* Cookbook opts out of the sticky header on mobile; the title bar scrolls
+       away with the content instead of following. Background is inherited from
+       the shared .modal-header rule so frosted/glass themes stay consistent. */
+    #cookbook-modal .modal-header {
+      position: static;
+      top: auto;
+    }
+    .modal-header h4 {
+      margin:0;
+      /* Push every header control (opacity slider, minimize, close) into one
+         group on the right — works whether or not optional controls like the
+         opacity slider are present, so the minimize button never floats to
+         the centre when a sibling is hidden. */
+      margin-right:auto;
+      font-size:1rem;
+      font-weight:600;
+      letter-spacing:-0.03em;
+      color:var(--red);
+    }
+    /* Headers with an optional Peek control already have the title's
+       auto-margin grouping all right-side controls. A second auto-margin on
+       the injected minimize button splits the group and makes it look
+       different from the other modal headers. */
+    .modal-header .theme-opacity-wrap + .modal-minimize-btn {
+      margin-left: 0 !important;
+    }
+    .close-btn,
+    .modal-close {
+      background:transparent;
+      color:color-mix(in srgb, var(--fg) 58%, transparent);
+      border:1px solid transparent;
+      font-size:0;
+      width:24px;
+      height:24px;
+      padding:0;
+      display:inline-flex;
+      align-items:center;
+      justify-content:center;
+      line-height:1;
+      text-indent:0;
+      cursor:pointer;
+      border-radius:999px;
+      line-height:1;
+      flex-shrink:0;
+      position:relative;
+    }
+    .close-btn > svg,
+    .modal-close > svg {
+      display:none;
+    }
+    .close-btn::before,
+    .modal-close::before {
+      content:"";
+      width:10px;
+      height:10px;
+      display:block;
+      background:currentColor;
+      -webkit-mask:
+        url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E")
+        center / contain no-repeat;
+      mask:
+        url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E")
+        center / contain no-repeat;
+    }
+    .close-btn:hover,
+    .modal-close:hover {
+      background:color-mix(in srgb, var(--fg) 10%, transparent);
+      border-color:color-mix(in srgb, var(--fg) 12%, transparent);
+      color:var(--fg);
+    }
+    /* Minimize button — sits beside the close button on every modal */
+    .minimize-btn {
+      background:transparent;
+      color:color-mix(in srgb, var(--fg) 58%, transparent);
+      border:1px solid transparent;
+      font-size:0;
+      font-weight:700;
+      width:24px;
+      height:24px;
+      padding:0;
+      display:inline-flex;
+      align-items:center;
+      justify-content:center;
+      line-height:1;
+      cursor:pointer;
+      border-radius:999px;
+      flex-shrink:0;
+      margin-left:auto; /* push to the right so it docks next to .close-btn */
+      margin-right:4px;
+    }
+    .minimize-btn::before {
+      content:"";
+      width:9px;
+      height:2px;
+      border-radius:999px;
+      background:currentColor;
+      display:block;
+      transform:translateY(2px);
+    }
+    .modal-minimize-btn.minimize-btn::before {
+      content:none;
+      display:none;
+    }
+    .minimize-btn:hover {
+      background:color-mix(in srgb, var(--fg) 10%, transparent);
+      border-color:color-mix(in srgb, var(--fg) 12%, transparent);
+      color:var(--fg);
+    }
+    @media (max-width: 768px) {
+      .minimize-btn { display: none !important; }
+      #modal-dock { display: none !important; }
+    }
+    /* Minimized modals are hidden but stay in the DOM with their state intact */
+    .modal.minimized { display:none !important; }
+    /* Bottom dock for minimized modals */
+    #modal-dock {
+      position:fixed;
+      bottom:var(--composer-clearance, 0px);
+      left:0;
+      right:0;
+      display:flex;
+      flex-wrap:wrap;
+      gap:4px;
+      padding:4px 8px;
+      z-index:240;
+      pointer-events:none;
+      justify-content:flex-start;
+    }
+    #modal-dock:empty { display:none; }
+    .modal-dock-item {
+      background:var(--panel);
+      border:1px solid var(--border);
+      border-bottom:none;
+      border-radius:6px 6px 0 0;
+      padding:4px 4px 4px 10px;
+      display:inline-flex;
+      align-items:center;
+      gap:6px;
+      cursor:pointer;
+      pointer-events:auto;
+      font-size:12px;
+      color:var(--fg);
+      max-width:220px;
+      box-shadow:0 -2px 8px rgba(0,0,0,0.25);
+      transition:background 0.15s;
+    }
+    .modal-dock-item:hover { background:var(--bg); }
+    .modal-dock-label {
+      white-space:nowrap;
+      overflow:hidden;
+      text-overflow:ellipsis;
+      max-width:180px;
+    }
+    .modal-dock-close {
+      background:transparent;
+      border:none;
+      color:var(--fg);
+      cursor:pointer;
+      font-size:14px;
+      padding:0 4px;
+      line-height:1;
+      opacity:0.6;
+    }
+    .modal-dock-close:hover { color:var(--red); opacity:1; }
+    .modal-body { flex:1; overflow-y:auto; }
+    .modal-body button { margin-top:6px; }
+    /* Styled confirm dialog — keeps backdrop */
+    #styled-confirm-overlay {
+      background:rgba(0,0,0,0.5);
+      backdrop-filter:blur(4px);
+      pointer-events:auto !important;
+      z-index: 99999 !important;
+      position: fixed !important;
+      top: 0 !important;
+      left: 0 !important;
+      width: 100% !important;
+      height: 100% !important;
+      top: 0; left: 0; width: 100%; height: 100%;
+    }
+    #styled-confirm-overlay .modal-content {
+      position: relative;
+      z-index: 10001;
+    }
+    .email-attachment-warning-modal {
+      z-index: 99999 !important;
+    }
+    .email-attachment-warning-modal .modal-content {
+      position: relative;
+      z-index: 100001;
+    }
+    .styled-confirm-box {
+      width:360px; max-width:90vw;
+      max-height:none; /* override modal-content's 85vh */
+      padding:14px 18px;
+    }
+    .styled-confirm-box .modal-header { margin-bottom:4px; }
+    .styled-confirm-box .modal-body p {
+      margin:8px 0 12px; color:var(--fg); font-size:0.92rem; line-height:1.45;
+      white-space:pre-line;
+    }
+    .styled-confirm-box .modal-footer {
+      display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
+      border-top:1px solid var(--border); margin-top:4px;
+    }
+    @media (max-width:768px) {
+      .styled-confirm-box {
+        width: 85vw;
+        padding: 12px 16px;
+        font-size: 0.88rem;
+        border-radius: 12px;
+      }
+      .styled-confirm-box .modal-header h4 { font-size: 0.9rem; }
+      .styled-confirm-box .modal-body p { font-size: 0.85rem; margin: 6px 0 10px; }
+      .styled-confirm-box .modal-footer { gap: 10px; }
+      .styled-confirm-box .confirm-btn {
+        flex: 1;
+        /* More bottom than top padding nudges the label up ~2px so it isn't
+           sitting low in the taller mobile buttons. */
+        padding: 8px 12px 12px;
+        font-size: 0.85rem;
+        border-radius: 8px;
+        text-align: center;
+      }
+    }
+    .confirm-btn {
+      /* Asymmetric padding nudges the label UP ~2px from where it was (more
+         bottom than top padding), so the confirm-dialog text isn't sitting low. */
+      padding:3px 16px 5px; border-radius:4px; font-size:0.85rem;
+      cursor:pointer; border:1px solid var(--border);
+      font-family:inherit;
+    }
+    @media (max-width: 820px) {
+      /* Mobile: flip the asymmetry to shift the text 1 px UP from
+         centre instead (the bigger touch targets on mobile read better
+         with the label sitting slightly high). */
+      .confirm-btn { padding:2px 16px 4px; }
+    }
+    .confirm-btn-secondary { background:var(--bg); color:var(--fg); }
+    .confirm-btn-secondary:hover { background:var(--border); }
+    #styled-confirm-cancel .styled-confirm-cancel-label {
+      position:relative;
+      top:2px;
+    }
+    .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color:#fff; border-color:transparent; }
+    .confirm-btn-primary:hover { filter:brightness(1.15); }
+    .confirm-btn-danger { background:var(--color-danger); color:#fff; border-color:transparent; }
+    .confirm-btn-danger:hover { background:var(--color-error); }
+        .cookbook-gguf-delete-box {
+      width:560px;
+      max-width:92vw;
+    }
+    .cookbook-gguf-delete-list {
+      display:flex;
+      flex-direction:column;
+      gap:6px;
+      max-height:42vh;
+      overflow:auto;
+      padding:2px 2px 4px;
+    }
+    .cookbook-gguf-delete-row {
+      display:grid;
+      grid-template-columns:18px minmax(0,1fr);
+      gap:7px 8px;
+      align-items:start;
+      padding:7px 8px;
+      border:1px solid var(--border);
+      border-radius:7px;
+      background:color-mix(in srgb, var(--panel, var(--bg)) 92%, var(--fg) 8%);
+      cursor:pointer;
+    }
+    .cookbook-gguf-delete-row:hover {
+      border-color:color-mix(in srgb, var(--accent-primary, var(--fg)) 45%, var(--border));
+    }
+    .cookbook-gguf-delete-cb {
+      -webkit-appearance:none;
+      appearance:none;
+      width:8px !important;
+      height:8px !important;
+      min-width:8px;
+      min-height:8px;
+      padding:0;
+      margin:4px 0 0;
+      border:1px solid var(--border);
+      border-radius:50%;
+      background:transparent;
+      box-sizing:content-box;
+      cursor:pointer;
+      transition:background 0.15s, border-color 0.15s, transform 0.12s;
+    }
+    .cookbook-gguf-delete-cb:hover {
+      border-color:var(--accent, var(--red));
+      transform:scale(1.12);
+    }
+    .cookbook-gguf-delete-cb:checked {
+      background:var(--accent, var(--red));
+      border-color:var(--accent, var(--red));
+    }
+    .cookbook-gguf-delete-main,
+    .cookbook-gguf-delete-path {
+      min-width:0;
+      overflow:hidden;
+      text-overflow:ellipsis;
+      white-space:nowrap;
+    }
+    .cookbook-gguf-delete-main {
+      font-size:0.86rem;
+      color:var(--fg);
+    }
+    .cookbook-gguf-delete-path {
+      grid-column:2;
+      margin-top:-2px;
+      font-size:0.74rem;
+      opacity:0.58;
+    }
+        /* Styled prompt — text-input dialog (used in place of window.prompt) */
+    #styled-prompt-overlay {
+      background:rgba(0,0,0,0.5);
+      backdrop-filter:blur(4px);
+      pointer-events:auto !important;
+      z-index: 99999 !important;
+      position: fixed !important;
+      top: 0 !important;
+      left: 0 !important;
+      width: 100% !important;
+      height: 100% !important;
+    }
+    #styled-prompt-overlay .modal-content {
+      position: relative;
+      z-index: 10001;
+    }
+    .styled-prompt-box { width: min(400px, 92vw); max-width: 100%; box-sizing: border-box; }
+    .styled-prompt-box .modal-body { padding-top: 4px; }
+    .styled-prompt-input {
+      width:100%;
+      box-sizing:border-box;
+      margin-top:8px;
+      padding:9px 12px;
+      border:1px solid var(--border);
+      border-radius:6px;
+      background:var(--bg);
+      color:var(--fg);
+      font:inherit;
+      font-size:0.95rem;
+      outline:none;
+      transition: border-color 0.15s, box-shadow 0.15s;
+    }
+    .styled-prompt-input:focus {
+      border-color: var(--accent-primary, var(--red, #4a9eff));
+      box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-primary, var(--red, #4a9eff)) 25%, transparent);
+    }
+    @media (max-width:768px) {
+      .styled-prompt-box { width: 85vw; }
+      .styled-prompt-input { font-size: 0.9rem; padding: 10px 12px; }
+    }
+    /* Scroll navigation buttons */
+    .scroll-nav-btn {
+      position:fixed;
+      background:var(--panel);
+      color: var(--accent);
+      border:none;
+      border-radius:10px;
+      width:38px; height:38px;
+      padding:0;
+      display:flex; align-items:center; justify-content:center;
+      font-size:14px;
+      line-height:1;
+      font-family:inherit;
+      cursor:pointer;
+      opacity:0;
+      pointer-events:none;
+      transition: opacity .2s, transform .3s cubic-bezier(0.25, 1, 0.5, 1), background .15s;
+      z-index:100;
+      transform: translateY(0);
+    }
+    .scroll-nav-btn::before {
+      content: '';
+      position: absolute;
+      inset: 0;
+      border-radius: 10px;
+      padding: 1px;
+      background: linear-gradient(to bottom, var(--border), color-mix(in srgb, var(--border) 30%, transparent));
+      -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+      mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+      -webkit-mask-composite: xor;
+      mask-composite: exclude;
+      pointer-events: none;
+    }
+    .scroll-nav-btn.agent-working::after,
+    body.chat-agent-working #scroll-bottom-btn.show::after {
+      content: '';
+      position: absolute;
+      inset: 0;
+      border-radius: 10px;
+      padding: 1px;
+      background: conic-gradient(
+        from 0deg,
+        transparent 0deg,
+        transparent 245deg,
+        color-mix(in srgb, var(--accent, var(--red)) 78%, transparent) 315deg,
+        transparent 360deg
+      );
+      -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+      mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+      -webkit-mask-composite: xor;
+      mask-composite: exclude;
+      opacity: 0.65;
+      pointer-events: none;
+      animation: scrollAgentWorkingRing 1.05s linear infinite;
+    }
+    @keyframes scrollAgentWorkingRing {
+      to { transform: rotate(360deg); }
+    }
+    #scroll-bottom-btn.show { opacity:1; pointer-events:auto; }
+    .scroll-nav-caret {
+      display: inline-block;
+      font-family: Arial, Helvetica, sans-serif;
+      font-size: 16px;
+      line-height: 1;
+      transform: translateY(-1px);
+      pointer-events: none;
+    }
+    @media (hover: hover) and (pointer: fine) {
+      #scroll-bottom-btn.show:hover {
+        border-color: color-mix(in srgb, var(--fg) 25%, transparent);
+      }
+    }
+    #scroll-bottom-btn.slide-out {
+      transform: translateY(20px);
+      opacity: 0 !important;
+      pointer-events: none;
+    }
+    /* Focus outline for accessibility */
+    :focus-visible {
+      outline: 2px solid var(--red);
+      outline-offset: 2px;
+    }
+    /* Hamburger menu button */
+    .hamburger {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 32px;
+      height: 32px;
+      background: none;
+      border: 1px solid transparent;
+      border-radius: 6px;
+      padding: 0;
+      cursor: pointer;
+      transition: background 0.15s, border-color 0.15s;
+    }
+    .hamburger:hover {
+      background: color-mix(in srgb, var(--fg) 7%, transparent);
+      border-color: var(--border);
+    }
+    .hamburger span {
+      display: block;
+      width: 16px;
+      height: 2px;
+      background: var(--fg);
+      border-radius: 1px;
+      transition: transform 0.2s, opacity 0.2s;
+    }
+    .hamburger span + span { margin-top: 3px; }
+    /* Agent indicator */
+    #agent-indicator {
+      position: fixed;
+      top: 20px;
+      right: 20px;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      padding: 6px 12px;
+      border-radius: 6px;
+      font-size: 12px;
+      display: none;
+      z-index: 100;
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    #agent-indicator.active {
+      display: block;
+      border-color: var(--color-agent-active);
+      box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
+    }
+    #agent-indicator:hover {
+      border-color: var(--color-agent-active);
+      background: var(--panel);
+    }
+    #research-toggle-btn:disabled {
+      opacity: 0.6;
+      cursor: not-allowed;
+    }
+    
+/* ── Drag & Drop ── */
+
+/* ---------- Color palette (dark / light) ---------- */
+
+    /* Drag and drop styling */
+    .section[dnd-active="true"] {
+      background: color-mix(in srgb, var(--red) 10%, transparent) !important;
+      border-color: var(--red) !important;
+    }
+    
+    .section[dnd-over="true"] {
+      background: color-mix(in srgb, var(--red) 20%, transparent) !important;
+      border-color: var(--red) !important;
+      transform: scale(1.02);
+    }
+    
+    .drag-handle {
+      cursor: grab;
+      opacity: 0.5;
+      padding: 0 6px;
+      user-select: none;
+    }
+    
+    .drag-handle:hover {
+      opacity: 0.8;
+    }
+    
+    .drag-handle:active {
+      cursor: grabbing;
+    }
+    
+/* ── UI Controls (Radio, Presets, Toolbar, Settings) ── */
+
+
+    /* Custom radio button styling */
+    .radio-option {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 8px;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--fg) 6%, transparent);
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    
+    .radio-option:hover {
+      background: color-mix(in srgb, var(--fg) 9%, transparent);
+      border-color: var(--fg);
+    }
+    
+    .radio-option input[type="radio"] {
+      appearance: none;
+      -webkit-appearance: none;
+      width: 18px;
+      height: 18px;
+      border: 2px solid var(--border);
+      border-radius: 50%;
+      outline: none;
+      margin: 0;
+      background: var(--bg);
+      transition: all 0.2s ease;
+      position: relative;
+      flex-shrink: 0;
+    }
+    
+    .radio-option input[type="radio"]:checked {
+      border-color: var(--red);
+      background: var(--red);
+    }
+    
+    .radio-option input[type="radio"]:focus {
+      box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 30%, transparent);
+    }
+    
+    .radio-label {
+      color: var(--fg);
+      font-size: 14px;
+      user-select: none;
+    }
+    
+    /* Preset buttons */
+    .preset-btn {
+      height: 27.2px; /* 15% smaller than 32px */
+      padding: 0 8.5px; /* 15% smaller than 10px */
+      margin-left: 4px;
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      background: var(--bg);
+      color: var(--fg);
+      font-family: 'Fira Code', monospace;
+      font-size: 10.2px; /* 15% smaller than 12px */
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    
+    .preset-btn:hover {
+      background: var(--panel);
+      border-color: var(--fg);
+    }
+    
+    .preset-btn.active {
+      background: var(--panel);
+      border-color: var(--fg);
+      box-shadow: 0 0 0 1px var(--fg), 0 0 8px color-mix(in srgb, var(--fg) 16%, transparent);
+      font-weight: 600;
+    }
+    
+    /* All preset buttons use the same blue color */
+    .preset-btn {
+      border-color: var(--red); /* Blue color */
+    }
+    
+    .preset-btn.active {
+      border-color: var(--red); /* Blue color when active */
+      box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent);
+    }
+    
+    /* Custom preset modal: the base .preset-modal-content sets overflow:hidden
+       (for desktop rounded-corner clipping), which on the mobile sheet clipped
+       the footer (Start/Cancel) off the bottom with no way to reach it. Let the
+       whole sheet scroll — same as every other mobile modal (.modal-content is
+       overflow-y:auto on mobile) — so the footer is always reachable. No flex
+       changes, so the body can't collapse. */
+    #custom-preset-modal .preset-modal-content {
+      overflow-y: auto !important;
+    }
+    #custom-preset-modal .modal-body label {
+      font-size: 13px;
+      font-weight: 500;
+      color: var(--fg);
+      margin-top: 8px;
+      margin-bottom: 4px;
+      display: block;
+    }
+
+    #custom-preset-modal .modal-body input,
+    #custom-preset-modal .modal-body textarea {
+      width: 100%;
+      margin-bottom: 8px;
+      box-sizing: border-box;
+    }
+    #custom-preset-modal .modal-body textarea,
+    #custom-preset-modal .modal-body input[type="text"] {
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      color: var(--fg);
+      padding: 8px 10px;
+      font-size: 13px;
+      font-family: inherit;
+      transition: border-color 0.15s;
+    }
+    #custom-preset-modal .modal-body textarea {
+      resize: vertical;
+    }
+    #custom-preset-modal .modal-body textarea:focus,
+    #custom-preset-modal .modal-body input[type="text"]:focus {
+      outline: none;
+      border-color: var(--red);
+    }
+
+    #custom-preset-modal .modal-footer {
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+      margin-top: 12px;
+      padding-top: 10px;
+      border-top: 1px solid var(--border);
+    }
+
+    #custom-preset-modal .modal-footer button {
+      padding: 7px 14px;
+      border-radius: 6px;
+      font-size: 12px;
+      font-weight: 500;
+      border: 1px solid var(--border);
+      background: none;
+      color: var(--fg);
+      cursor: pointer;
+      transition: all 0.15s;
+    }
+    #custom-preset-modal .modal-footer button:hover {
+      background: color-mix(in srgb, var(--fg) 8%, transparent);
+    }
+
+    #custom-preset-modal .modal-footer button#save-custom-preset {
+      /* The theme's accent is stored in --red (theme.js sets --red = accentHex)
+         and --accent is undefined, so the canonical accent expression is
+         var(--accent, var(--red)). The old bare var(--accent) resolved to nothing
+         which, with color:var(--bg), made the button invisible. */
+      background: var(--accent, var(--red));
+      color: var(--bg);
+      border-color: var(--accent, var(--red));
+    }
+    #custom-preset-modal .modal-footer button#save-custom-preset:hover {
+      opacity: 0.85;
+    }
+    /* Toolbar visibility tab */
+    .toolbar-hint {
+      font-size: 12px;
+      color: color-mix(in srgb, var(--fg) 55%, transparent);
+      margin-bottom: 12px;
+    }
+    /* ── Appearance visibility toggles ── */
+    .vis-toggles {
+      display: flex;
+      flex-direction: column;
+    }
+    .vis-row {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      cursor: pointer;
+      padding: 5px 6px;
+      border-radius: 4px;
+      transition: background 0.12s;
+    }
+    .vis-row:hover {
+      background: color-mix(in srgb, var(--fg) 5%, transparent);
+    }
+    .vis-row input[type="checkbox"] {
+      display: none;
+    }
+    .vis-icon {
+      width: 20px;
+      height: 20px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+      color: color-mix(in srgb, var(--fg) 40%, transparent);
+    }
+    .vis-icon-text {
+      font-size: 10px;
+      font-weight: 700;
+      font-family: inherit;
+      letter-spacing: -0.5px;
+    }
+    .vis-label {
+      flex: 1;
+      font-size: 12px;
+      color: var(--fg);
+      user-select: none;
+    }
+    .vis-switch {
+      position: relative;
+      width: 30px;
+      height: 16px;
+      background: color-mix(in srgb, var(--fg) 15%, transparent);
+      border-radius: 8px;
+      transition: background 0.2s;
+      flex-shrink: 0;
+    }
+    .vis-switch::after {
+      content: '';
+      position: absolute;
+      top: 2px;
+      left: 2px;
+      width: 12px;
+      height: 12px;
+      background: var(--panel);
+      border-radius: 50%;
+      transition: transform 0.2s;
+      box-shadow: 0 1px 2px rgba(0,0,0,0.25);
+    }
+    .vis-row input:checked + .vis-switch {
+      background: var(--red);
+    }
+    .vis-row input:checked + .vis-switch::after {
+      transform: translateX(14px);
+    }
+    .vis-row input:checked ~ .vis-icon,
+    .vis-row:has(input:checked) .vis-icon {
+      color: var(--fg);
+    }
+    .persona-display-name-row .vis-label {
+      position: relative;
+      top: -4px;
+    }
+    .persona-display-name-row .vis-switch {
+      position: relative;
+      left: 4px;
+    }
+    .md-attach-paperclip-icon {
+      position: relative;
+      top: 1px;
+    }
+    /* Compare model selector — match the calendar modal's clean header
+       (no border underline). */
+    #compare-model-overlay .modal-header h4 {
+      pointer-events: none;
+    }
+    /* Compare model selector: keep manually-resized/tiny windows contained.
+       Picker dropdowns are appended to document.body, so the card itself can
+       clip and scroll without cropping the dropdown list. */
+    #compare-model-overlay .modal-content {
+      display: flex;
+      flex-direction: column;
+      max-height: min(720px, calc(100dvh - 48px));
+      overflow: hidden;
+      min-height: 180px;
+    }
+    #compare-model-overlay .modal-body {
+      overflow: auto;
+      flex: 1 1 auto;
+      min-height: 0;
+    }
+    .vis-hint {
+      font-size: 10px;
+      color: color-mix(in srgb, var(--fg) 30%, transparent);
+      font-weight: 400;
+      margin-left: 2px;
+    }
+    /* Settings toggle — admin-only lock indicator */
+    .ui-vis-lock {
+      display: none;
+    }
+    /* (legacy toolbar-toggle styles removed — now using .vis-* classes) */
+
+    /* Demo highlight pulse */
+    .odysseus-highlight {
+      outline: 2px solid var(--accent, var(--red)) !important;
+      outline-offset: 1px;
+      box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
+      animation: ody-pulse 1.5s ease-in-out infinite;
+      z-index: 100;
+      position: relative;
+    }
+    @keyframes ody-pulse {
+      0%, 100% { outline-color: var(--accent, var(--red)); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, var(--red)) 18%, transparent); }
+      50%       { outline-color: color-mix(in srgb, var(--accent, var(--red)) 40%, transparent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent, var(--red)) 8%, transparent); }
+    }
+    /* Floating breathing halo. Rendered as a body-level div positioned over
+       the target, so we don't fight the target's own outline / box-shadow /
+       overflow chain. JS keeps it in sync with the target's bounding rect. */
+    .tour-halo {
+      position: fixed;
+      pointer-events: none;
+      border: 3px solid var(--accent, var(--red));
+      border-radius: 10px;
+      z-index: 10000;
+      animation: ody-breathe 1.4s ease-in-out infinite;
+      opacity: 0;
+      transform: scale(0.94);
+      transition: opacity 0.35s ease-out, transform 0.35s ease-out;
+    }
+    .tour-halo.tour-fade-in {
+      opacity: 1;
+      transform: scale(1);
+    }
+    @keyframes ody-breathe {
+      0%, 100% {
+        box-shadow: 0 0 0 3px  color-mix(in srgb, var(--accent, var(--red)) 55%, transparent),
+                    0 0 22px 4px color-mix(in srgb, var(--accent, var(--red)) 40%, transparent);
+        border-color: var(--accent, var(--red));
+      }
+      50% {
+        box-shadow: 0 0 0 6px  color-mix(in srgb, var(--accent, var(--red)) 30%, transparent),
+                    0 0 40px 14px color-mix(in srgb, var(--accent, var(--red)) 70%, transparent);
+        border-color: color-mix(in srgb, var(--accent, var(--red)) 80%, transparent);
+      }
+    }
+    /* While the tour is active, lift overflow:hidden on common clipping
+       ancestors so the halo around a highlighted child isn't cropped. */
+    body.tour-active .sidebar,
+    body.tour-active .sidebar-inner,
+    body.tour-active .chat-input-bar,
+    body.tour-active .chat-input-top,
+    body.tour-active .chat-input-wrap,
+    body.tour-active .chat-input-right,
+    body.tour-active .mode-toggle,
+    body.tour-active .model-picker-wrap {
+      overflow: visible !important;
+    }
+
+    /* ── Secret tour hint (drag-to-snap on first modal open) ── */
+    .tour-hint {
+      position: fixed;
+      z-index: 10002;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 12px 14px 10px;
+      width: 240px;
+      font-size: 0.78rem;
+      line-height: 1.5;
+      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.32);
+      opacity: 0;
+      transform: translateY(-4px);
+      transition: opacity 0.28s ease-out, transform 0.28s ease-out;
+      pointer-events: auto;
+    }
+    .tour-hint.tour-hint-in  { opacity: 1; transform: translateY(0); }
+    .tour-hint.tour-hint-out { opacity: 0; transform: translateY(-4px); }
+    .tour-hint-visual {
+      display: flex;
+      justify-content: center;
+      margin-bottom: 8px;
+      color: var(--accent, var(--red));
+    }
+    .tour-hint-visual svg { display: block; }
+    .tour-hint-text { margin-bottom: 10px; opacity: 0.92; }
+    .tour-hint-text b { color: var(--accent, var(--red)); font-weight: 600; }
+    .tour-hint-dismiss {
+      display: block;
+      margin: 0 0 0 auto;
+      background: none;
+      border: 1px solid var(--border);
+      color: var(--fg);
+      border-radius: 6px;
+      padding: 3px 12px;
+      cursor: pointer;
+      font-family: inherit;
+      font-size: 0.72rem;
+      opacity: 0.85;
+      transition: opacity 0.15s, background 0.15s;
+    }
+    .tour-hint-dismiss:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 8%, transparent); }
+
+    /* SVG dance: cursor approaches title bar, drags right, modal snaps to a
+       right-half zone, holds, returns. 3.2s loop. */
+    .th-cursor { animation: th-cursor 3.2s ease-in-out infinite; transform-origin: 0 0; }
+    .th-modal-group { animation: th-modal-slide 3.2s ease-in-out infinite; transform-origin: 39px 31px; }
+    .th-zone { animation: th-zone-flash 3.2s ease-in-out infinite; }
+    @keyframes th-cursor {
+      0%, 5%    { transform: translate(35px, 32px); }
+      18%       { transform: translate(35px, 22px); }
+      48%       { transform: translate(80px, 22px); }
+      72%       { transform: translate(80px, 22px); }
+      90%, 100% { transform: translate(35px, 32px); }
+    }
+    @keyframes th-modal-slide {
+      0%, 18%   { transform: translate(0, 0) scale(1, 1); }
+      48%       { transform: translate(45px, 0) scale(1, 1); }
+      58%       { transform: translate(30px, -19px) scale(1.4, 2.4); }
+      72%       { transform: translate(30px, -19px) scale(1.4, 2.4); }
+      90%, 100% { transform: translate(0, 0) scale(1, 1); }
+    }
+    @keyframes th-zone-flash {
+      0%, 38%   { opacity: 0; }
+      48%       { opacity: 0.22; }
+      58%, 72%  { opacity: 0; }
+      100%      { opacity: 0; }
+    }
+    @media (prefers-reduced-motion: reduce) {
+      .th-cursor, .th-modal-group, .th-zone { animation: none !important; }
+    }
+    .odysseus-hl-label {
+      position: absolute;
+      top: -22px;
+      left: 8px;
+      background: var(--red);
+      color: var(--bg);
+      font-size: 0.7rem;
+      padding: 2px 8px;
+      border-radius: 4px;
+      white-space: nowrap;
+      z-index: 101;
+      pointer-events: none;
+    }
+
+    /* Generated images inside chat bubbles */
+    .msg.generated-image-wrap .body { text-align: center; }
+    .generated-image {
+      max-width: 100%;
+      max-height: 512px;
+      border-radius: 8px;
+      cursor: pointer;
+      transition: transform 0.2s;
+      display: inline-block;
+      margin: 0 auto;
+    }
+    .generated-image:hover { transform: scale(1.02); }
+    .generated-image-caption {
+      font-size: 0.8rem;
+      opacity: 0.5;
+      margin-top: 6px;
+      font-style: italic;
+      text-align: center;
+    }
+
+    /* Setup wizard */
+    .setup-wizard { padding: 16px; max-width: 500px; }
+    .setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
+    .setup-wizard .setup-label { font-size: 0.85rem; color: var(--fg); opacity: 0.7; margin-bottom: 8px; }
+    .setup-wizard .setup-presets { display: flex; flex-wrap: wrap; gap: 8px; }
+    .setup-wizard .setup-preset-btn {
+      padding: 8px 14px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--panel); color: var(--fg); cursor: pointer;
+      font-size: 0.85rem; transition: all 0.2s;
+    }
+    .setup-wizard .setup-preset-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .setup-wizard .setup-input {
+      display: block; width: 100%; padding: 8px 12px; margin-bottom: 8px;
+      background: var(--panel); border: 1px solid var(--border);
+      border-radius: 6px; color: var(--fg); font-size: 0.85rem; box-sizing: border-box;
+    }
+    .setup-wizard .setup-input:focus { border-color: var(--red); outline: none; }
+    .setup-wizard .setup-connect-btn {
+      padding: 8px 20px; background: var(--red); color: var(--bg);
+      border: none; border-radius: 6px; cursor: pointer; font-weight: 600; margin-top: 4px;
+    }
+    .setup-wizard .setup-connect-btn:hover { opacity: 0.9; }
+    .setup-wizard .setup-status { font-size: 0.8rem; margin-top: 8px; color: var(--fg); opacity: 0.7; }
+    .setup-wizard .setup-model-list { display: flex; flex-wrap: wrap; gap: 8px; }
+    .setup-wizard .setup-model-btn {
+      padding: 8px 14px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--panel); color: var(--fg); cursor: pointer;
+      font-size: 0.85rem; transition: all 0.2s;
+    }
+    .setup-wizard .setup-model-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .setup-wizard .setup-step.hidden { display: none; }
+
+    /* Dropdown menu styles */
+    .dropdown {
+      position: absolute;
+      top: calc(100% + 6px);
+      right: 0;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px color-mix(in srgb, var(--fg) 5%, transparent);
+      z-index: 1000;
+      display: none;
+      min-width: 220px;
+      padding: 6px;
+      backdrop-filter: blur(12px);
+    }
+
+    .dropdown.show {
+      display: block;
+      animation: dropdown-in 0.15s ease-out;
+    }
+    @keyframes dropdown-in {
+      from { opacity:0; transform:translateY(-6px) scale(0.97); }
+      to { opacity:1; transform:translateY(0) scale(1); }
+    }
+
+    .dropdown-item {
+      padding: 8px 10px;
+      border-radius: 6px;
+      cursor: pointer;
+      transition: background 0.12s ease;
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
+    }
+
+    .dropdown-item:last-child {
+      border-bottom: none;
+    }
+
+    .dropdown-item .menu-icon {
+      width: 28px;
+      height: 28px;
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--fg) 5%, transparent);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 14px;
+      flex-shrink: 0;
+    }
+
+    .dropdown-item .menu-text h4 {
+      margin: 0;
+      font-size: 12.5px;
+      font-weight: 500;
+      color: var(--fg);
+      line-height: 1.3;
+    }
+
+    .dropdown-item .menu-text p {
+      margin: 0;
+      font-size: 10.5px;
+      color: var(--color-subheader);
+      line-height: 1.3;
+    }
+
+    .dropdown-item:hover {
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .dropdown-item:hover .menu-icon {
+      background: color-mix(in srgb, var(--red) 15%, transparent);
+    }
+
+    /* Compact dropdown items (session/model context menus) */
+    .dropdown-item-compact {
+      cursor: pointer;
+      padding: 6px 8px;
+      font-size: 11px;
+      border-radius: 8px;
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      color: var(--fg);
+      transition: background 0.1s;
+    }
+    .dropdown-item-compact:hover {
+      background: color-mix(in srgb, var(--accent) 10%, transparent);
+    }
+    .dropdown-item-compact .dropdown-icon {
+      width: 14px;
+      height: 14px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+      opacity: 0.5;
+    }
+    .dropdown-item-compact .dropdown-icon svg {
+      width: 14px;
+      height: 14px;
+    }
+    .dropdown-item-compact .dropdown-shortcut {
+      margin-left: auto;
+      font-size: 9.5px;
+      opacity: 0.35;
+      font-weight: 400;
+    }
+    /* Keyboard shortcut hints (⌘+Alt+D etc.) are meaningless on touch —
+       hide them in the per-chat actions menu on mobile. */
+    @media (max-width: 768px) {
+      .session-dropdown .dropdown-shortcut { display: none; }
+    }
+    .dropdown-item-danger {
+      color: var(--red) !important;
+    }
+    .dropdown-item-danger .dropdown-icon {
+      opacity: 0.7;
+    }
+
+    /* Inline rename input for sessions */
+    .session-rename-input {
+      width: 100%;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--accent, var(--accent-primary));
+      border-radius: 4px;
+      padding: 2px 6px;
+      font-family: inherit;
+      font-size: 12px;
+      line-height: 1.3;
+      outline: none;
+    }
+
+    /* Session dropdown container */
+    .session-dropdown-menu {
+      position: fixed;
+      z-index: 1000;
+      display: none;
+      min-width: auto;
+      width: max-content;
+      padding: 4px;
+      animation: dropdown-in 0.15s ease-out;
+    }
+
+    /* Folder move submenu */
+    .session-folder-submenu {
+      position: fixed;
+      z-index: 1001;
+      display: none;
+      min-width: auto;
+      width: max-content;
+      padding: 4px;
+    }
+
+    .dropdown-divider {
+      height: 1px;
+      background: var(--border);
+      margin: 4px 8px;
+    }
+    
+    /* Search toggle styles */
+    .search-toggle {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 6px 0;
+      border-bottom: 1px solid var(--border);
+      margin-bottom: 8px;
+    }
+    
+    .search-provider-label {
+      font-size: 14px;
+      color: var(--fg);
+    }
+
+/* ── Voice, Search, Themes, Comparison, Censor, Print ── */
+
+    /* Voice recording styles */
+    #mic-btn {
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      transition: all 0.2s ease;
+    }
+    
+    #mic-btn:hover {
+      background: color-mix(in srgb, var(--fg) 6%, transparent);
+      border-color: var(--fg);
+    }
+    
+    #mic-btn.recording {
+      background: var(--color-recording);
+      border-color: var(--color-recording);
+      animation: pulse 1.5s infinite;
+    }
+    
+    @keyframes pulse {
+      0% { opacity: 1; }
+      50% { opacity: 0.7; }
+      100% { opacity: 1; }
+    }
+    
+    #recording-indicator {
+      position: fixed;
+      top: 10px;
+      left: 0;
+      right: 0;
+      background: rgba(0, 0, 0, 0.8);
+      backdrop-filter: blur(10px);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 12px;
+      margin: 10px;
+      z-index: 1000;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+    }
+    
+    #recording-indicator.hidden {
+      display: none !important;
+    }
+    
+    .recording-content {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      color: white;
+    }
+    
+    .recording-icon {
+      color: var(--color-recording);
+      font-size: 20px;
+      animation: pulse 1.5s infinite;
+    }
+    
+    .recording-text {
+      font-size: 16px;
+      font-weight: 500;
+    }
+    
+    #stop-recording {
+      background: var(--color-recording);
+      color: white;
+      border: none;
+      border-radius: 6px;
+      padding: 6px 12px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: background 0.2s ease;
+    }
+    
+    #stop-recording:hover {
+      background: var(--color-recording-hover);
+    }
+    
+    /* Error state for recording */
+    #recording-indicator.error {
+      background: rgba(173, 26, 26, 0.9);
+    }
+    
+    .recording-error {
+      color: var(--color-recording);
+      font-size: 14px;
+      margin-top: 4px;
+    }
+    
+    @media (max-width: 768px) {
+      #recording-indicator {
+        margin: 8px;
+        padding: 10px;
+      }
+      
+      .recording-text {
+        font-size: 14px;
+      }
+      
+      #stop-recording {
+        padding: 6px 10px;
+        font-size: 12px;
+      }
+    }
+/* SYNTAX HIGHLIGHTING — uses theme vars from style.css, no hardcoded overrides */
+.hljs { color: var(--hl-fg, #9cdef2); background: none !important; }
+pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
+
+    /* ---------- Search overlay (Ctrl+K command palette) ---------- */
+    .search-overlay {
+      position: fixed;
+      top: 0; left: 0; width: 100%; height: 100%;
+      background: rgba(0, 0, 0, 0.6);
+      display: flex;
+      align-items: flex-start;
+      justify-content: center;
+      padding-top: var(--search-overlay-top, 15vh);
+      z-index: 300;
+      backdrop-filter: blur(6px);
+    }
+    .search-overlay.hidden { display: none; }
+    .search-popup {
+      width: 520px;
+      max-width: 90vw;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      max-height: 60vh;
+    }
+    .search-popup input#search-input {
+      width: 100%;
+      background: transparent;
+      border: none;
+      border-bottom: 1px solid var(--border);
+      outline: none;
+      color: var(--fg);
+      font-size: 16px;
+      font-family: 'Fira Code', monospace;
+      padding: 14px 16px;
+      box-sizing: border-box;
+    }
+    .search-popup input#search-input::placeholder {
+      color: color-mix(in srgb, var(--fg) 30%, transparent);
+    }
+    .search-results {
+      overflow-y: auto;
+      flex: 1;
+      padding: 4px;
+    }
+    .search-results:empty {
+      display: none;
+    }
+    .search-group-header {
+      font-size: 10px;
+      font-weight: 600;
+      color: var(--color-subheader);
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+      padding: 10px 12px 4px;
+    }
+    .search-result-item {
+      display: flex;
+      align-items: baseline;
+      gap: 8px;
+      padding: 8px 12px;
+      border-radius: 6px;
+      cursor: pointer;
+      transition: background 0.1s;
+    }
+    .search-result-item:hover,
+    .search-result-item.selected {
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .search-result-role {
+      font-size: 10px;
+      font-weight: 600;
+      color: var(--color-subheader);
+      flex-shrink: 0;
+      min-width: 24px;
+    }
+    .search-result-snippet {
+      flex: 1;
+      font-size: 12px;
+      color: var(--fg);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .search-result-time {
+      font-size: 10px;
+      color: color-mix(in srgb, var(--fg) 35%, transparent);
+      flex-shrink: 0;
+      white-space: nowrap;
+    }
+    mark.search-highlight {
+      /* Was orange-on-orange (0.35 alpha bg + orange text) — too low-contrast
+         to read. Solid accent fill with bg-colored text reads clearly. */
+      background: var(--accent, #e8a830);
+      color: var(--bg, #1a1a1a);
+      border-radius: 2px;
+      padding: 0 2px;
+      font-weight: 600;
+    }
+            .search-empty {
+      text-align: center;
+      color: color-mix(in srgb, var(--fg) 35%, transparent);
+      padding: 24px;
+      font-size: 13px;
+    }
+
+    /* ---------- Theme popup (uses .modal > .modal-content frame) ---------- */
+    #theme-modal { z-index: 260; }
+    #theme-popup .theme-popup-sub { color: color-mix(in srgb, var(--fg) 50%, transparent); font-size: 11px; margin-bottom: 10px; }
+    /* `max-height` instead of a fixed height so the popup shrinks to fit its
+       content (avoids the leftover whitespace after the time-based switching
+       card was removed). The inner .theme-tab-panel keeps overflow:auto, so
+       any taller content still scrolls inside. */
+    #theme-popup { overflow-y: hidden; max-height: min(85vh, 600px); }
+    #theme-popup .modal-header { flex-shrink: 0; }
+    #theme-popup .admin-tabs { margin: 0 -10px 8px; padding: 0 10px; flex-shrink: 0; }
+    .theme-tab-panel { overflow-y: auto; min-height: 0; flex: 1; padding-bottom: 10px; }
+
+    .theme-grid {
+      display: grid; grid-template-columns: repeat(auto-fill, minmax(66px, 1fr));
+      gap: 6px; margin-bottom: 12px;
+    }
+    .theme-swatch {
+      border: 2px solid var(--border); border-radius: 8px; cursor: pointer;
+      padding: 5px; text-align: center; font-size: 0.65rem; color: var(--fg);
+      transition: border-color 0.15s, transform 0.15s;
+    }
+    .theme-swatch:hover { transform: scale(1.06); }
+    .theme-swatch.active { border-color: var(--red); box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 33%, transparent); }
+    .theme-swatch-colors {
+      display: flex; justify-content: center; margin-bottom: 3px;
+    }
+    .theme-swatch-colors span {
+      width: 15px; height: 15px; border-radius: 50%;
+      margin-left: -5px;
+      border: 1.5px solid color-mix(in srgb, var(--fg) 12%, transparent);
+    }
+    .theme-swatch-colors span:first-child { margin-left: 0; }
+    .theme-custom-label { font-size: 11px; font-weight: 600; color: color-mix(in srgb, var(--fg) 50%, transparent); text-transform: uppercase; letter-spacing: 0.06em; margin: 10px 0 6px; }
+    .theme-custom { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; }
+    .color-row { display: flex; align-items: center; gap: 4px; position: relative; }
+    .color-row label { font-size: 13px; font-weight: 500; color: var(--fg); opacity: 0.7; flex: 1; }
+    .color-row input[type="color"],
+    .color-row input.cp-swatch-input {
+      width: 24px; height: 24px; border: 1px solid var(--border); border-radius: 50%;
+      background: none; cursor: pointer; padding: 0; flex-shrink: 0;
+      overflow: hidden;
+      -webkit-appearance: none;
+      appearance: none;
+    }
+    .color-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
+    .color-row input[type="color"]::-webkit-color-swatch { border: none; border-radius: 50%; }
+    .color-row input[type="color"]::-moz-color-swatch { border: none; border-radius: 50%; }
+    #theme-bg-effect-color { position: relative; top: 2px; }
+    .color-row input.cp-swatch-input {
+      color: transparent;
+      text-shadow: none;
+      caret-color: transparent;
+      font-size: 0;
+      user-select: none;
+    }
+    .color-row input.cp-swatch-input::selection { background: transparent; }
+    .color-row input.cp-swatch-input:focus { outline: 1px solid var(--red); outline-offset: 1px; }
+    .color-reset-btn {
+      width: 24px; height: 24px; border: none; background: none; cursor: pointer;
+      color: var(--fg); opacity: 0; font-size: 1.15rem; padding: 0; line-height: 1;
+      transition: opacity 0.15s, color 0.15s; flex-shrink: 0; pointer-events: none;
+      position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
+    }
+    .color-row input[type="color"],
+    .color-row input.cp-swatch-input { margin-left: auto; }
+    /* Keep the harmony hex readout on the left, with the swatch dot anchored
+       to the right like the other color controls. */
+    #theme-harmony-card #harmony-accent-hex {
+      order: -1;
+      margin-right: auto;
+    }
+    .color-row:has(.color-reset-btn.changed) input[type="color"],
+    .color-row:has(.color-reset-btn.changed) input.cp-swatch-input { margin-right: 28px; }
+    .color-reset-btn.changed { opacity: 0.4; pointer-events: auto; }
+    .color-reset-btn.changed:hover { opacity: 1; color: var(--red); }
+    .theme-custom-divider {
+      grid-column: 1 / -1; font-size: 11px; color: var(--fg); opacity: 0.5;
+      text-transform: uppercase; letter-spacing: 0.04em; margin: 6px 0 2px;
+      border-top: 1px solid var(--border); padding-top: 6px;
+    }
+    .theme-swatch[data-custom] { position: relative; overflow: visible; }
+    /* Accent-coloured circular X — always visible (mobile-friendly), sits
+       INSIDE the swatch's top-right corner so it never crops into a
+       neighbouring swatch. */
+    .theme-delete-btn {
+      position: absolute;
+      top: -2px;
+      right: -2px;
+      width: 20px;
+      height: 20px;
+      padding: 0;
+      border: none;
+      border-radius: 50%;
+      background: var(--accent, var(--red, #d92534));
+      color: #fff;
+      cursor: pointer;
+      z-index: 2;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
+      transition: transform 0.12s, background 0.12s;
+    }
+    .theme-delete-btn:hover {
+      background: color-mix(in srgb, var(--accent, var(--red)) 80%, white);
+      transform: scale(1.12);
+    }
+    .theme-delete-btn:active { transform: scale(0.95); }
+    .theme-delete-btn svg {
+      display: block;
+      /* Optical nudge — the X reads off-center inside the circle without it. */
+      position: relative;
+      left: 1px;
+      top: -1px;
+    }
+    .theme-save-row {
+      display: flex; gap: 6px; margin-top: 8px;
+    }
+    .theme-save-row input {
+      flex: 1; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--bg); color: var(--fg); font-size: 12px; font-family: inherit;
+    }
+    .theme-save-row input:focus { outline: none; border-color: var(--red); }
+    .theme-save-row input::placeholder { color: color-mix(in srgb, var(--fg) 35%, transparent); }
+    .theme-save-row button {
+      padding: 6px 12px; border: 1px solid var(--red); border-radius: 6px;
+      background: transparent; color: var(--red); cursor: pointer;
+      font-size: 12px; font-family: inherit; white-space: nowrap; transition: all 0.15s;
+    }
+    .theme-save-row button:hover { background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .theme-save-error {
+      font-size: 11px; color: var(--red); margin-top: 2px; display: none;
+    }
+    /* Import/Export */
+    .theme-io-row { display: flex; gap: 6px; margin-top: 6px; }
+    .theme-io-btn {
+      flex: 1; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px;
+      background: transparent; color: var(--fg); cursor: pointer;
+      font-size: 12px; opacity: 0.7; transition: all 0.15s; font-family: inherit;
+    }
+    .theme-io-btn:hover { opacity: 1; border-color: var(--fg); background: color-mix(in srgb, var(--fg) 5%, transparent); }
+
+    #memory-import-btn svg,
+    #memory-export-btn svg {
+      color: var(--accent, var(--red));
+    }
+    .theme-import-area {
+      width: 100%; margin-top: 6px; padding: 6px 8px;
+      border: 1px solid var(--border); border-radius: 6px;
+      background: var(--bg); color: var(--fg); font-size: 0.7rem;
+      font-family: inherit; resize: vertical; min-height: 48px;
+    }
+    .theme-import-area:focus { outline: none; border-color: var(--red); }
+    .theme-import-area.hidden { display: none; }
+    .theme-import-actions { display: flex; gap: 6px; margin-top: 4px; }
+    .theme-import-actions.hidden { display: none; }
+    /* Font & Density */
+    .theme-fd-row { display: flex; gap: 8px; margin-bottom: 8px; }
+    .theme-fd-group { flex: 1; display: flex; flex-direction: column; gap: 3px; }
+    .theme-fd-label { font-size: 12px; font-weight: 500; color: var(--fg); opacity: 0.6; }
+    .theme-fd-select {
+      padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--bg); color: var(--fg); font-size: 12px;
+      font-family: inherit; cursor: pointer;
+    }
+    .theme-fd-select:focus { outline: none; border-color: var(--red); }
+    @media (max-width: 768px) {
+      /* The switch is too cramped beside the Font and Density selects on
+         narrow screens; give Frosted a dedicated row instead. */
+      .theme-fd-row:first-of-type {
+        flex-wrap: wrap;
+      }
+      .theme-fd-row:first-of-type #theme-frosted-group {
+        flex: 1 1 100%;
+        flex-direction: row;
+        align-items: center;
+        justify-content: space-between;
+        min-width: 0;
+      }
+    }
+    .theme-fd-range {
+      width: 100%;
+      max-width: 100%;
+      box-sizing: border-box;
+      margin: 0;
+      padding: 0;
+      height: 24px;
+      background: transparent;
+      cursor: pointer;
+      -webkit-appearance: none;
+      appearance: none;
+      accent-color: var(--red);
+    }
+    .theme-fd-range::-webkit-slider-runnable-track {
+      height: 4px; background: var(--border); border-radius: 2px;
+    }
+    .theme-fd-range::-moz-range-track {
+      height: 4px; background: var(--border); border-radius: 2px;
+    }
+    .theme-fd-range::-webkit-slider-thumb {
+      -webkit-appearance: none; appearance: none;
+      width: 14px; height: 14px; border-radius: 50%;
+      background: var(--red); border: none; margin-top: -5px; cursor: pointer;
+    }
+    .theme-fd-range::-moz-range-thumb {
+      width: 14px; height: 14px; border-radius: 50%;
+      background: var(--red); border: none; cursor: pointer;
+    }
+    .theme-fd-range:focus { outline: none; }
+    /* Color Harmony Generator */
+    .theme-harmony-row { display: flex; gap: 8px; align-items: flex-end; }
+    .harmony-generate-btn {
+      padding: 5px 14px; border: 1px solid var(--red); border-radius: 6px;
+      background: transparent; color: var(--red); cursor: pointer;
+      font-size: 12px; white-space: nowrap; transition: all 0.15s;
+      font-family: inherit; width: 100%;
+    }
+    .harmony-generate-btn:hover { background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .harmony-preview {
+      display: flex; height: 20px; border-radius: 6px; overflow: hidden;
+      margin-top: 8px; border: 1px solid var(--border);
+    }
+    .harmony-preview:empty { display: none; border: none; }
+    .harmony-preview span { flex: 1; }
+    #theme-reset-btn {
+      margin-top: 6px; width: 100%; padding: 6px; border: 1px solid var(--border);
+      border-radius: 6px; background: var(--bg); color: var(--fg); cursor: pointer;
+      font-size: 12px; font-family: inherit; opacity: 0.7; transition: opacity 0.15s;
+    }
+    #theme-reset-btn:hover { opacity: 1; }
+    .theme-adv-toggle {
+      margin-top: 10px; margin-bottom: 10px;
+      padding: 6px 8px; cursor: pointer;
+      font-size: 11px; color: var(--red); opacity: 0.8;
+      border: 1px solid var(--border); border-radius: 6px;
+      transition: opacity 0.15s, background 0.15s;
+      user-select: none;
+    }
+    .theme-adv-toggle:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 4%, transparent); }
+    .theme-adv-toggle .theme-adv-arrow {
+      display: inline-block; transition: transform 0.2s; font-size: 10px;
+      margin-right: 4px;
+    }
+    .theme-adv-toggle.open .theme-adv-arrow { transform: rotate(90deg); }
+    .theme-adv-section { margin-top: 8px; margin-bottom: 10px; }
+    .theme-adv-section.hidden { display: none; }
+    .theme-adv-group { margin-bottom: 8px; }
+    .theme-adv-group-label {
+      font-size: 10px; color: var(--fg); opacity: 0.5;
+      text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px;
+    }
+    .theme-adv-clear-btn {
+      margin-top: 6px; width: 100%; padding: 5px; border: 1px solid var(--border);
+      border-radius: 6px; background: transparent; color: var(--fg); opacity: 0.5;
+      cursor: pointer; font-size: 12px; font-family: inherit; transition: opacity 0.15s;
+    }
+    .theme-adv-clear-btn:hover { opacity: 1; }
+
+    /* Customization is an editor, so keep its sections visually related while
+       preserving the existing controls and their order. */
+    #theme-tab-customize {
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+    }
+    #theme-tab-customize > .admin-card,
+    #theme-tab-customize > .theme-adv-toggle,
+    #theme-tab-customize > .theme-adv-section {
+      margin-top: 0;
+      margin-bottom: 0;
+    }
+    #theme-tab-customize > .admin-card {
+      padding: 12px;
+    }
+    #theme-tab-customize > .admin-card > h2 {
+      display: flex;
+      align-items: center;
+      min-height: 20px;
+      margin: 0 0 10px;
+      padding-bottom: 7px;
+      border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
+      font-size: 13px;
+    }
+    #theme-tab-customize .theme-custom {
+      gap: 6px 14px;
+    }
+    #theme-tab-customize .color-row {
+      min-height: 30px;
+      padding: 3px 6px;
+      box-sizing: border-box;
+      border: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
+      background: color-mix(in srgb, var(--fg) 2%, transparent);
+    }
+    #theme-tab-customize .color-row label {
+      font-size: 12px;
+    }
+    #theme-tab-customize .theme-adv-toggle {
+      flex-shrink: 0;
+      margin: 0;
+      background: color-mix(in srgb, var(--fg) 2%, transparent);
+    }
+    #theme-tab-customize .theme-adv-section {
+      padding: 10px 12px 2px;
+      border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
+      border-radius: 7px;
+      background: color-mix(in srgb, var(--panel) 55%, transparent);
+    }
+    #theme-tab-customize .theme-adv-group {
+      margin: 0;
+      padding: 0 0 9px;
+      border-bottom: 1px solid color-mix(in srgb, var(--border) 48%, transparent);
+    }
+    #theme-tab-customize .theme-adv-group + .theme-adv-group {
+      padding-top: 9px;
+    }
+    #theme-tab-customize .theme-adv-group:last-of-type {
+      border-bottom: 0;
+    }
+    #theme-tab-customize .theme-adv-group-label {
+      margin-bottom: 6px;
+      color: var(--red);
+      opacity: 0.8;
+      font-weight: 600;
+    }
+    #theme-tab-customize .theme-fd-row,
+    #theme-tab-customize .theme-harmony-row {
+      gap: 12px;
+      margin-bottom: 10px;
+    }
+    #theme-tab-customize .theme-fd-row:last-child,
+    #theme-tab-customize .theme-harmony-row:last-child {
+      margin-bottom: 0;
+    }
+    #theme-tab-customize .theme-fd-group {
+      min-width: 0;
+    }
+    #theme-tab-customize .theme-fd-select {
+      min-height: 30px;
+    }
+    #theme-tab-customize #theme-harmony-card .theme-fd-label {
+      font-size: 11px;
+    }
+    #theme-tab-customize .theme-save-row,
+    #theme-tab-customize .theme-io-row {
+      gap: 8px;
+    }
+    #theme-tab-customize #theme-reset-btn {
+      margin-top: 8px;
+    }
+    @media (max-width: 560px) {
+      #theme-tab-customize .theme-custom {
+        grid-template-columns: 1fr;
+      }
+      #theme-tab-customize .theme-fd-row,
+      #theme-tab-customize .theme-harmony-row {
+        flex-direction: column;
+        align-items: stretch;
+      }
+      #theme-tab-customize .theme-fd-group,
+      #theme-tab-customize .theme-harmony-row .theme-fd-group {
+        width: 100%;
+      }
+    }
+
+
+    /* Mobile: bottom sheet modals — slide up from bottom */
+    @media (max-width: 768px) {
+      .modal {
+        align-items: flex-end;
+        background: rgba(0,0,0,0.4);
+        pointer-events: auto;
+        /* Anchor the bottom sheet to the DYNAMIC viewport. The base overlay is
+           position:fixed; height:100% = the layout viewport, whose bottom sits
+           UNDER Firefox Android's bottom URL bar — so flex-end parked the sheet
+           (and its footer / Start button) beneath the bar. dvh excludes the bar
+           so the footer lands above it. Extra safe-area pad for iOS home bar. */
+        height: 100dvh;
+        padding-bottom: env(safe-area-inset-bottom, 0px);
+      }
+      /* Confirm dialog stays centered, not a bottom sheet */
+      #styled-confirm-overlay {
+        align-items: center;
+      }
+      .styled-confirm-box {
+        border-radius: 12px !important;
+        border: 1px solid var(--border) !important;
+        animation: modal-enter 0.25s ease-out both !important;
+        max-height: none !important;
+      }
+      .styled-confirm-box.modal-closing {
+        animation: modal-exit 0.18s ease-in both !important;
+      }
+      .styled-confirm-box::before {
+        display: none !important;
+      }
+      .styled-confirm-box .close-btn {
+        display: none !important;
+      }
+      #theme-popup {
+        width: 100% !important;
+        height: 65vh !important;
+        max-height: 65vh !important;
+        top: auto !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
+        position: fixed !important;
+        border-radius: 14px 14px 0 0;
+        border: none;
+        border-top: 1px solid var(--border);
+        padding: 6px 12px 12px;
+        animation: sheet-enter 0.2s ease-out forwards;
+        overflow-y: hidden !important;
+      }
+      #theme-popup .theme-tab-panel {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+        -webkit-overflow-scrolling: touch;
+      }
+      #theme-popup.sheet-ready {
+        animation: none;
+      }
+      #theme-popup.modal-closing {
+        animation: sheet-exit 0.15s ease-in both !important;
+      }
+      .modal-content,
+      .memory-modal-content,
+      .settings-modal-content,
+      #compare-model-overlay .modal-content {
+        width: 100% !important;
+        /* 85dvh leaves comfortable headroom above the sheet so the user can
+           still see the chat behind it and the drag handle has breathing
+           room. Was 65vh (too short, content clipped) → 90vh (too tall, top
+           hugged the status bar). */
+        max-height: 85dvh !important;
+        max-height: 85vh !important; /* fallback for browsers without dvh */
+        height: auto !important;
+        border-radius: 14px 14px 0 0;
+        border: none;
+        border-top: 1px solid var(--border);
+        padding-top: 6px !important;
+        /* Clip children to the rounded top corners — otherwise the sticky
+           modal-header's var(--panel) background paints a darker
+           rectangle past the radius and the corners look square again. */
+        overflow: hidden;
+        animation: sheet-enter 0.2s ease-out forwards;
+      }
+      /* Keep the tall tool drawers on a shared mobile height ceiling. The
+         final mobile drawer rule below makes this content-sized so shorter
+         tools remain compact while tall ones leave a visible top gap. */
+      #cookbook-modal .modal-content,
+      #tasks-modal .modal-content,
+      #calendar-modal .modal-content,
+      #memory-modal .memory-modal-content,
+      #settings-modal .settings-modal-content,
+      #email-lib-modal .modal-content,
+      #doclib-modal .doclib-modal-content {
+        max-height: 90vh !important;
+        max-height: 90dvh !important;
+        height: auto !important;
+        /* Reserve the iOS home-indicator / bottom-bar strip so an expanded
+           skill card's footer (Delete / Edit / Run) isn't hidden under it.
+           dvh already excludes the URL bar; this handles the safe area. */
+        padding-bottom: env(safe-area-inset-bottom, 0px) !important;
+      }
+      /* Grab handle pill on the non-standard content classes too. Memory's
+         desktop rule defines a radial-glow ::before later in the file —
+         these body-prefixed selectors win the specificity battle on mobile. */
+      body .memory-modal-content::before,
+      body .settings-modal-content::before {
+        content: '';
+        display: block;
+        position: static;
+        inset: auto;
+        width: 36px; height: 4px;
+        background: var(--fg);
+        opacity: 0.25;
+        border-radius: 2px;
+        margin: 0 auto 4px;
+        flex-shrink: 0;
+        padding: 0;
+        border-top: 10px solid transparent;
+        border-bottom: 6px solid transparent;
+        background-clip: padding-box;
+        animation: none;
+      }
+      .memory-modal-content .close-btn,
+      .memory-modal-content .modal-close,
+      .settings-modal-content .close-btn,
+      .settings-modal-content .modal-close {
+        display: none !important;
+      }
+      .memory-modal-content,
+      .settings-modal-content {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+      }
+      /* Library modals use the same bottom-sheet sizing as the other tools.
+         The shared mobile drawer rule below provides the top breathing room. */
+      #cookbook-modal .modal-content,
+      #calendar-modal .modal-content,
+      #email-lib-modal .modal-content,
+      #doclib-modal .modal-content,
+      #gallery-modal .modal-content,
+      #tasks-modal .modal-content {
+        /* vh-first as fallback for very old browsers, then dvh wins so
+           the modal shrinks/grows with the mobile URL bar. Wrong order
+           previously made the expanded chat preview extend below the
+           visible viewport on Chrome/Safari mobile (URL bar covered the
+           action buttons row). */
+        max-height: 90vh !important;
+        max-height: 90dvh !important;
+        height: auto !important;
+      }
+      /* Anchor those modals to the top so the full height is usable */
+      #cookbook-modal,
+      #calendar-modal,
+      #email-lib-modal,
+      #doclib-modal,
+      #gallery-modal,
+      #tasks-modal {
+        padding-top: 0 !important;
+        align-items: stretch !important;
+      }
+      /* Deep Research already gets the swipe grab-handle pill from the
+         shared `.modal-content::before` rule (the pane carries that class).
+         The previous header-level pill was redundant and rendered as a
+         SECOND pill stacked above the real one — removed. */
+      /* The inner body must flex to fill the new full height, and the
+         tasks list inside it must scroll independently — otherwise the
+         list crops at whatever pre-mobile height the desktop layout had. */
+      #tasks-modal .modal-content {
+        display: flex !important;
+        flex-direction: column !important;
+      }
+      #tasks-modal .modal-body {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      /* Memory/Skills: the body lacks flex:1, so on the fixed-height mobile
+         sheet (overflow:hidden) it grew past the viewport and clipped the
+         bottom of the skills list + its row action buttons with no way to
+         scroll there. Bound the body so the inner list scrolls internally. */
+      #memory-modal .memory-modal-content {
+        display: flex !important;
+        flex-direction: column !important;
+      }
+      /* flex-basis MUST be 0 (not auto) — matches the working #doclib-modal
+         .modal-body. With basis:auto, Firefox (incl. mobile) sizes the body
+         to its content (~half height) instead of filling, while Chromium
+         fills it regardless — which is exactly why the skill expand worked
+         on desktop/Chromium but stuck at ~50% on Firefox mobile. */
+      #memory-modal .memory-modal-body {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+        overflow: hidden !important;
+      }
+      /* Same basis:0 fix down the rest of the skills chain so every layer
+         fills instead of sizing to content under Firefox. */
+      #memory-modal .memory-tab-panel[data-memory-panel="skills"] {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+      }
+      #memory-modal .memory-tab-panel[data-memory-panel="skills"] > .admin-card {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+      }
+      /* The skills modal carries an extra toolbar row (search/sort/select +
+         bulk bar) the doc/email libraries don't, so the shared 82dvh preview
+         min-height overflowed here and pushed the expanded footer (Delete /
+         Edit / Run) off the bottom of the screen. Let flexbox size it from
+         the resolved card height instead, so the footer always pins inside
+         the visible area. */
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview {
+        min-height: 0 !important;
+      }
+      /* Once enter animation finishes, clear it so inline transform works for dragging */
+      .modal-content.sheet-ready {
+        animation: none;
+      }
+      .modal-content.modal-closing {
+        animation: sheet-exit 0.15s ease-in both !important;
+      }
+      /* Grab handle — large touch target, visible pill */
+      #theme-popup::before,
+      .modal-content::before {
+        content: '';
+        display: block;
+        width: 36px; height: 4px;
+        background: var(--fg); opacity: 0.25;
+        border-radius: 2px;
+        margin: 0 auto 4px;
+        flex-shrink: 0;
+        padding: 0;
+        /* Expand touch target without changing visual size */
+        border-top: 10px solid transparent;
+        border-bottom: 6px solid transparent;
+        background-clip: padding-box;
+      }
+      /* Hide close X on mobile — swipe down to dismiss */
+      .modal-content .close-btn,
+      .modal-content .modal-close,
+      #theme-popup .close-btn {
+        display: none !important;
+      }
+      /* Hide the auto-injected minimize (_) button on mobile — the dock
+         chip already represents the minimized state, and the swipe-down
+         gesture is the canonical minimize action. */
+      .modal-minimize-btn,
+      .minimize-btn,
+      [data-minimize] {
+        display: none !important;
+      }
+      /* Lock modals to vertical touch only, prevent horizontal dragging */
+      .modal-content {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+      }
+      .modal-content .cookbook-body,
+      .modal-content .modal-body {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+      }
+      .modal-header {
+        cursor: default;
+        touch-action: none;
+      }
+      @keyframes sheet-enter {
+        from { transform: translateY(100%); }
+        to   { transform: translateY(0); }
+      }
+      @keyframes sheet-exit {
+        from { transform: translateY(0); }
+        to   { transform: translateY(100%); }
+      }
+    }
+
+    /* ── Model A/B Comparison ── */
+
+    /* -- Extracted inline-style classes -- */
+    .cmp-header-action-btn {
+      background: none; border: 1px solid var(--border); color: var(--fg);
+      cursor: pointer; padding: 3px 10px; font-size: 11px; font-weight: 600;
+      opacity: 0.7; transition: all 0.15s; line-height: 1; border-radius: 4px;
+      display: inline-flex; align-items: center; font-family: inherit;
+    }
+    .cmp-form-control {
+      padding: 8px; background: var(--bg); color: var(--fg);
+      border: 1px solid var(--border); border-radius: 6px; font-size: 0.85em;
+    }
+    .cmp-btn-secondary {
+      background: transparent; color: var(--fg); border: 1px solid var(--border);
+      border-radius: 6px; cursor: pointer;
+    }
+    .cmp-btn-primary {
+      background: var(--fg); color: var(--bg); border: none;
+      border-radius: 6px; cursor: pointer; font-weight: 600;
+      transition: filter 0.12s, background 0.12s, color 0.12s;
+    }
+    /* Override global button:hover (which switches bg to var(--panel) =
+       very dark) — keep the bright primary look and just brighten slightly. */
+    .cmp-btn-primary:hover:not(:disabled) {
+      background: var(--fg); color: var(--bg);
+      filter: brightness(1.1);
+    }
+    .cmp-btn-primary:active:not(:disabled) { filter: brightness(0.95); }
+    .cmp-btn-secondary:hover:not(:disabled) {
+      background: color-mix(in srgb, var(--fg) 8%, transparent);
+      border-color: var(--fg); color: var(--fg);
+    }
+    .cmp-model-row {
+      display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
+      flex-wrap: wrap;
+      padding: 2px 0;
+      transition: margin-left 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.15s, border-color 0.15s;
+    }
+    .cmp-model-row-duplicate {
+      margin-left: -4px;
+      margin-right: -4px;
+      padding: 4px;
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--color-error) 8%, transparent);
+      border: 1px solid color-mix(in srgb, var(--color-error) 20%, transparent);
+    }
+    .cmp-model-row-swap-target {
+      margin-left: -4px;
+      margin-right: -4px;
+      padding: 4px;
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--accent, var(--red)) 8%, transparent);
+      box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 42%, transparent);
+    }
+    .cmp-model-row-swap-target .cmp-row-label {
+      color: var(--accent, var(--red));
+      opacity: 1;
+    }
+    .cmp-row-label {
+      color: var(--fg); font-size: 0.82em; font-weight: 600; min-width: 20px;
+      opacity: 0.4; text-align: center; flex-shrink: 0;
+    }
+    .cmp-model-meta {
+      order: 3;
+      flex: 1 0 calc(100% - 28px);
+      margin-left: 28px;
+      margin-top: -4px;
+      display: flex;
+      align-items: center;
+      gap: 5px;
+      min-width: 0;
+      font-size: 10px;
+      line-height: 1.2;
+    }
+    .cmp-model-meta-chip,
+    .cmp-model-meta-warning,
+    .cmp-model-meta-muted {
+      display: inline-flex;
+      align-items: center;
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      border-radius: 999px;
+      padding: 1px 6px;
+      letter-spacing: 0;
+    }
+    .cmp-model-meta-chip {
+      color: color-mix(in srgb, var(--fg) 60%, transparent);
+      background: color-mix(in srgb, var(--fg) 5%, transparent);
+      border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
+    }
+    .cmp-model-meta-warning {
+      color: var(--color-error);
+      background: color-mix(in srgb, var(--color-error) 10%, transparent);
+      border: 1px solid color-mix(in srgb, var(--color-error) 24%, transparent);
+      font-weight: 600;
+    }
+    .cmp-model-meta-muted {
+      color: color-mix(in srgb, var(--fg) 36%, transparent);
+    }
+    .cmp-rm-btn {
+      background: none; border: none; color: var(--accent, var(--red)); cursor: pointer;
+      min-width: 20px; font-size: 16px; font-weight: 600; opacity: 0.3;
+      transition: all 0.15s; padding: 0; line-height: 1; text-align: center;
+      position: relative; top: -2px;
+      order: 2;
+      margin-left: auto;
+      align-self: center;
+    }
+    .cmp-prov-select {
+      flex: 0 0 auto; width: 120px; font-size: 0.8em;
+    }
+    /* Eval-prompts picker — only shown during compare; absolute top-right
+       inside .chat-input-top, matching .model-picker-wrap's slot. */
+    .chat-input-top > .cmp-eval-wrap {
+      position: relative;
+      grid-column: 2;
+      grid-row: 1;
+      top: -2px; right: 0;
+      z-index: 2;
+    }
+    .cmp-eval-btn {
+      display: inline-flex; align-items: center; gap: 4px;
+      padding: 4px 8px;
+      background: transparent;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      color: var(--fg);
+      font-size: 11px; font-weight: 500;
+      font-family: inherit;
+      cursor: pointer; opacity: 0.75;
+      transition: opacity 0.15s, border-color 0.15s;
+    }
+    .cmp-eval-btn:hover { opacity: 1; border-color: var(--fg); }
+    .cmp-eval-clear {
+      display: inline-flex; align-items: center; gap: 4px; margin-left: 4px;
+      border: 0; background: transparent; color: var(--fg); opacity: .55;
+      padding: 3px 5px; font: inherit; font-size: 10px; cursor: pointer;
+    }
+    .cmp-eval-clear:hover { opacity: 1; }
+    .cmp-eval-caret { opacity: 0.7; transform: rotate(180deg); }
+    .cmp-eval-menu {
+      position: absolute; bottom: calc(100% + 4px); right: 0;
+      min-width: 220px; max-width: 280px;
+      max-height: 360px; overflow-y: auto;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      box-shadow: 0 -4px 16px rgba(0,0,0,0.3);
+      padding: 4px;
+      z-index: 1000;
+    }
+    .cmp-eval-menu.hidden { display: none; }
+    .cmp-eval-group-label {
+      font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px;
+      opacity: 0.45; font-weight: 600;
+      padding: 6px 8px 2px;
+    }
+    .cmp-eval-item {
+      display: block; width: 100%;
+      text-align: left;
+      padding: 5px 8px;
+      background: none; border: none;
+      color: var(--fg); font-size: 11px;
+      font-family: inherit;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+    .cmp-eval-item:hover {
+      background: color-mix(in srgb, var(--fg) 8%, transparent);
+    }
+    .cmp-eval-empty {
+      padding: 10px; text-align: center;
+      font-size: 11px; opacity: 0.5;
+    }
+    /* Tick on items that ship a known expected answer */
+    .cmp-eval-item-tick {
+      float: right;
+      margin-left: 6px;
+      font-size: 10px;
+      color: var(--color-success, #4caf50);
+      opacity: 0.8;
+    }
+    /* Expected-answer panel — floats as its own little window above the
+       chat-input-bar. Distinct surface, padded, drop-shadow, slightly
+       lifted so it reads as a separate UI element, not part of the input. */
+    .chat-input-bar:has(.cmp-eval-expected) { position: relative; }
+    .cmp-eval-expected {
+      position: absolute;
+      bottom: calc(100% + 8px);
+      right: 0;
+      display: inline-flex; align-items: center; gap: 8px;
+      padding: 8px 12px;
+      font-size: 11px;
+      background: var(--panel);
+      border: 1px solid color-mix(in srgb, var(--color-success, #4caf50) 50%, transparent);
+      border-radius: 8px;
+      box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
+      color: var(--fg);
+      width: fit-content;
+      z-index: 5;
+      pointer-events: auto;
+    }
+    .cmp-eval-expected.hidden { display: none; }
+    .cmp-eval-expected-label {
+      opacity: 0.6;
+      text-transform: uppercase;
+      font-size: 9px;
+      letter-spacing: 0.5px;
+      font-weight: 600;
+    }
+    .cmp-eval-expected-value {
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+      font-size: 11px;
+    }
+    .cmp-eval-expected-close {
+      background: none; border: none; color: var(--fg);
+      font-size: 14px; line-height: 1; padding: 0 0 0 4px;
+      opacity: 0.5; cursor: pointer; font-family: inherit;
+    }
+    .cmp-eval-expected-close:hover { opacity: 1; }
+    /* Auto-grade badge — stamped on a pane after stream completes when an
+       eval prompt with a known expected answer was used. */
+    .pane-grade-badge {
+      display: inline-flex; align-items: center; justify-content: center;
+      width: 18px; height: 18px;
+      margin: 0 4px;
+      font-size: 12px; font-weight: 700;
+      border-radius: 50%;
+      border: 1px solid currentColor;
+      flex-shrink: 0;
+    }
+    .pane-grade-badge.pass {
+      color: var(--color-success, #4caf50);
+      background: color-mix(in srgb, var(--color-success, #4caf50) 12%, transparent);
+    }
+    .pane-grade-badge.fail {
+      color: var(--color-error, #e55);
+      background: color-mix(in srgb, var(--color-error, #e55) 12%, transparent);
+    }
+
+    /* Compare probe overlay */
+    .compare-probe-overlay {
+      position: fixed; inset: 0; z-index: 300;
+      background: rgba(0,0,0,0.5); display: flex;
+      align-items: center; justify-content: center;
+      padding: 12px;
+      box-sizing: border-box;
+    }
+    .compare-probe-card {
+      width: min(460px, calc(100% - 16px));
+      max-height: min(620px, calc(100dvh - 24px));
+      box-sizing: border-box;
+      background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px;
+      display: flex; flex-direction: column; align-items: stretch; gap: 10px;
+      min-width: 280px; box-shadow: 0 8px 28px rgba(0,0,0,0.28);
+      overflow: hidden;
+      font-family: inherit;
+    }
+    .compare-probe-title {
+      font-size: 13px; font-weight: 400; color: var(--fg);
+    }
+    .compare-probe-title-label { font-weight: 600; }
+    .compare-probe-title-status { font-weight: 400; }
+    .compare-probe-list {
+      display: grid; grid-template-columns: 1fr 1fr; gap: 6px; min-width: 0; width: 100%;
+      overflow-y: auto;
+    }
+    .compare-probe-row {
+      display: flex; align-items: center; gap: 6px; padding: 6px 10px;
+      border-radius: 6px; background: color-mix(in srgb, var(--fg) 4%, transparent);
+      font-size: 12px; transition: background 0.2s; overflow: hidden;
+    }
+    .compare-probe-row.fail {
+      background: color-mix(in srgb, var(--color-error, #f44) 8%, transparent);
+    }
+    .compare-probe-spinner {
+      width: 24px; text-align: center; font-size: 10px; flex-shrink: 0;
+      letter-spacing: -1px; opacity: 0.6;
+    }
+    .compare-probe-spinner.ok { color: var(--color-success); animation: none; }
+    .compare-probe-spinner.fail { color: var(--color-error, #f44); animation: none; }
+    .compare-probe-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .compare-probe-status { font-size: 11px; opacity: 0.5; flex-shrink: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .compare-probe-status.ok { color: var(--color-success); opacity: 1; }
+    .compare-probe-status.fail { color: var(--color-error, #f44); opacity: 1; }
+    .compare-probe-action-btn {
+      display: inline-flex; align-items: center; gap: 5px;
+      min-height: 25px; padding: 3px 8px; background: transparent; color: var(--fg); border: 1px solid var(--border);
+      border-radius: 5px; cursor: pointer; font-size: 10px; font-family: inherit;
+      opacity: 0.78; transition: opacity 0.15s, border-color 0.15s, background 0.15s; white-space: nowrap; flex-shrink: 0;
+    }
+    .compare-probe-action-btn svg,
+    .compare-probe-footer-btn svg { width: 12px; height: 12px; flex: 0 0 auto; color: var(--accent, var(--red)); }
+    .compare-probe-action-btn:hover { opacity: 1; border-color: var(--accent, var(--red)); background: color-mix(in srgb, var(--fg) 5%, transparent); }
+    .compare-probe-feedback { display: flex; flex-direction: column; gap: 5px; }
+    .compare-probe-feedback:empty { display: none; }
+    .compare-probe-detail {
+      display: flex; align-items: center; gap: 6px; min-width: 0;
+      padding: 6px 8px; font-size: 10px;
+      background: color-mix(in srgb, var(--color-error, #f44) 6%, transparent);
+      border-left: 2px solid color-mix(in srgb, var(--color-error, #f44) 72%, transparent);
+      border-radius: 4px;
+    }
+    .compare-probe-detail-icon {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      flex: 0 0 14px;
+      width: 14px;
+      height: 14px;
+      color: var(--accent, var(--red));
+      font-family: inherit;
+      font-size: 11px;
+      font-weight: 700;
+      line-height: 1;
+    }
+    .compare-probe-detail-message { flex: 1 1 auto; min-width: 0; line-height: 1.35; color: color-mix(in srgb, var(--fg) 72%, transparent); overflow-wrap: anywhere; }
+    .compare-probe-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; margin-top: 2px; }
+    .compare-probe-footer-btn {
+      display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 10px;
+      font-family: inherit; font-size: 11px; font-weight: 600; line-height: 1;
+    }
+    .compare-probe-start-anyway,
+    .compare-probe-start-anyway:hover:not(:disabled) {
+      background: var(--accent, var(--red));
+      border: 1px solid var(--accent, var(--red));
+      color: var(--bg);
+    }
+    .compare-probe-start-anyway svg { color: currentColor; }
+    .compare-probe-start-anyway span { font-weight: 700; }
+    @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
+    @keyframes pane-shake {
+      0%, 100% { transform: translateX(0); }
+      15% { transform: translateX(-3px) rotate(-0.5deg); }
+      30% { transform: translateX(3px) rotate(0.5deg); }
+      45% { transform: translateX(-2px); }
+      60% { transform: translateX(2px); }
+      75% { transform: translateX(-1px); }
+    }
+
+    .chat-container.compare-active {
+      display: flex;
+      flex-direction: column;
+      padding: 0;
+      overflow: hidden;
+      animation: compare-enter 0.3s ease-out;
+    }
+    @keyframes compare-enter {
+      from { opacity: 0; transform: translateY(8px); }
+      to { opacity: 1; transform: translateY(0); }
+    }
+    .chat-container.compare-active .chat-input-bar {
+      margin-bottom: 0;
+    }
+    .chat-container.compare-active #model-picker-wrap {
+      display: none !important;
+    }
+    .compare-grid {
+      display: grid;
+      gap: 4px;
+      flex: 1 1 0;
+      min-height: 0;
+      overflow: hidden;
+      position: relative;
+    }
+    .compare-mobile-tabs { display: none; }
+    .compare-grid[data-cols="2"] { grid-template-columns: 1fr 1fr; }
+    .compare-grid[data-cols="3"] { grid-template-columns: 1fr 1fr 1fr; }
+    .compare-grid[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="5"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="6"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="7"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="8"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid { grid-auto-rows: 1fr; }
+    .compare-shuffle-notice {
+      position: absolute;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%) scale(0.94);
+      z-index: 30;
+      display: inline-flex;
+      align-items: center;
+      gap: 8px;
+      padding: 10px 14px;
+      border: 1px solid color-mix(in srgb, var(--red) 28%, var(--border));
+      border-radius: 8px;
+      background: color-mix(in srgb, var(--panel, var(--bg)) 92%, transparent);
+      color: var(--fg);
+      box-shadow: 0 10px 30px color-mix(in srgb, #000 24%, transparent);
+      font-size: 12px;
+      font-weight: 700;
+      line-height: 1;
+      opacity: 0;
+      pointer-events: none;
+      transition: opacity 0.14s ease, transform 0.14s ease;
+    }
+    .compare-shuffle-notice.show {
+      opacity: 1;
+      transform: translate(-50%, -50%) scale(1);
+    }
+    .compare-shuffle-notice-icon {
+      display: inline-flex;
+      color: var(--red);
+      animation: compare-shuffle-dice 0.56s ease both;
+    }
+    .compare-shuffle-notice-icon svg {
+      display: block;
+      width: 18px;
+      height: 18px;
+    }
+    @keyframes compare-shuffle-dice {
+      0% { transform: rotate(-18deg) scale(0.9); }
+      55% { transform: rotate(380deg) scale(1.08); }
+      100% { transform: rotate(360deg) scale(1); }
+    }
+    /* Sequential waterfall layout — stacked rows, staggered left, flush right */
+    .compare-grid.sequential-layout {
+      display: flex !important;
+      flex-direction: column !important;
+      grid-template-columns: none !important;
+      gap: 4px;
+      overflow-y: auto;
+    }
+    .compare-grid.sequential-layout .compare-pane {
+      flex-shrink: 0;
+      min-height: 200px;
+      transition: margin-left 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
+    }
+    /* Herringbone diagonal cut on left side of sequential pane headers */
+    .compare-grid.sequential-layout .compare-pane .pane-header {
+      clip-path: polygon(20px 0, 100% 0, 100% 100%, 0 100%);
+      padding-left: 26px;
+    }
+    .compare-pane {
+      display: flex;
+      flex-direction: column;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      overflow: hidden;
+      min-height: 0;
+      min-width: 0;
+      position: relative;
+      transition: border-color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
+    }
+    .compare-pane::before {
+      content: "";
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      height: 2px;
+      z-index: 2;
+      pointer-events: none;
+      background: transparent;
+      transform-origin: left center;
+    }
+    .compare-pane.is-streaming {
+      border-color: color-mix(in srgb, var(--accent, var(--red)) 34%, var(--border));
+      box-shadow: inset 0 1px 0 color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
+    }
+    .compare-pane.is-streaming::before {
+      background: linear-gradient(90deg, transparent 0%, var(--accent, var(--red)) 35%, var(--red) 50%, var(--accent, var(--red)) 65%, transparent 100%);
+      animation: compare-pane-progress 1.25s linear infinite;
+    }
+    .compare-pane.is-awaiting-input {
+      border-color: color-mix(in srgb, var(--red) 42%, var(--border));
+      box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--red) 12%, transparent);
+    }
+    .compare-pane.is-awaiting-input::before {
+      background: var(--red);
+      animation: none;
+    }
+    .compare-pane.is-done {
+      border-color: color-mix(in srgb, var(--green, #50fa7b) 20%, var(--border));
+    }
+    .compare-pane.is-failed {
+      border-color: color-mix(in srgb, var(--color-error) 42%, var(--border));
+    }
+    .compare-pane.is-failed::before {
+      background: var(--color-error);
+    }
+    @keyframes compare-pane-progress {
+      from { transform: translateX(-100%); }
+      to { transform: translateX(100%); }
+    }
+    .compare-pane .pane-header {
+      display: flex;
+      flex-direction: column;
+      align-items: stretch;
+      gap: 4px;
+      padding: 4px 10px;
+      background: color-mix(in srgb, var(--fg) 4%, transparent);
+      border-bottom: 1px solid var(--border);
+      font-size: 0.82em;
+      font-weight: 600;
+      color: var(--fg);
+      transition: background 0.4s;
+      flex-shrink: 0;
+      overflow: visible;
+      min-width: 0;
+      flex-wrap: nowrap;
+    }
+    .pane-header-row { display: flex; align-items: center; width: 100%; min-width: 0; }
+    .pane-header-primary { min-height: 24px; }
+    .pane-header-secondary { min-height: 20px; }
+    .pane-primary-actions, .pane-stats { display: flex; align-items: center; gap: 4px; min-width: 0; }
+    .pane-primary-actions { margin-left: auto; flex: 0 0 auto; }
+    .pane-stats { flex: 1 1 auto; overflow: hidden; }
+    .compare-header-bar button:hover,
+    .compare-header-bar button:focus-visible {
+      background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent) !important;
+      border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)) !important;
+      color: var(--fg) !important;
+      opacity: 1 !important;
+      outline: none;
+    }
+    .compare-header-bar button:active {
+      background: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent) !important;
+      transform: scale(0.96);
+    }
+    .compare-more-wrap { position: relative; display: inline-flex; }
+    .compare-more-btn {
+      display: inline-flex; align-items: center; justify-content: center;
+      width: 28px; height: 24px; padding: 0 !important;
+      border: 1px solid transparent; border-radius: 5px;
+      background: none; color: var(--fg); cursor: pointer;
+      opacity: 0.5; transition: opacity .15s, background .15s;
+    }
+    .compare-header-bar .compare-close-btn {
+      width: 24px; height: 24px; min-width: 24px; padding: 0;
+      border-radius: 999px; font-size: 0; display: inline-flex;
+      align-items: center; justify-content: center;
+    }
+    .compare-header-bar .compare-close-btn:hover,
+    .compare-header-bar .compare-close-btn:focus-visible {
+      background: color-mix(in srgb, var(--fg) 10%, transparent) !important;
+      border-color: transparent !important;
+      color: var(--fg) !important;
+      opacity: 1 !important;
+      outline: none;
+    }
+    .compare-header-bar .compare-more-btn:hover,
+    .compare-header-bar .compare-more-btn:focus-visible,
+    .compare-header-bar .compare-more-btn[aria-expanded="true"] {
+      background: color-mix(in srgb, var(--fg) 10%, transparent) !important;
+      border-color: transparent !important;
+      color: var(--fg) !important;
+      opacity: 1 !important;
+      outline: none;
+    }
+    .compare-more-menu {
+      position: absolute; top: calc(100% + 5px); right: 0; z-index: 10002;
+      display: none; flex-direction: column; gap: 3px; min-width: 132px;
+      padding: 5px; border: 1px solid var(--border); border-radius: 8px;
+      background: var(--panel, var(--bg)); box-shadow: 0 8px 24px rgba(0,0,0,.3);
+    }
+    .compare-more-menu.is-open { display: flex; }
+    .compare-more-menu > .compare-export-wrap,
+    .compare-more-menu > button {
+      width: 100%; display: flex; align-items: center;
+    }
+    .compare-more-menu #compare-export-btn,
+    .compare-more-menu #compare-shuffle-btn,
+    .compare-more-menu #compare-check-btn,
+    .compare-more-menu #compare-add-menu-btn {
+      width: 100%; min-height: 32px; height: 32px;
+      box-sizing: border-box; justify-content: flex-start; gap: 7px;
+      padding: 7px 9px !important;
+      border: 1px solid transparent !important; border-radius: 6px;
+      background: transparent !important; color: var(--fg) !important;
+      font-size: 11px; font-weight: 500; opacity: .72;
+      transition: background .12s ease, opacity .12s ease, color .12s ease;
+    }
+    .compare-more-menu #compare-export-btn:hover,
+    .compare-more-menu #compare-shuffle-btn:hover,
+    .compare-more-menu #compare-check-btn:hover,
+    .compare-more-menu #compare-add-menu-btn:hover,
+    .compare-more-menu #compare-export-btn:focus-visible,
+    .compare-more-menu #compare-shuffle-btn:focus-visible,
+    .compare-more-menu #compare-check-btn:focus-visible,
+    .compare-more-menu #compare-add-menu-btn:focus-visible {
+      opacity: 1; background: color-mix(in srgb, var(--red) 10%, transparent) !important;
+      border-color: transparent !important; color: var(--fg) !important; outline: none;
+    }
+    .compare-more-menu #compare-export-btn svg,
+    .compare-more-menu #compare-shuffle-btn svg,
+    .compare-more-menu #compare-check-btn svg,
+    .compare-more-menu #compare-add-menu-btn svg { flex: 0 0 auto; opacity: .65; }
+    .compare-more-menu #compare-export-btn span,
+    .compare-more-menu #compare-shuffle-btn span,
+    .compare-more-menu #compare-check-btn span,
+    .compare-more-menu #compare-add-menu-btn span {
+      font-size: 11px !important; margin-left: 0 !important;
+      color: var(--fg) !important; opacity: 1 !important; font-weight: 500 !important;
+    }
+    .compare-more-menu #compare-export-btn,
+    .compare-more-menu #compare-shuffle-btn,
+    .compare-more-menu #compare-check-btn,
+    .compare-more-menu #compare-add-menu-btn {
+      color: var(--fg) !important; opacity: 1 !important; font-weight: 500 !important;
+    }
+    .compare-more-menu #compare-export-btn svg,
+    .compare-more-menu #compare-shuffle-btn svg,
+    .compare-more-menu #compare-check-btn svg,
+    .compare-more-menu #compare-add-menu-btn svg { color: var(--fg) !important; opacity: .65 !important; }
+    .compare-export-item:hover,
+    .compare-export-item:focus-visible {
+      background: color-mix(in srgb, var(--red) 10%, transparent) !important;
+      border-color: transparent !important; outline: none;
+    }
+    .compare-export-item { display: flex; align-items: center; gap: 8px; }
+    .compare-export-item > svg { flex: 0 0 auto; opacity: .65; }
+    .compare-export-menu .compare-export-item,
+    .compare-export-menu .compare-export-item > span {
+      color: var(--fg) !important;
+      opacity: 1 !important;
+    }
+    .compare-add-flap {
+      position: absolute; z-index: 20; right: -8px; top: 50%;
+      width: 34px; min-width: 34px; height: 30px; box-sizing: border-box;
+      padding: 0 !important;
+      display: inline-flex; flex-direction: row; align-items: center; justify-content: center;
+      gap: 0; border: 1px solid var(--border);
+      border-radius: 0 7px 7px 0; background: var(--panel, var(--bg));
+      color: var(--fg); cursor: pointer; opacity: .7;
+      box-shadow: 3px 2px 9px color-mix(in srgb, var(--fg) 12%, transparent);
+      transform: translate(0, -50%); transition: width .18s ease, opacity .15s, background .15s, box-shadow .15s;
+    }
+    .compare-add-flap:hover, .compare-add-flap:focus-visible {
+      width: 70px; opacity: 1; background: color-mix(in srgb, var(--fg) 8%, var(--panel));
+      box-shadow: 4px 3px 12px color-mix(in srgb, var(--fg) 18%, transparent);
+      outline: none;
+    }
+    .compare-add-flap span {
+      max-width: 0; overflow: hidden; white-space: nowrap; margin-left: 0 !important;
+      font-size: 10px; opacity: 0; transition: max-width .18s ease, margin-left .18s ease, opacity .12s;
+    }
+    .compare-add-flap:hover span, .compare-add-flap:focus-visible span {
+      max-width: 32px; margin-left: 3px !important; opacity: 1;
+    }
+    .compare-add-flap svg { flex-shrink: 0; position: relative; left: -3px; }
+    @media (max-width: 768px) {
+      .compare-add-flap {
+        top: 8px; right: 8px; width: auto; min-width: 32px; height: 32px;
+        padding: 5px 8px !important; flex-direction: row; transform: none;
+        border-right: 1px solid var(--border); border-radius: 6px;
+      }
+      .compare-add-flap span { opacity: 1; }
+    }
+    .pane-actions {
+      display: flex; gap: 4px; align-items: center; margin-left: auto; flex-shrink: 0;
+    }
+    .compare-pane-footer {
+      font-size: 0.72em; opacity: 0.4; padding: 4px 10px;
+      border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
+      text-align: center; flex-shrink: 0;
+    }
+    /* Per-pane vote button. Sits at the bottom of each compare pane so
+       the action lives right under the response it judges. */
+    .pane-vote-footer {
+      padding: 6px 8px;
+      border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
+      background: color-mix(in srgb, var(--fg) 3%, transparent);
+      flex-shrink: 0;
+    }
+    .pane-vote-btn {
+      width: 100%;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      padding: 6px 10px;
+      font-family: inherit;
+      font-size: 12px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: background 0.15s, border-color 0.15s, opacity 0.15s;
+    }
+    .pane-vote-btn:hover:not(:disabled) {
+      background: color-mix(in srgb, var(--accent, var(--fg)) 12%, var(--bg));
+      border-color: var(--accent, var(--fg));
+    }
+    .pane-vote-btn:disabled { cursor: not-allowed; }
+    .pane-vote-label {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      min-width: 0;
+    }
+    .pane-action-btn {
+      background: none; border: none; color: var(--fg); cursor: pointer;
+      opacity: 0.3; padding: 2px; border-radius: 4px;
+      display: flex; align-items: center; transition: all 0.15s;
+    }
+    .pane-action-btn:hover { opacity: 0.8; background: color-mix(in srgb, var(--fg) 6%, transparent); }
+    /* Pane title as clickable model-swap button */
+    .pane-title-btn {
+      background: none; border: none; cursor: pointer;
+      font-size: 10px; font-weight: 400; font-family: inherit;
+      color: var(--fg); padding: 0;
+      text-align: left; display: flex; align-items: center; gap: 4px;
+      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+      transition: opacity 0.15s;
+      min-width: 0; flex: 1 1 0;
+    }
+    .pane-title-btn:hover { opacity: 0.7; }
+    .pane-title-caret { font-size: 0.6em; opacity: 0.35; flex-shrink: 0; position: relative; top: 2px; }
+    .pane-title-btn:hover .pane-title-caret { opacity: 0.7; }
+    .pane-mode-badge {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      flex: 0 0 auto;
+      height: 18px;
+      padding: 0 6px;
+      border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
+      border-radius: 999px;
+      background: color-mix(in srgb, var(--fg) 3%, var(--bg));
+      color: color-mix(in srgb, var(--fg) 76%, transparent);
+      font-size: 9px;
+      font-weight: 600;
+      line-height: 1;
+      letter-spacing: 0;
+      white-space: nowrap;
+    }
+    .pane-mode-dot {
+      width: 5px;
+      height: 5px;
+      border-radius: 50%;
+      background: currentColor;
+      opacity: 0.75;
+      flex: 0 0 auto;
+    }
+    .pane-mode-detail {
+      opacity: 0.52;
+      font-weight: 500;
+    }
+    .pane-mode-badge.pane-mode-icon-only {
+      width: 20px;
+      padding: 0;
+      justify-content: center;
+    }
+    .pane-mode-settings {
+      opacity: 0.65;
+      cursor: pointer;
+    }
+    .pane-settings-btn { width: 20px; height: 20px; justify-content: center; }
+    .pane-settings-btn[aria-expanded="true"] { opacity: 1; background: color-mix(in srgb, var(--fg) 9%, transparent); }
+    .response-metrics[data-action="settings"] { cursor: pointer; }
+    .response-thinking-state { color: color-mix(in srgb, var(--fg) 55%, transparent); }
+    .pane-mode-settings:hover,
+    .pane-mode-settings:focus-visible,
+    .pane-mode-settings[aria-expanded="true"] {
+      opacity: 1;
+      color: var(--fg);
+      background: color-mix(in srgb, var(--fg) 9%, var(--bg));
+      border-color: color-mix(in srgb, var(--accent, var(--fg)) 45%, var(--border));
+      outline: none;
+    }
+    .pane-mode-icon,
+    .pane-mode-icon svg {
+      display: block;
+      width: 12px;
+      height: 12px;
+    }
+    .pane-mode-agent {
+      color: color-mix(in srgb, var(--fg) 76%, transparent);
+      background: color-mix(in srgb, var(--fg) 3%, var(--bg));
+      border-color: color-mix(in srgb, var(--border) 70%, transparent);
+    }
+    .pane-mode-search {
+      color: color-mix(in srgb, var(--accent, var(--red)) 82%, var(--fg));
+      background: color-mix(in srgb, var(--accent, var(--red)) 10%, var(--bg));
+      border-color: color-mix(in srgb, var(--accent, var(--red)) 28%, var(--border));
+    }
+    .pane-mode-research {
+      color: color-mix(in srgb, #36c48f 82%, var(--fg));
+      background: color-mix(in srgb, #36c48f 10%, var(--bg));
+      border-color: color-mix(in srgb, #36c48f 28%, var(--border));
+    }
+    .compare-check-label {
+      position: relative;
+      top: -2px;
+      font-size: 11px;
+      margin-left: 3px;
+    }
+    .compare-check-icon {
+      position: relative;
+      top: 1px;
+    }
+    #compare-check-btn > .spinner-whirlpool {
+      position: relative;
+      top: -2px;
+      transform: none;
+    }
+    .compare-pane .pane-close-btn { opacity: 0.3; }
+    .compare-pane .pane-close-btn:hover { opacity: 1; color: var(--color-error); }
+    /* Model swap dropdown under pane title */
+    .pane-model-dropdown {
+      position: absolute;
+      z-index: 1000;
+      min-width: 220px;
+      max-height: 300px;
+      overflow-y: auto;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      box-shadow: 0 4px 16px rgba(0,0,0,0.3);
+      padding: 4px;
+    }
+    .pane-model-item {
+      display: block; width: 100%;
+      box-sizing: border-box;
+      padding: 8px 10px; font-size: 11px; line-height: 1.3;
+      text-align: left; background: none; border: none; border-radius: 6px;
+      color: var(--fg); cursor: pointer; transition: background 0.12s ease;
+      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+    }
+    .pane-model-item:hover,
+    .pane-model-item:focus-visible { background: color-mix(in srgb, var(--red) 10%, transparent); outline: none; }
+    .pane-model-item.current { color: var(--red); font-weight: 600; }
+    .pane-timer {
+      font-size: 10px; font-weight: 400; opacity: 0.45; font-variant-numeric: tabular-nums;
+      white-space: nowrap; padding-right: 4px;
+    }
+    .pane-summary {
+      display: inline-flex;
+      align-items: center;
+      position: relative;
+      top: 1px;
+      min-height: 16px;
+      max-width: 150px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      padding: 0 5px;
+      border-radius: 999px;
+      background: color-mix(in srgb, var(--fg) 4%, transparent);
+      color: color-mix(in srgb, var(--fg) 58%, transparent);
+      font-size: 9px;
+      font-weight: 500;
+      line-height: 1;
+      font-variant-numeric: tabular-nums;
+      cursor: pointer;
+      transition: max-width .15s ease, white-space .15s ease;
+    }
+    .pane-summary:empty { display: none; }
+    .pane-stats-popup {
+      position: fixed; z-index: 10040; width: 210px; padding: 6px;
+      background: var(--panel, var(--bg)); color: var(--fg);
+      border: 1px solid var(--border); border-radius: 7px;
+      box-shadow: 0 8px 24px rgba(0,0,0,.3); font-size: 11px;
+    }
+    .pane-stats-popup-title { padding: 4px 6px 7px; font-weight: 600; border-bottom: 1px solid var(--border); }
+    .pane-stats-popup-row { display: flex; justify-content: space-between; gap: 12px; padding: 6px; }
+    .pane-stats-popup-row span { color: color-mix(in srgb, var(--fg) 55%, transparent); }
+    .pane-stats-popup-row strong { font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; }
+    /* Metrics stay on the lower row even in dense comparisons. */
+    .compare-grid[data-cols="4"] .pane-timer,
+    .compare-grid[data-cols="5"] .pane-timer,
+    .compare-grid[data-cols="6"] .pane-timer {
+      width: auto; order: initial; margin-top: 0; padding-bottom: 0; padding-left: 0;
+    }
+    .pane-finish-badge {
+      font-weight: 600; color: var(--green, #50fa7b);
+      position: relative;
+      top: 2px;
+    }
+    .compare-pane.winner .pane-header {
+      background: color-mix(in srgb, var(--green, #50fa7b) 12%, transparent);
+      border-bottom-color: color-mix(in srgb, var(--green, #50fa7b) 30%, var(--border));
+    }
+    .compare-pane.winner .pane-title {
+      color: var(--green, #50fa7b);
+    }
+    .compare-pane.loser .pane-header {
+      opacity: 0.5;
+    }
+    .confetti-piece {
+      position: fixed;
+      pointer-events: none;
+      z-index: 1000000;
+    }
+    .compare-pane.expanded { grid-column: 1 / -1; }
+    .compare-pane .chat-history {
+      flex: 1 1 0;
+      min-height: 0;
+      overflow-y: auto !important;
+      overflow-x: hidden;
+      padding: 8px;
+      display: flex;
+      flex-direction: column;
+    }
+    .compare-pane .chat-history .msg {
+      flex-shrink: 0;
+    }
+    .compare-gen-image {
+      max-width: 100%;
+      max-height: 100%;
+      object-fit: contain;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+    .compare-section {
+      margin-bottom: 14px;
+    }
+    .compare-section:last-child {
+      margin-bottom: 0;
+    }
+    .compare-section-label {
+      font-size: 9px;
+      text-transform: uppercase;
+      letter-spacing: 0.6px;
+      opacity: 0.5;
+      margin-bottom: 5px;
+      font-weight: 600;
+    }
+    /* Active-type/mode readout next to "Type:"/"Mode:" — only needed on mobile
+       (where the tab/toggle text labels are hidden); hidden on desktop. */
+    .compare-type-current,
+    .compare-mode-current { display: none; }
+    /* Contextual one-liner under the mode toggles describing what you just
+       changed — empty until the first toggle, then a subtle hint. */
+    .compare-mode-hint {
+      font-size: 11px;
+      opacity: 0.55;
+      margin-top: 6px;
+      min-height: 0;
+      line-height: 1.3;
+    }
+    .compare-mode-hint:empty { display: none; }
+    .compare-type-tabs .compare-mode-tab > svg,
+    .compare-type-current > svg {
+      color: var(--accent, var(--red));
+    }
+
+    .compare-mode-tabs {
+      display: flex;
+      gap: 4px;
+      flex-wrap: wrap;
+      min-width: 0;
+    }
+    /* Type tabs match Mode toggles 1:1 (same flex column layout, same metrics) */
+    .compare-mode-tab {
+      display: flex; flex-direction: column; align-items: center; justify-content: center;
+      width: 56px; height: 38px; flex: 1 1 0;
+      padding: 5px 4px 4px; border: 1px solid var(--border); border-radius: 6px;
+      cursor: pointer; user-select: none; transition: all 0.15s;
+      background: none; color: var(--fg); opacity: 0.35;
+      flex-shrink: 0; gap: 0;
+      font-family: inherit;
+    }
+    .compare-mode-tab > svg {
+      width: 13px; height: 13px; flex: 0 0 13px;
+    }
+    .compare-mode-tab:hover {
+      opacity: 0.7; background: color-mix(in srgb, var(--fg) 6%, transparent);
+    }
+    .compare-mode-tab.active {
+      opacity: 1; border-color: var(--fg);
+      background: color-mix(in srgb, var(--fg) 10%, transparent);
+    }
+    .compare-sources-box {
+      display: flex; align-items: center; gap: 6px;
+      padding: 6px 10px; margin-bottom: 8px;
+      border-radius: 6px; font-size: 0.78em;
+      background: color-mix(in srgb, var(--red) 8%, transparent);
+      border: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+      color: color-mix(in srgb, var(--fg) 70%, transparent);
+      cursor: default;
+    }
+    .compare-sources-box .sources-label { font-weight: 600; }
+    /* Compact tool blocks inside compare panes */
+    .compare-pane .agent-thread-node {
+      margin: 4px 0; font-size: 0.85em;
+    }
+    .compare-pane .agent-thread-cmd {
+      max-height: 80px; overflow-y: auto;
+    }
+    .compare-pane .agent-tool-output pre {
+      max-height: 120px; overflow-y: auto;
+    }
+    .compare-vote-bar {
+      display: flex; justify-content: center; gap: 8px;
+      padding: 8px; border-top: 1px solid var(--border);
+      flex-shrink: 0; flex-wrap: wrap;
+    }
+    .compare-vote-bar.hidden { display: none; }
+    .compare-vote-btn {
+      padding: 6px 13px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--panel); color: var(--fg); cursor: pointer; font-size: 0.8em;
+      transition: all 0.15s; white-space: nowrap;
+    }
+    .compare-score-label {
+      position: relative;
+      top: -2px;
+      display: inline-block;
+    }
+    .compare-vote-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .compare-vote-tie { opacity: 0.7; }
+    .compare-rematch-btn {
+      display: flex; align-items: center; gap: 6px; margin-left: 8px;
+      border-color: color-mix(in srgb, var(--accent, var(--red)) 60%, var(--border));
+      background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
+      color: var(--fg); opacity: 1; font-weight: 600;
+    }
+    .compare-rematch-btn:hover { border-color: var(--accent, var(--red)); background: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); }
+    /* Preview button accent in pane header */
+    .pane-preview-btn.active { color: var(--red); }
+    /* Full-pane iframe for HTML preview */
+    .compare-pane-iframe {
+      flex: 1;
+      width: 100%;
+      border: none;
+      border-radius: 0 0 8px 8px;
+      background: #fff;
+    }
+    /* ---- Add-pane "+" button in pane header (last pane only) ---- */
+    .pane-add-btn { display: none !important; font-size: 16px; font-weight: 600; }
+    .compare-pane:last-child .pane-add-btn { display: flex !important; }
+    /* Dropdown for adding a pane */
+    .add-pane-dropdown {
+      position: absolute;
+      right: 0;
+      z-index: 100;
+      background: var(--panel, var(--bg));
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      max-height: 300px;
+      overflow-y: auto;
+      min-width: 220px;
+      box-shadow: 0 8px 24px rgba(0,0,0,0.3), 0 0 0 1px color-mix(in srgb, var(--fg) 5%, transparent);
+      padding: 6px;
+      backdrop-filter: blur(12px);
+      animation: dropdown-in 0.15s ease-out;
+    }
+    .add-pane-search {
+      width: 100%;
+      padding: 6px 8px;
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      background: var(--bg);
+      color: var(--fg);
+      font-size: 0.85em;
+      box-sizing: border-box;
+      margin-bottom: 4px;
+      position: sticky;
+      top: 0;
+      z-index: 1;
+    }
+    /* Compare toggle buttons — icon + label stacked */
+    .compare-toggle-label {
+      display: block;
+      font-size: 9px;
+      line-height: 1;
+      margin-top: 2px;
+      font-weight: 500;
+    }
+    .compare-blind-toggle,
+    .compare-save-toggle,
+    .compare-dice-toggle,
+    .compare-parallel-toggle,
+    .compare-reset-toggle {
+      display: flex; flex-direction: column; align-items: center; justify-content: center;
+      width: 56px; height: auto; flex: 1 1 0;
+      padding: 5px 4px 4px; border: 1px solid var(--border); border-radius: 6px;
+      cursor: pointer; user-select: none; transition: all 0.15s;
+      background: none; color: var(--fg); opacity: 0.35;
+      flex-shrink: 0; gap: 0;
+    }
+    .compare-blind-toggle:hover,
+    .compare-save-toggle:hover,
+    .compare-dice-toggle:hover,
+    .compare-parallel-toggle:hover,
+    .compare-reset-toggle:hover {
+      opacity: 0.7; background: color-mix(in srgb, var(--fg) 6%, transparent);
+    }
+    .compare-blind-toggle.active {
+      opacity: 1; color: var(--accent, var(--red)); border-color: var(--accent, var(--red));
+      background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
+    }
+    .compare-blind-toggle.active > svg {
+      color: var(--accent, var(--red));
+    }
+    .compare-save-toggle.active {
+      opacity: 1; color: var(--color-save-green); border-color: var(--color-save-green);
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .compare-dice-toggle.active {
+      opacity: 1; color: var(--red); border-color: var(--red);
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .compare-parallel-toggle {
+      opacity: 1; color: #e0a050; border-color: #e0a050;
+      background: rgba(224, 160, 80, 0.1);
+    }
+    .compare-parallel-toggle.active {
+      color: var(--accent, var(--red)); border-color: var(--accent, var(--red));
+      background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
+    }
+    @media (max-width: 520px) {
+      .compare-toggle-label { display: none; }
+      /* Tab text labels are hidden on mobile, so spell out the active type next
+         to "Type:" with its icon. */
+      .compare-type-current {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        margin-left: 5px;
+        font-weight: 600;
+        vertical-align: -3px;
+      }
+      .compare-type-current svg { width: 14px; height: 14px; }
+      /* Mode list — comma-separated, each name already coloured inline. */
+      .compare-mode-current {
+        display: inline;
+        margin-left: 5px;
+        font-weight: 600;
+      }
+      .compare-blind-toggle,
+      .compare-save-toggle,
+      .compare-dice-toggle,
+      .compare-parallel-toggle,
+      .compare-reset-toggle {
+        width: 32px; height: 32px; min-width: 32px; padding: 0;
+      }
+      /* The Group tab's seq/parallel toggle is a single full-width button (not
+         a row of compact icons like the Compare header), so keep its label and
+         make it a comfortable touch target with the text beside the icon. */
+      #group-mode-btn {
+        flex-direction: row !important;
+        width: auto !important;
+        height: auto !important;
+        min-height: 44px;
+        padding: 8px 14px !important;
+        gap: 8px !important;
+        font-size: 13px;
+      }
+      #group-mode-btn .compare-toggle-label {
+        display: inline !important;
+        font-size: 13px;
+        margin-top: 0;
+      }
+      #group-mode-btn svg { width: 18px; height: 18px; }
+      /* Compare header: hide labels + close button, show icons only */
+      #compare-shuffle-btn span {
+        display: none;
+      }
+      #compare-shuffle-btn,
+      #compare-check-btn,
+      #compare-add-btn {
+        padding: 3px 6px;
+      }
+      /* Save space so the header buttons fit on one row: tighter padding +
+         smaller labels (icons kept full size). (Score now lives in the vote bar.) */
+      .compare-header-bar button { padding: 3px 5px !important; }
+      .compare-header-bar #compare-check-btn > span,
+      .compare-header-bar #compare-add-btn > span {
+        font-size: 10px; margin-left: 2px;
+      }
+      /* Compare mobile: keep the close X visible — it's the only way out
+         now that the hamburger is hidden during compare mode. */
+      .compare-header-bar {
+        padding: 14px 8px 10px 8px !important;
+        min-height: 44px;
+      }
+      .compare-pane .pane-header {
+        align-items: stretch;
+        row-gap: 3px;
+      }
+      .compare-pane .pane-title-btn {
+        flex: 1 1 auto;
+        width: auto;
+        padding-right: 2px;
+      }
+      /* Compare is already visibly in chat mode; the Chat speech-bubble
+         badge adds a redundant second row on narrow screens. */
+      .compare-pane .pane-mode-chat { display: none; }
+      .compare-pane .pane-timer,
+      .compare-pane .pane-summary,
+      .compare-pane .pane-finish-badge,
+      .compare-pane .pane-mode-badge,
+      .compare-pane .pane-actions {
+        order: 1;
+        align-self: center;
+      }
+      .compare-pane .pane-finish-badge { top: 0; }
+      .compare-pane .pane-summary {
+        max-width: 110px;
+      }
+      .compare-pane .pane-mode-detail {
+        display: none;
+      }
+      .compare-grid[data-cols="4"] .compare-pane .pane-timer,
+      .compare-grid[data-cols="5"] .compare-pane .pane-timer,
+      .compare-grid[data-cols="6"] .compare-pane .pane-timer {
+        width: auto;
+        order: 1;
+        margin-top: 0;
+        padding-bottom: 0;
+        padding-left: 0;
+      }
+      .compare-pane .pane-actions {
+        margin-left: auto;
+      }
+      /* Mode tabs: icons only, centered */
+      .compare-mode-tab span { display: none; }
+      .compare-mode-tabs { justify-content: center; }
+      /* Header action buttons: hide text labels on Export / Shuffle /
+         Model on mobile so the close X fits on the right. Score keeps
+         its "Score" label because its icon (4-square grid) reads too
+         similar to Shuffle's icon without text. */
+      .compare-header-bar #compare-export-btn > span,
+      .compare-header-bar #compare-shuffle-btn > span {
+        display: none;
+      }
+      /* Override the desktop override: on mobile the model picker overlay
+         MUST cap its height to the viewport so the dropdown doesn't run
+         past the bottom of the screen. */
+      #compare-model-overlay .modal-content {
+        max-height: 85dvh !important;
+        max-height: 85vh !important;
+        overflow: hidden !important;
+      }
+      #compare-model-overlay .modal-body {
+        overflow: auto !important;
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+    }
+    /* Hide number input spinners */
+    input[type="number"]::-webkit-inner-spin-button,
+    input[type="number"]::-webkit-outer-spin-button {
+      -webkit-appearance: none; margin: 0;
+    }
+
+    /* ── Sensitive info censor ── */
+    .censored-item {
+      filter: blur(5px);
+      cursor: pointer;
+      transition: filter 0.2s;
+      border-radius: 2px;
+      padding: 0 2px;
+      background: rgba(255,100,100,0.08);
+      user-select: none;
+    }
+    .censored-item:hover { filter: blur(3px); background: rgba(255,100,100,0.15); }
+    .censored-item.revealed {
+      filter: none;
+      background: rgba(100,255,100,0.08);
+      user-select: auto;
+      cursor: text;
+    }
+
+    #settings-menu-list .list-item.active {
+      background: color-mix(in srgb, var(--accent) 10%, transparent);
+      border-color: var(--accent);
+    }
+
+    /* ── Print / PDF Export ── */
+    @media print {
+      body { background: #fff !important; color: #000 !important; }
+      #sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
+      #welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
+      .modal, .toast, .overflow-wrapper, .mode-toggle, .incognito-btn,
+      button, .dropdown, .session-dropdown,
+      .agent-tool-spinner, .agent-thread-node.running { display: none !important; }
+      main.chat-container { width: 100% !important; margin: 0 !important; padding: 0 !important; max-height: none !important; overflow: visible !important; }
+      #chat-history { max-height: none !important; overflow: visible !important; height: auto !important; padding: 0 !important; }
+      .msg { break-inside: avoid; page-break-inside: avoid; border: none !important; box-shadow: none !important; }
+      .msg-ai { background: #f5f5f5 !important; color: #000 !important; }
+      .msg-user { background: #e8e8e8 !important; color: #000 !important; }
+      .msg .role { color: #333 !important; font-weight: bold; }
+      .msg .body { color: #000 !important; }
+      pre, code { background: #f0f0f0 !important; color: #000 !important; border: 1px solid #ccc !important; }
+      details { display: block !important; }
+      details[open] summary ~ * { display: block !important; }
+      details > summary { list-style: none; }
+      details > summary::before { content: "" !important; }
+      #chat-history::before { content: attr(data-print-title); display: block; font-size: 1.3em; font-weight: bold; margin-bottom: 1em; color: #000; }
+      a { color: #000 !important; text-decoration: underline; }
+    }
+
+
+/* ── Components (from style.css) ── */
+
+/* Self-hosted Fira Code font */
+@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
+@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
+@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
+
+/* Scrollbar styling */
+
+/* Code block styling */
+pre, code, .hljs {
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+/* WebKit (Chrome, Edge, Safari) */
+/* Utility class for red text */
+.red-text {
+  color: var(--red);
+}
+
+/* Internal chat links (search results, session references) */
+a.chat-link {
+  color: var(--hl-function);
+  text-decoration: none;
+  border-bottom: 1px dotted var(--hl-function);
+  cursor: pointer;
+}
+a.chat-link:hover {
+  opacity: 0.8;
+  border-bottom-style: solid;
+}
+
+a.chat-link[href^="#email-"] {
+  display: inline;
+  padding: 1px 5px;
+  border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 34%, transparent);
+  border-radius: 5px;
+  background: color-mix(in srgb, var(--accent, var(--red)) 8%, transparent);
+  color: var(--fg);
+  font-weight: 600;
+  border-bottom-style: solid;
+}
+a.chat-link[href^="#email-"]:hover {
+  opacity: 1;
+  background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
+  border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
+}
+
+a.chat-link[href^="#notes-more-"],
+a.chat-link[href^="#skills-more-"],
+a.chat-link[href^="#memories-more-"],
+a.chat-link[href^="#events-more-"],
+a.chat-link[href^="#sessions-more-"] {
+  color: var(--muted);
+  border-bottom: 1px dotted color-mix(in srgb, var(--muted) 55%, transparent);
+  font-size: 11px;
+  font-weight: 500;
+}
+a.chat-link[href^="#notes-more-"]:hover,
+a.chat-link[href^="#skills-more-"]:hover,
+a.chat-link[href^="#memories-more-"]:hover,
+a.chat-link[href^="#events-more-"]:hover,
+a.chat-link[href^="#sessions-more-"]:hover {
+  color: var(--fg);
+  opacity: 1;
+  border-bottom-style: solid;
+}
+.more-list-expanded {
+  display: block;
+  max-height: 0;
+  overflow: hidden;
+  opacity: 0;
+  color: inherit;
+  font: inherit;
+  line-height: inherit;
+  transition: max-height 220ms ease, opacity 160ms ease;
+}
+.more-list-expanded.is-open {
+  opacity: 1;
+}
+.more-list-expanded-content {
+  color: inherit;
+  font: inherit;
+  line-height: inherit;
+}
+
+/* Session items */
+.session-item { position: relative; }
+.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; }
+.session-menu-btn:hover { background: none !important; border-color: transparent !important; }
+@media (max-width: 768px) {
+  .session-menu-btn { display: none !important; }
+  .item-drag-handle { display: none !important; }
+}
+.session-menu-btn svg { transition: transform 0.2s ease; }
+
+/* First-time swipe hint */
+.swipe-hint {
+  position: absolute;
+  right: 8px;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 0.7rem;
+  color: var(--color-error, #f44);
+  opacity: 0.8;
+  transition: opacity 0.5s ease;
+  pointer-events: none;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  z-index: 2;
+}
+.swipe-hint-arrow {
+  animation: swipe-nudge 1s ease-in-out infinite;
+}
+@keyframes swipe-nudge {
+  0%, 100% { transform: translateX(0); }
+  50% { transform: translateX(-6px); }
+}
+
+/* Utility classes */
+.muted { opacity: 0.5; }
+.muted-sm { opacity: 0.35; font-size: 0.8em; }
+.accent-link { color: var(--accent-primary, var(--color-accent)); cursor: pointer; font-size: 0.85em; }
+.models-empty-state { text-align: center; padding: 12px 8px; line-height: 1.6; }
+
+/* Provider logo inside favorite dot */
+.provider-logo {
+  border: none !important;
+  background: none !important;
+  width: 14px !important; height: 14px !important;
+  display: inline-flex; align-items: center; justify-content: center;
+  transition: opacity 0.15s;
+}
+.provider-logo svg { width: 14px; height: 14px; display: block; }
+.provider-logo:hover { opacity: 1 !important; transform: scale(1.2); }
+
+/* Hide session menu button until hover — use width:0 so it doesn't steal space from text */
+.list-item .hamburger { opacity: 0; width: 0; min-width: 0; overflow: hidden; padding: 0 !important; transition: opacity 0.15s, width 0.15s, padding 0.15s; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
+.list-item:hover .hamburger { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px !important; }
+@media (max-width: 768px) {
+  .list-item .hamburger { opacity: 0.5; width: 28px; min-width: 28px; padding: 0 4px !important; }
+  .list-item .hamburger:active { opacity: 1; }
+}
+
+/* Hamburger menu button styling (overrides default button appearance) */
+button.hamburger {
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+}
+
+/* ============================================ */
+/* HEADER SIZING FOR CHAT MESSAGES */
+/* ============================================ */
+/* Markdown in chat messages — colorful, scannable */
+.msg h1, .msg h2, .msg h3, .msg h4, .msg h5, .msg h6 {
+  margin: 0.6em 0 0.3em 0;
+  line-height: 1.3;
+  border-bottom: none;
+  padding-bottom: 0;
+}
+
+.msg h1 {
+  font-size: 1.15em;
+  font-weight: 700;
+  color: var(--hl-keyword, #c678dd);
+}
+
+.msg h2 {
+  font-size: 1.1em;
+  font-weight: 600;
+  color: var(--hl-function, #5b8def);
+}
+
+.msg h3 {
+  font-size: 1.05em;
+  font-weight: 600;
+  color: var(--hl-string, #98c379);
+}
+
+.msg h4 {
+  font-size: 1.02em;
+  font-weight: 600;
+  color: var(--hl-builtin, #e5c07b);
+}
+
+.msg h5 {
+  font-size: 1em;
+  font-weight: 600;
+  color: var(--hl-variable, #61afef);
+}
+
+.msg h6 {
+  font-size: 0.95em;
+  font-weight: 600;
+  color: var(--hl-number, #d19a66);
+}
+
+/* Bold text — subtle accent color */
+.msg strong, .msg b {
+  color: var(--hl-builtin, #e5c07b);
+  font-weight: 600;
+}
+
+/* Italic — softer highlight */
+.msg em, .msg i {
+  color: var(--hl-params, #abb2bf);
+  font-style: italic;
+}
+
+/* Bold + italic */
+.msg strong em, .msg em strong,
+.msg b i, .msg i b,
+.msg b em, .msg em b,
+.msg strong i, .msg i strong {
+  color: var(--hl-keyword, #c678dd);
+}
+
+/* Strikethrough */
+.msg del {
+  color: color-mix(in srgb, var(--fg) 45%, transparent);
+  text-decoration: line-through;
+}
+
+/* Inline code */
+.msg code:not(pre code) {
+  color: var(--hl-string, #98c379);
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  padding: 1px 5px;
+  border-radius: 4px;
+  font-size: 0.9em;
+}
+
+/* Blockquotes */
+.msg blockquote {
+  border-left: 3px solid var(--hl-function, #5b8def);
+  padding: 4px 12px;
+  margin: 0.5em 0;
+  color: color-mix(in srgb, var(--fg) 75%, transparent);
+  background: var(--bg);
+  border-radius: 0 6px 6px 0;
+}
+.msg blockquote p { margin: 0.3em 0; }
+
+/* Horizontal rules */
+.msg hr {
+  border: none;
+  height: 1px;
+  background: linear-gradient(90deg, transparent, var(--border), transparent);
+  margin: 0.8em 0;
+}
+
+/* Lists */
+.msg ul, .msg ol {
+  margin: 0.3em 0 0.3em 1.2em;
+  padding: 0;
+}
+.msg li {
+  margin: 0.15em 0;
+}
+.msg li::marker {
+  color: var(--hl-function, #5b8def);
+}
+
+/* Links */
+.msg a {
+  color: var(--hl-function, #5b8def);
+  text-decoration: none;
+  border-bottom: 1px solid rgba(91, 141, 239, 0.3);
+  transition: border-color 0.15s;
+}
+.msg a:hover {
+  border-bottom-color: var(--hl-function, #5b8def);
+}
+.msg a.is-loading {
+  opacity: 0.65;
+  border-bottom-color: var(--accent, #bd93f9);
+  cursor: progress;
+}
+
+/* Tables */
+.msg table {
+  border-collapse: collapse;
+  margin: 0.5em 0;
+  font-size: 0.9em;
+  display: block;
+  width: max-content;
+  min-width: 100%;
+  max-width: 100%;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+  table-layout: auto;
+}
+.msg th {
+  background: color-mix(in srgb, var(--fg) 7%, transparent);
+  color: var(--hl-keyword, #c678dd);
+  font-weight: 600;
+  padding: 6px 12px;
+  border: 1px solid var(--border);
+  text-align: left;
+  min-width: 9ch;
+  word-break: normal;
+  overflow-wrap: break-word;
+}
+.msg td {
+  padding: 5px 12px;
+  border: 1px solid var(--border);
+  min-width: 9ch;
+  word-break: normal;
+  overflow-wrap: break-word;
+}
+
+/* Agent UI Styling */
+.agent-controls {
+  background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+  border: 1px solid #dee2e6;
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 12px;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.agent-toggle label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-weight: 500;
+  margin-bottom: 8px;
+}
+
+#workflow-selector {
+  margin-top: 8px;
+}
+
+#workflow-type {
+  width: 100%;
+  padding: 6px 12px;
+  border: 1px solid #ced4da;
+  border-radius: 4px;
+  background: white;
+  font-size: 14px;
+}
+
+.agent-progress {
+  background: #ffebee;
+  border: 1px solid #ef9a9a;
+  border-radius: 6px;
+  padding: 12px;
+  margin: 8px 0;
+  text-align: center;
+}
+
+.agent-working {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  font-style: italic;
+  color: var(--red);
+}
+
+.loading-dots::after {
+  content: '...';
+  animation: dots 1.5s infinite;
+}
+
+@keyframes dots {
+  0%, 20% { opacity: 0; }
+  40% { opacity: 0.5; }
+  60%, 100% { opacity: 1; }
+}
+
+.workflow-info {
+  background: #f8f9fa;
+  border: 1px solid var(--red);
+  border-radius: 6px;
+  padding: 8px 12px;
+  margin: 4px 0;
+  font-size: 0.9em;
+  color: var(--red);
+  text-align: center;
+}
+
+/* Scrollbar uses --red from :root (set at top of file) */
+
+/* Loading spinner */
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+.spinner {
+  width: 24px;
+  height: 24px;
+  margin: 8px auto;
+  border: 3px solid var(--border);
+  border-top-color: var(--red);
+  border-radius: 50%;
+  animation: spin 0.9s linear infinite;
+}
+
+/* Inline spinner for buttons */
+.btn-spinner {
+  display: inline-block;
+  width: 12px;
+  height: 12px;
+  border: 2px solid transparent;
+  border-top-color: currentColor;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-right: 6px;
+}
+
+/* Loading indicator for messages */
+.loading-indicator {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px;
+}
+.loading-dots {
+  display: flex;
+  gap: 4px;
+}
+.loading-dot {
+  width: 6px;
+  height: 6px;
+  background-color: var(--fg);
+  border-radius: 50%;
+  opacity: 0.6;
+}
+.loading-dot:nth-child(1) {
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+}
+.loading-dot:nth-child(2) {
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+  animation-delay: -0.32s;
+}
+.loading-dot:nth-child(3) {
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+  animation-delay: -0.64s;
+}
+@keyframes loading-bounce {
+  0%, 80%, 100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+/* Hamburger menu button */
+.hamburger {
+  display: inline-flex;
+  flex-direction: column;
+  justify-content: space-between;
+  width: 24px;
+  height: 18px !important;
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+}
+.hamburger span {
+  display: block;
+  width: 100%;
+  height: 3px;
+  background: var(--fg);
+  border-radius: 2px;
+}
+
+/* Agent indicator */
+#agent-indicator {
+  position: fixed;
+  top: 20px;
+  right: 20px;
+  background: var(--sidebar-bg, var(--panel));
+  color: var(--fg);
+  border: 1px solid var(--border);
+  padding: 6px 12px;
+  border-radius: 6px;
+  font-size: 12px;
+  display: none;
+  z-index: 100;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+#agent-indicator.active {
+  display: block;
+  border-color: var(--color-agent-active);
+  box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
+}
+#agent-indicator:hover {
+  border-color: var(--color-agent-active);
+  background: var(--panel);
+}
+
+/* Preset buttons */
+.preset-btn {
+  height: 27.2px;
+  padding: 0 8.5px;
+  margin-left: 4px;
+  border: 1px solid var(--red);
+  border-radius: 4px;
+  background: var(--sidebar-bg, var(--panel));
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 10.2px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.preset-btn:hover {
+  background: var(--panel);
+  border-color: var(--fg);
+}
+
+.preset-btn.active {
+  background: var(--panel);
+  border-color: var(--red);
+  box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent);
+  font-weight: 600;
+}
+
+/* Custom preset modal — inherits from .modal base class */
+
+/* Unified chat input area */
+.chat-input-area {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 12px;
+  margin-top: 12px;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.chat-controls-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 8px;
+}
+
+.chat-controls-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.chat-controls-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.control-group {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.control-label {
+  font-size: 11px;
+  color: var(--fg);
+  opacity: 0.8;
+}
+
+.toggle-switch {
+  position: relative;
+  display: inline-block;
+  width: 30px;
+  height: 16px;
+}
+
+.toggle-switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+}
+
+.toggle-slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: color-mix(in srgb, var(--fg) 15%, transparent);
+  border-radius: 8px;
+  transition: background 0.08s;
+}
+
+.toggle-slider:before {
+  position: absolute;
+  content: "";
+  height: 12px;
+  width: 12px;
+  left: 4px;
+  top: 2px;
+  background-color: var(--panel);
+  border-radius: 50%;
+  transition: transform 0.08s;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.25);
+}
+
+.toggle-switch input:checked + .toggle-slider {
+  background-color: var(--toggle-active, var(--red));
+}
+
+.toggle-switch input:checked + .toggle-slider:before {
+  transform: translateX(14px);
+}
+
+.preset-buttons-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.chat-input-form {
+  display: flex;
+  gap: 8px;
+  align-items: flex-end;
+}
+
+#message {
+  flex: 1;
+  min-height: 34px;
+  max-height: 120px;
+  resize: none;
+  font-size: 13px !important;
+  overflow-y: auto !important;
+  line-height: 1.4 !important;
+  font-family: inherit !important;
+}
+
+.action-button {
+  width: 34px;
+  height: 34px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  margin: 0;
+  background: none;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  cursor: pointer;
+  color: var(--fg);
+  transition: all 0.2s ease;
+}
+
+.action-button:hover {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  border-color: var(--fg);
+}
+
+.action-button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.action-button.recording {
+  background: var(--color-recording);
+  border-color: var(--color-recording);
+  animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+  0% { opacity: 1; }
+  50% { opacity: 0.7; }
+  100% { opacity: 1; }
+}
+
+#stop-icon {
+  display: none;
+  width: 14px;
+  height: 14px;
+  background: var(--color-recording);
+  border-radius: 2px;
+}
+
+/* Attachment strip — centered + max-width to match the chat-input-bar below,
+   otherwise the chip floats flush-left while the input is centered (visible on
+   desktop where the chat area is wider than 800px). */
+.attach-strip {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  padding: 2px 8px;
+  max-width: 800px;
+  width: 100%;
+  margin-left: auto;
+  margin-right: auto;
+  box-sizing: border-box;
+}
+.attach-strip:empty { display: none; }
+
+/* Upload-in-progress feedback: the message bubble shows immediately, so while
+   the files are still uploading we put a whirlpool ON each attachment chip and
+   dim the chip's content — making it obvious that file is being sent, not stuck. */
+.attach-strip.attach-uploading .thumb {
+  position: relative;
+  pointer-events: none;
+}
+.attach-strip.attach-uploading .thumb > :not(.thumb-upload-spinner) {
+  opacity: 0.4;
+}
+.thumb-upload-spinner {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+  z-index: 3;
+}
+
+.thumb {
+  border: 1px solid var(--border);
+  background: color-mix(in srgb, var(--fg) 11%, transparent);
+  padding: 3px 6px;
+  font-size: 12px;
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  border-radius: 4px;
+  transition: all 0.2s ease;
+  max-width: 180px;
+}
+.thumb-img {
+  max-width: 60px;
+  max-height: 40px;
+  border-radius: 3px;
+  object-fit: cover;
+}
+.attach-image-preview {
+  margin: 4px 0;
+}
+.attach-image-preview img {
+  box-shadow: 0 1px 4px rgba(0,0,0,0.2);
+  /* Same border as the chat bubbles. */
+  border: 1px solid var(--bubble-border, var(--border));
+}
+/* Image chips: image fills the chip, X overlays as a corner accent badge.
+   Same on desktop and mobile — doc/text chips keep the beside-X layout. */
+.thumb.thumb-image {
+  position: relative;
+  padding: 0;
+}
+.thumb.thumb-image .thumb-img {
+  max-height: 56px;
+  display: block;
+}
+@media (max-width: 768px) {
+  .thumb.thumb-image .thumb-img {
+    cursor: pointer;
+    touch-action: manipulation;
+  }
+}
+.thumb.thumb-image button {
+  position: absolute;
+  /* Sit on the top-right corner edge as an accent badge. */
+  top: -7px;
+  right: -7px;
+  width: 24px;
+  height: 24px;
+  min-width: 0;
+  padding: 0;
+  border: 2px solid var(--bg);
+  border-radius: 50%;
+  background: var(--accent-primary, var(--red));
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 15px;
+  line-height: 1;
+  z-index: 3;
+  transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
+}
+.thumb.thumb-image button:hover {
+  transform: scale(1.12);
+  filter: brightness(1.12);
+  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
+}
+.thumb.thumb-image button:active {
+  transform: scale(0.96);
+}
+
+.attach-crop-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: var(--attach-crop-z, 100001);
+  background: rgba(0, 0, 0, 0.72);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 14px;
+  box-sizing: border-box;
+}
+.attach-crop-panel {
+  width: min(94vw, 520px);
+  max-height: min(86vh, 720px);
+  background: var(--panel, #111);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  box-shadow: 0 18px 50px rgba(0,0,0,0.45);
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+.attach-crop-stage {
+  position: relative;
+  min-height: 280px;
+  height: min(66vh, 560px);
+  background: #050505;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  touch-action: none;
+}
+.attach-crop-img {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+  user-select: none;
+  pointer-events: none;
+}
+.attach-crop-box {
+  position: absolute;
+  border: 2px solid var(--accent, var(--red));
+  box-shadow: 0 0 0 9999px rgba(0,0,0,0.42);
+  cursor: move;
+  touch-action: none;
+  box-sizing: border-box;
+}
+.attach-crop-box::before,
+.attach-crop-box::after {
+  content: '';
+  position: absolute;
+  inset: 33.333% 0 auto;
+  border-top: 1px solid rgba(255,255,255,0.38);
+}
+.attach-crop-box::after {
+  inset: 66.666% 0 auto;
+}
+.attach-crop-handle {
+  position: absolute;
+  right: -9px;
+  bottom: -9px;
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  background: var(--accent, var(--red));
+  border: 2px solid var(--bg);
+  box-sizing: border-box;
+  cursor: nwse-resize;
+}
+.attach-crop-actions {
+  display: flex;
+  gap: 8px;
+  padding: 10px;
+  background: color-mix(in srgb, var(--bg) 78%, transparent);
+}
+.attach-crop-btn {
+  flex: 1;
+  height: 34px;
+  border: 1px solid var(--border);
+  border-radius: 5px;
+  background: var(--bg);
+  color: var(--fg);
+  font: inherit;
+  font-size: 12px;
+}
+.attach-crop-primary {
+  background: var(--accent, var(--red));
+  color: #fff;
+  border-color: var(--accent, var(--red));
+}
+@media (max-width: 768px) {
+  /* Collapsed "N files" badge: use the same corner-X accent badge as image thumbs. */
+  .thumb-collapsed { position: relative; }
+  .thumb-collapsed .thumb-collapsed-x {
+    position: absolute;
+    top: -7px;
+    right: -7px;
+    width: 24px;
+    height: 24px;
+    min-width: 0;
+    padding: 0;
+    border: 2px solid var(--bg);
+    border-radius: 50%;
+    background: var(--accent-primary, var(--red));
+    color: #fff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 15px;
+    line-height: 1;
+    z-index: 3;
+    opacity: 1;
+  }
+  /* Bigger remove-X tap target for non-image (doc/text) chips on mobile too. */
+  .thumb button {
+    height: 28px;
+    min-width: 28px;
+    font-size: 15px;
+  }
+}
+.thumb span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.thumb:hover {
+  background: color-mix(in srgb, var(--fg) 16%, transparent);
+  border-color: var(--fg);
+}
+
+.thumb button {
+  height: 24px;
+  padding: 0 5px;
+  font-size: 0.95em;
+  border-radius: 4px;
+  color: var(--accent-primary, var(--red));
+}
+
+.thumb-collapsed {
+  cursor: pointer;
+  color: var(--red);
+  border-color: var(--red);
+  background: color-mix(in srgb, var(--red) 10%, transparent);
+  font-weight: 600;
+  gap: 8px;
+  border-radius: 999px;   /* pill — rounder than the square file chips */
+  padding-left: 12px;
+}
+.thumb-collapsed:hover {
+  background: color-mix(in srgb, var(--red) 20%, transparent);
+}
+.thumb-collapsed-label { white-space: nowrap; }
+.thumb-collapsed-x {
+  height: 24px; padding: 0 7px; font-size: 13px; border-radius: 4px;
+  color: var(--accent-primary, var(--red));
+  background: none; border: none; cursor: pointer; opacity: 0.6;
+}
+.thumb-collapsed-x:hover { opacity: 1; }
+
+/* Recording indicator */
+#recording-indicator {
+  position: fixed;
+  top: 10px;
+  left: 0;
+  right: 0;
+  background: rgba(0, 0, 0, 0.8);
+  backdrop-filter: blur(10px);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 12px;
+  margin: 10px;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+#recording-indicator.hidden {
+  display: none !important;
+}
+
+.recording-content {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: white;
+}
+
+.recording-icon {
+  color: var(--color-recording);
+  font-size: 20px;
+  animation: pulse 1.5s infinite;
+}
+
+.recording-text {
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.stop-recording-btn {
+  background: var(--color-recording);
+  color: white;
+  border: none;
+  border-radius: 6px;
+  padding: 6px 12px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s ease;
+}
+
+.stop-recording-btn:hover {
+  background: var(--color-recording-hover);
+}
+
+#recording-indicator.error {
+  background: rgba(173, 26, 26, 0.9);
+}
+
+.recording-error {
+  color: var(--color-recording);
+  font-size: 14px;
+  margin-top: 4px;
+}
+
+/* Mermaid diagram containers */
+.mermaid-container {
+  margin: 12px 0;
+  padding: 16px;
+  background: color-mix(in srgb, var(--bg) 95%, var(--fg));
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  overflow-x: auto;
+  text-align: center;
+}
+.mermaid-container svg { max-width: 100%; height: auto; }
+
+/* KaTeX math overrides */
+.katex-display { margin: 0.8em 0; overflow-x: auto; overflow-y: hidden; }
+.katex { font-size: 1.1em; }
+
+/* Hide thinking sections globally via settings toggle */
+body.hide-thinking .thinking-section { display: none !important; }
+
+/* Widen chat area via settings toggle */
+body.fullwidth-chat .chat-history {
+  padding-left: 0 !important;
+  padding-right: 12px !important;
+}
+
+/* Thinking process styles — colors follow theme accent */
+.msg .body .stream-content {
+  width: 100%;
+}
+.thinking-section {
+  margin: 12px 0;
+  width: 100%;
+  max-width: 100%;
+  box-sizing: border-box;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+
+.thinking-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 6px 12px;
+  cursor: pointer;
+  user-select: none;
+  background: color-mix(in srgb, var(--red) 8%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  transition: background 0.2s ease;
+}
+
+.thinking-header > .token-new {
+  display: none;
+}
+
+.thinking-header > div:last-child {
+  flex-shrink: 0;
+}
+
+.thinking-header:hover {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+}
+
+.thinking-header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 0.9em;
+  color: var(--red);
+  font-weight: 500;
+  overflow: hidden;
+  min-width: 0;
+}
+.thinking-header-left span {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  transition: opacity 0.2s ease;
+}
+
+.thinking-icon {
+  font-size: 1.1em;
+}
+
+.thinking-toggle {
+  font-size: 0.9em;
+  color: var(--red);
+  transition: transform 0.3s ease;
+}
+.thinking-toggle::after {
+  content: '\25BC'; /* ▼ */
+}
+
+.thinking-toggle.expanded {
+  transform: rotate(180deg);
+}
+
+.thinking-content {
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height 0.3s ease, padding 0.3s ease;
+  padding: 0 12px;
+}
+
+.thinking-content.expanded {
+  max-height: 300px;
+  overflow-y: auto;
+  padding: 12px;
+}
+
+.thinking-content-inner {
+  font-size: 0.85em;
+  color: var(--fg);
+  opacity: 0.9;
+  line-height: 1.5;
+}
+.live-reply-content {
+  animation: fadeSlideIn 0.3s ease-out;
+}
+@keyframes fadeSlideIn {
+  from { opacity: 0; transform: translateY(4px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+/* Thinking indicator animation */
+.thinking-indicator {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  color: var(--red);
+  font-style: italic;
+  padding: 8px 0;
+}
+
+.thinking-dots::after {
+  content: '...';
+  animation: thinking-dots 1.5s infinite;
+  display: inline-block;
+  width: 20px;
+  text-align: left;
+}
+
+@keyframes thinking-dots {
+  0%, 20% { content: '.'; }
+  40% { content: '..'; }
+  60%, 100% { content: '...'; }
+}
+
+.thinking-complete {
+  color: var(--red);
+  font-size: 0.9em;
+  padding: 4px 0;
+  opacity: 0.8;
+}
+
+.research-context-continue-hint {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  margin: 5px 2px 0;
+  color: var(--fg-muted, var(--fg));
+  font-size: 10px;
+  opacity: 0.55;
+}
+.research-context-continue-hint svg {
+  flex: 0 0 auto;
+  opacity: 0.8;
+}
+
+/* ── Sources section — collapsible source citations ── */
+.sources-section {
+  margin: 8px 0 12px;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 8px;
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+.sources-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  cursor: pointer;
+  background: color-mix(in srgb, var(--red) 8%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  transition: background 0.2s ease;
+  user-select: none;
+}
+.sources-header:hover {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+}
+.sources-header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 0.85em;
+  font-weight: 500;
+  color: var(--red);
+}
+.sources-header-left svg {
+  width: 14px;
+  height: 14px;
+  flex-shrink: 0;
+  opacity: 0.7;
+}
+.sources-toggle {
+  font-size: 0.8em;
+  color: var(--red);
+  opacity: 0.7;
+  transition: none;
+}
+.sources-toggle::after {
+  content: '\25B6'; /* ▶ right arrow */
+}
+.sources-toggle[data-arrow="down"]::after {
+  content: '\25BC'; /* ▼ down arrow */
+}
+.sources-content {
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height 0.3s ease, padding 0.3s ease;
+  padding: 0 10px;
+}
+.sources-content.expanded {
+  max-height: 3000px;
+  padding: 8px 10px;
+}
+.sources-content-inner {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.source-link {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 5px 8px;
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--fg) 4%, transparent);
+  text-decoration: none;
+  color: var(--fg);
+  transition: background 0.15s ease;
+}
+.source-link:hover {
+  background: color-mix(in srgb, var(--fg) 10%, transparent);
+}
+.source-num {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  background: color-mix(in srgb, var(--fg) 15%, transparent);
+  color: var(--fg);
+  font-size: 0.7em;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+.source-title {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 0.82em;
+}
+.source-domain {
+  font-size: 0.72em;
+  opacity: 0.45;
+  flex-shrink: 0;
+}
+
+.ai-spinner {
+  color: var(--red);
+}
+/* Nudge the Tidy button 2px left. */
+#memory-tidy-btn { position: relative; left: -2px; }
+/* Tidy button's whirlpool nudge — sits 1px lower so it visually centers on
+   the Tidy label baseline. */
+#memory-tidy-btn .ai-spinner-whirlpool,
+#memory-tidy-btn .spinner-whirlpool {
+  position: relative;
+  top: 1px;
+}
+.list-item.stream-complete {
+  animation: none;
+}
+.session-run-state {
+  flex: 0 0 auto;
+  margin-left: 5px;
+  font-size: 10px;
+  line-height: 1;
+  color: var(--fg-dim, #888);
+  opacity: 0.9;
+}
+.session-run-state.is-working {
+  color: var(--accent, var(--red));
+}
+.session-run-state .session-run-whirlpool {
+  width: 12px !important;
+  height: 12px !important;
+  margin: 0 !important;
+  display: inline-flex;
+  align-items: center;
+}
+.session-run-state .session-run-whirlpool .ai-spinner-whirlpool {
+  display: inline-flex;
+  width: 12px;
+  height: 12px;
+}
+.session-run-state.is-done {
+  color: var(--color-success, #4caf50);
+  font-weight: 600;
+}
+.cookbook-notif-active svg { opacity: 1 !important; }
+
+/* Rail notification dot — pulsing indicator on icon-rail buttons */
+.icon-rail-btn.rail-notify {
+  opacity: 1 !important;
+  position: relative;
+}
+.icon-rail-btn.rail-notify::before {
+  content: '';
+  position: absolute;
+  top: 5px;
+  right: 4px;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--accent, var(--red));
+  animation: rail-notif-pulse 2s ease-in-out infinite;
+  z-index: 1;
+}
+.icon-rail-btn.rail-notify.rail-notify-success::before {
+  background: var(--color-success, #4caf50);
+}
+@keyframes rail-notif-pulse {
+  0%, 100% { opacity: 1; transform: scale(1); }
+  50% { opacity: 0.4; transform: scale(0.8); }
+}
+@keyframes stream-complete-pulse {
+  0%, 100% { box-shadow: none; }
+  50% { box-shadow: inset 0 0 0 1.5px var(--accent); }
+}
+
+/* ===== SLASH COMMAND RESPONSES ===== */
+.msg.msg-system {
+  padding: 6px 12px; margin: 4px auto 4px 8px;
+  max-width: 85%; background: none; border-left: 2px solid var(--border);
+}
+.msg.msg-system .body { padding: 0; }
+.msg.msg-system pre { margin: 4px 0; white-space: pre-wrap; font-size: 0.85em; }
+.dropdown-divider {
+  height: 1px;
+  margin: 3px 6px;
+  background: color-mix(in srgb, var(--border) 60%, transparent);
+}
+.msg.stream-done-toast {
+  cursor: pointer;
+  border-left-color: var(--accent, var(--border));
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--accent) 6%, transparent);
+  transition: background 0.15s;
+}
+.msg.stream-done-toast:hover {
+  background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
+.msg.stream-done-toast .body {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.stream-done-indicator {
+  font-family: monospace;
+  font-size: 1.1em;
+  line-height: 1;
+  color: var(--accent);
+  flex-shrink: 0;
+  animation: bar-pulse 1.2s ease-in-out infinite;
+}
+@keyframes bar-pulse {
+  0%, 100% { opacity: 0.4; }
+  50% { opacity: 1; }
+}
+
+/* ===== AGENT MULTI-BUBBLE ===== */
+.msg.msg-tool {
+  display: none; /* legacy — hidden, replaced by agent-thread */
+}
+.msg.msg-continuation {
+  margin-top: 2px;
+}
+
+/* ===== AGENT THREAD TIMELINE ===== */
+.agent-thread {
+  position: relative;
+  margin: 2px 0 2px 28px;
+  padding: 4px 0 4px 22px;
+  max-width: calc(85% - 20px);
+  box-sizing: border-box;
+}
+.agent-thread::before {
+  content: '';
+  position: absolute;
+  left: 5px;
+  top: 14px;
+  bottom: 14px;
+  width: 2px;
+  background: color-mix(in srgb, var(--red) 18%, transparent);
+  border-radius: 1px;
+}
+/* Extend line to connect to chat bubble above/below */
+.agent-thread.has-top::before {
+  top: -6.5px;
+}
+.agent-thread.has-bottom::before {
+  bottom: -5px;
+}
+.agent-thread.has-ask-user-bottom::before {
+  bottom: -12px;
+}
+/* Terminating dot at bottom when no bubble below and last node is expanded */
+.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
+  content: '';
+  position: absolute;
+  /* Centre the 6px dot on the rail: the rail sits at left:5px + 2px wide,
+     so its centre is 6px from the thread's left edge and the dot's left
+     edge belongs at 3px — i.e. 3px - padding-left. Keep this in step with
+     .agent-thread-dot and with the mobile padding override below. */
+  left: -19px;
+  bottom: 5px;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--red);
+  box-shadow: 0 0 4px 1px color-mix(in srgb, var(--red) 55%, transparent),
+              0 0 0 3px color-mix(in srgb, var(--red) 18%, transparent);
+}
+
+/* Synapse pulse — a bright dot traveling down the line */
+.agent-thread::after {
+  content: '';
+  position: absolute;
+  left: 4px;
+  width: 4px;
+  height: 4px;
+  border-radius: 50%;
+  background: var(--red);
+  box-shadow: 0 0 3px 1px color-mix(in srgb, var(--red) 50%, transparent);
+  pointer-events: none;
+  top: 0%;
+  opacity: 0;
+}
+.agent-thread.streaming::after {
+  animation: synapse-capped-short 0.8s ease-in-out infinite;
+}
+.agent-thread.streaming.has-top::after {
+  animation: synapse-capped 0.8s ease-in-out infinite;
+}
+.agent-thread.streaming.has-bottom::after {
+  animation: synapse-travel-short 0.8s ease-in-out infinite;
+}
+.agent-thread.streaming.has-top.has-bottom::after {
+  animation: synapse-travel 0.8s ease-in-out infinite;
+}
+@keyframes synapse-travel {
+  0% { top: 0%; opacity: 0; }
+  5% { opacity: 0.5; }
+  85% { opacity: 0.35; }
+  100% { top: 100%; opacity: 0; }
+}
+@keyframes synapse-capped {
+  0% { top: 0%; opacity: 0; }
+  5% { opacity: 0.5; }
+  70% { opacity: 0.35; top: calc(100% - 20px); }
+  100% { opacity: 0; top: calc(100% - 20px); }
+}
+@keyframes synapse-travel-short {
+  0% { top: 14px; opacity: 0; }
+  5% { opacity: 0.5; }
+  85% { opacity: 0.35; }
+  100% { top: 100%; opacity: 0; }
+}
+@keyframes synapse-capped-short {
+  0% { top: 14px; opacity: 0; }
+  5% { opacity: 0.5; }
+  70% { opacity: 0.35; top: calc(100% - 20px); }
+  100% { opacity: 0; top: calc(100% - 20px); }
+}
+.agent-thread-node {
+  position: relative;
+  padding: 5px 0;
+}
+.agent-thread-node + .agent-thread-node {
+  margin-top: 2px;
+}
+/* Supervisor ladder cards — same chrome as tool cards but tinted so the
+   user can tell at a glance "this is the agent recovering" vs "this is
+   the agent doing work". Stop rung gets the red accent. */
+.agent-thread-node.supervisor-step .agent-thread-tool {
+  color: color-mix(in srgb, var(--accent, #c08a3e) 80%, var(--fg));
+  font-style: italic;
+}
+.agent-thread-node.supervisor-step .agent-thread-dot {
+  background: color-mix(in srgb, var(--accent, #c08a3e) 60%, transparent);
+}
+.agent-thread-node.supervisor-step[data-rung="stop"] .agent-thread-tool {
+  color: var(--red, #d65a5a);
+}
+.agent-thread-node.supervisor-step[data-rung="stop"] .agent-thread-dot {
+  background: color-mix(in srgb, var(--red, #d65a5a) 60%, transparent);
+}
+.agent-thread-dot {
+  position: absolute;
+  left: -20px;
+  top: 10px;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--red);
+  border: 2px solid var(--bg);
+  z-index: 1;
+}
+.agent-thread-node.running .agent-thread-dot {
+  background: var(--red);
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 25%, transparent);
+  animation: thread-pulse 1.5s ease-in-out infinite;
+  top: 10px;
+}
+@keyframes thread-pulse {
+  0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 20%, transparent); }
+  50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--red) 10%, transparent); }
+}
+.agent-thread-node.error .agent-thread-dot {
+  background: var(--color-error);
+}
+.agent-thread-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-size: 0.85em;
+  color: color-mix(in srgb, var(--fg) 70%, transparent);
+  user-select: none;
+  padding: 2px 0;
+}
+.agent-thread-header:hover {
+  color: var(--fg);
+}
+.agent-thread-icon {
+  font-size: 0.9em;
+  color: var(--red);
+}
+.agent-thread-tool-icon {
+  width: 14px;
+  height: 14px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 14px;
+  color: color-mix(in srgb, var(--fg) 64%, transparent);
+  opacity: 0.85;
+}
+.agent-thread-tool-icon svg {
+  display: block;
+  width: 12px;
+  height: 12px;
+}
+.agent-thread-tool-icon-link {
+  color: inherit;
+  text-decoration: none;
+  cursor: pointer;
+}
+.agent-thread-tool-icon-link:hover {
+  color: var(--red);
+  opacity: 1;
+}
+.agent-thread-node.error .agent-thread-icon {
+  color: var(--color-error);
+}
+.agent-thread-tool {
+  font-weight: 600;
+  color: var(--red);
+  text-transform: uppercase;
+  letter-spacing: 0.3px;
+  font-size: 0.9em;
+}
+.agent-thread-header-link {
+  width: 17px;
+  height: 17px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 17px;
+  margin-left: -3px;
+  color: var(--red);
+  opacity: 0.72;
+  text-decoration: none;
+  border: 0;
+  background: transparent;
+}
+.agent-thread-header-link:hover {
+  opacity: 1;
+  color: var(--red);
+}
+.agent-thread-header-link svg {
+  display: block;
+}
+.agent-thread-header-link-calendar {
+  transform: translateY(-2px);
+}
+.msg-ai .body a[href^="#event-"],
+.msg-ai .body a[href^="#email-"],
+.msg-ai .body a[href^="#document-"],
+.msg-ai .body a[href^="#note-"],
+.msg-ai .body a[href^="#task-"],
+.msg-ai .body a[href^="#skill-"],
+.msg-ai .body a[href^="#memory-"],
+.msg-ai .body a[href^="#session-"] {
+  display: inline-flex;
+  align-items: baseline;
+  max-width: 100%;
+  padding: 1px 5px;
+  border-radius: 5px;
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  color: var(--red);
+  font-weight: 600;
+  text-decoration: none;
+  box-decoration-break: clone;
+  -webkit-box-decoration-break: clone;
+}
+.msg-ai .body a[href^="#event-"],
+.msg-ai .body a[href^="#note-"],
+.msg-ai .body a[href^="#skill-"],
+.msg-ai .body a[href^="#memory-"],
+.msg-ai .body a[href^="#session-"] {
+  font-size: 11px;
+  line-height: 1.35;
+}
+.msg-ai .body p:has(> .emoji:first-child):has(> a.chat-link[href^="#note-"]) {
+  margin-top: 0.15em;
+  margin-bottom: 0.15em;
+  line-height: 1.35;
+}
+.msg-ai .body a[href^="#event-"]:hover,
+.msg-ai .body a[href^="#email-"]:hover,
+.msg-ai .body a[href^="#document-"]:hover,
+.msg-ai .body a[href^="#note-"]:hover,
+.msg-ai .body a[href^="#task-"]:hover,
+.msg-ai .body a[href^="#skill-"]:hover,
+.msg-ai .body a[href^="#memory-"]:hover,
+.msg-ai .body a[href^="#session-"]:hover {
+  background: color-mix(in srgb, var(--red) 18%, transparent);
+  text-decoration: underline;
+}
+.agent-thread-status {
+  font-size: 0.85em;
+  opacity: 0.5;
+}
+.agent-thread-chevron {
+  width: 12px;
+  height: 12px;
+  flex: 0 0 12px;
+  display: inline-grid;
+  place-items: center;
+  opacity: 0.4;
+}
+.agent-thread-chevron::before {
+  content: "";
+  width: 0;
+  height: 0;
+  border-top: 4px solid transparent;
+  border-bottom: 4px solid transparent;
+  border-left: 6px solid currentColor;
+  transform-origin: 50% 50%;
+  transition: transform 0.2s ease;
+}
+.agent-thread-node.open .agent-thread-chevron::before {
+  transform: rotate(90deg);
+}
+.agent-thread-node.browser-preview-node .agent-thread-chevron {
+  display: none;
+}
+.agent-thread-wave {
+  font-family: monospace;
+  font-size: 0.85em;
+  color: var(--red);
+  letter-spacing: -1px;
+}
+/* Live "cooking" timer on a running tool — prominent (accent, tabular) so a
+   long-running command always reads as alive, not frozen. */
+.agent-thread-elapsed {
+  margin: 0 6px 0 4px;
+  font-size: 11px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  color: var(--accent, var(--red));
+}
+/* Stall watchdog banner — shown when the stream has been silent for ~1min. */
+.stall-banner {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 8px auto;
+  padding: 8px 12px;
+  max-width: 90%;
+  font-size: 12px;
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--color-warning, #f0ad4e) 12%, var(--bg));
+  border: 1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);
+}
+.stall-banner-txt { flex: 1; opacity: 0.85; }
+.stall-banner-btn {
+  font-size: 11px;
+  font-weight: 600;
+  padding: 4px 10px;
+  border-radius: 6px;
+  border: none;
+  background: var(--accent, var(--red));
+  color: #fff;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+.stall-banner-stop {
+  background: none;
+  color: var(--fg);
+  border: 1px solid var(--border);
+}
+.agent-thread-content {
+  display: none;
+  padding: 4px 0 2px 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+.agent-thread-node.open .agent-thread-content {
+  display: block;
+}
+.agent-thread-cmd {
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 8px 12px;
+  margin: 4px 0;
+  color: var(--fg);
+  font-size: 0.85em;
+  white-space: pre-wrap;
+  word-break: break-word;
+  line-height: 1.4;
+  overflow-x: auto;
+}
+.agent-thread-summary {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  max-width: 100%;
+  margin: 4px 0;
+  padding: 4px 8px;
+  border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 26%, var(--border));
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--accent, var(--red)) 8%, transparent);
+  color: color-mix(in srgb, var(--fg) 84%, var(--accent, var(--red)));
+  font-size: 12px;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.35;
+}
+.agent-thread-summary-link {
+  cursor: pointer;
+  text-decoration: none;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.agent-thread-summary-link:hover {
+  color: var(--accent, var(--red));
+  border-color: color-mix(in srgb, var(--accent, var(--red)) 42%, var(--border));
+  background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
+}
+
+.agent-image-progress {
+  margin: 6px 0 0;
+}
+.agent-image-progress-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  font-size: 11px;
+  line-height: 1.2;
+  color: color-mix(in srgb, var(--fg) 72%, transparent);
+}
+.agent-image-progress-label {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.agent-image-progress-value {
+  flex-shrink: 0;
+  font-variant-numeric: tabular-nums;
+  color: color-mix(in srgb, var(--accent, var(--red)) 75%, var(--fg));
+}
+.agent-image-progress-track {
+  display: none !important;
+}
+
+.private-browser-preview {
+  width: 100%;
+  max-height: min(300px, 36vh);
+  margin: 6px 0 2px;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  color: var(--fg);
+  box-shadow: none;
+}
+.private-browser-preview-header {
+  height: 32px;
+  min-height: 32px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 0 6px 0 10px;
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 15%, transparent);
+  background: color-mix(in srgb, var(--red) 10%, transparent);
+  font-size: 0.85em;
+  font-weight: 500;
+  cursor: pointer;
+}
+.private-browser-preview-title {
+  flex-shrink: 0;
+  color: var(--red);
+  font-size: inherit;
+  font-weight: inherit;
+  line-height: 1;
+  letter-spacing: 0;
+}
+.private-browser-preview-status {
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: color-mix(in srgb, var(--fg) 68%, transparent);
+  font-size: inherit;
+  line-height: 1;
+}
+.private-browser-preview-fold {
+  width: 18px;
+  height: 18px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+  left: 2px;
+  flex-shrink: 0;
+  padding: 0;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: var(--red);
+  cursor: pointer;
+  font-size: 0.95em;
+  font-family: inherit;
+  font-weight: 500;
+  line-height: 1;
+  text-shadow: none;
+  -webkit-text-stroke: 0;
+}
+.private-browser-preview-fold:hover {
+  background: transparent;
+  color: var(--red);
+}
+.private-browser-preview-frame {
+  position: relative;
+  min-height: 130px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  background: color-mix(in srgb, #000 72%, var(--bg));
+  box-shadow:
+    inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent),
+    inset 0 0 0 1px color-mix(in srgb, #000 35%, transparent),
+    0 8px 18px color-mix(in srgb, #000 18%, transparent);
+}
+.private-browser-preview[data-browser-url] .private-browser-preview-frame {
+  cursor: zoom-in;
+}
+.private-browser-preview[data-browser-url] .private-browser-preview-frame::after {
+  content: "";
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  opacity: 0.55;
+  transition: opacity 150ms ease;
+  background:
+    radial-gradient(circle at center, transparent 42%, color-mix(in srgb, #000 30%, transparent) 100%),
+    color-mix(in srgb, var(--bg) 10%, transparent);
+}
+.private-browser-preview[data-browser-url] .private-browser-preview-frame:hover::after {
+  opacity: 0.9;
+}
+.private-browser-preview.folded .private-browser-preview-frame {
+  display: none;
+}
+.private-browser-preview.folded .private-browser-preview-header {
+  border-bottom: 0;
+}
+.private-browser-preview-frame img {
+  width: 100%;
+  height: auto;
+  max-height: calc(min(300px, 36vh) - 32px);
+  display: block;
+  object-fit: contain;
+  transform: scale(1.1);
+  transform-origin: center center;
+  animation: private-browser-image-in 180ms ease-out both;
+}
+.private-browser-preview-frame img:not([src]),
+.private-browser-preview-frame img[src=""] {
+  display: none;
+}
+.private-browser-preview-empty {
+  padding: 22px 14px;
+  width: auto;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  color: color-mix(in srgb, var(--fg) 48%, transparent);
+  font-size: 11px;
+  line-height: 1.35;
+}
+.private-browser-preview-spinner {
+  width: 13px;
+  height: 13px;
+  flex: 0 0 13px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+.private-browser-preview-spinner .spinner-whirlpool,
+.private-browser-preview-spinner .ai-spinner-whirlpool {
+  margin: 0 !important;
+}
+@keyframes private-browser-image-in {
+  from { opacity: 0; transform: scale(1.08); }
+  to { opacity: 1; transform: scale(1.1); }
+}
+
+@media (max-width: 700px) {
+  .private-browser-preview-frame img {
+    max-height: calc(36vh - 32px);
+  }
+}
+
+/* Mobile: constrain thread content */
+@media (max-width: 768px) {
+  .agent-thread {
+    margin-left: 16px;
+    padding-left: 18px;
+    max-width: calc(90% - 16px);
+  }
+  .agent-thread-cmd {
+    font-size: 0.78em;
+    padding: 6px 8px;
+    max-width: 100%;
+    overflow-x: auto;
+  }
+  .agent-thread-content {
+    max-width: calc(100vw - 90px);
+  }
+  .agent-tool-output pre {
+    font-size: 0.8em;
+    max-width: 100%;
+    overflow-x: auto;
+  }
+  .agent-thread-header {
+    font-size: 0.8em;
+  }
+  .agent-thread-dot {
+    left: -16px;
+    top: 10px;
+  }
+  /* padding-left drops to 18px here, so the terminating dot needs its own
+     offset (3px - 18px) to stay centred on the rail. */
+  .agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
+    left: -15px;
+  }
+}
+
+/* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */
+.agent-tool-output {
+  margin-top: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  border: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  border-radius: 8px;
+  overflow: hidden;
+}
+.agent-tool-output summary {
+  color: var(--red);
+  background: color-mix(in srgb, var(--red) 10%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 15%, transparent);
+  cursor: pointer;
+  font-size: 0.85em;
+  user-select: none;
+  padding: 6px 10px;
+  font-weight: 500;
+  /* Chevron on the right (like the thinking section) instead of the default
+     left-side disclosure triangle. */
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  list-style: none;
+}
+.agent-tool-output summary::-webkit-details-marker { display: none; }
+/* File-write diff — neutral chrome (not the red error tint) + colored lines */
+.agent-tool-diff {
+  background: color-mix(in srgb, var(--fg) 4%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 18%, transparent);
+}
+.agent-tool-diff summary {
+  color: var(--fg);
+  background: color-mix(in srgb, var(--fg) 7%, transparent);
+  border-bottom-color: color-mix(in srgb, var(--fg) 12%, transparent);
+}
+.agent-tool-diff .diff-stat {
+  font-weight: 600;
+  opacity: 0.7;
+  font-family: var(--mono, monospace);
+}
+/* Collapsed diff summary: filename + +adds/−dels (theme green/red). */
+.agent-tool-diff summary {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.agent-tool-diff .diff-file {
+  font-family: var(--mono, monospace);
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.agent-tool-diff .diff-summary-stats {
+  margin-left: auto;
+  font-family: var(--mono, monospace);
+  font-weight: 600;
+  flex-shrink: 0;
+}
+.agent-tool-diff .diff-summary-stats .diff-stat-add { color: var(--green, #2ecc71); }
+.agent-tool-diff .diff-summary-stats .diff-stat-del { color: var(--red, #e74c3c); }
+.agent-tool-diff .diff-summary-stats .diff-stat-new { color: var(--accent, var(--red)); opacity: 0.85; }
+.diff-pre {
+  margin: 0;
+  padding: 8px 10px;
+  overflow-x: auto;
+  font-family: var(--mono, monospace);
+  font-size: 0.82em;
+  line-height: 1.45;
+}
+.diff-pre span { display: block; white-space: pre; }
+.diff-pre .diff-add { background: color-mix(in srgb, #2ecc71 22%, transparent); }
+.diff-pre .diff-del { background: color-mix(in srgb, #e74c3c 22%, transparent); }
+.diff-pre .diff-hunk { color: var(--accent); opacity: 0.85; }
+.diff-pre .diff-meta { opacity: 0.55; }
+.diff-pre .diff-ctx { opacity: 0.8; }
+/* Suppress the global `summary::before { content: '▶' }` left arrow — this
+   section uses a right-side chevron instead. */
+.agent-tool-output summary::before { content: none; }
+.agent-tool-output summary::after {
+  content: '\25BC'; /* ▼ */
+  color: var(--red);
+  font-size: 0.9em;
+  transition: transform 0.3s ease;
+}
+.agent-tool-output[open] > summary::after {
+  transform: rotate(180deg);
+}
+.agent-tool-output summary:hover {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+}
+.agent-thinking-dots .ai-spinner {
+  font-size: 12px;
+  letter-spacing: 0.5px;
+}
+
+.agent-tool-output[open] {
+  background: color-mix(in srgb, var(--red) 6%, transparent);
+}
+.agent-tool-output[open] > :not(summary) {
+  animation: detail-reveal 0.25s ease-out both;
+}
+@keyframes detail-reveal {
+  from { opacity: 0; transform: translateY(-4px); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+.agent-tool-output pre {
+  background: transparent;
+  border: none;
+  border-radius: 0;
+  padding: 10px 14px;
+  margin-top: 0;
+  max-height: 300px;
+  overflow-y: auto;
+  font-size: 0.95em;
+  color: var(--fg);
+  opacity: 0.85;
+  white-space: pre-wrap;
+  word-break: break-all;
+  line-height: 1.5;
+}
+
+/* Mobile responsive styles */
+@media (max-width: 768px) {
+  #recording-indicator {
+    margin: 8px;
+    padding: 10px;
+  }
+  
+  .recording-text {
+    font-size: 14px;
+  }
+  
+  .stop-recording-btn {
+    padding: 6px 10px;
+    font-size: 12px;
+  }
+}
+/* ===== PANE STYLES (shared by compare) ===== */
+
+.pane-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 6px 0 4px;
+  border-bottom: 1px solid var(--border);
+}
+.close-split-btn,
+.pane-close-btn {
+  background: none;
+  border: none;
+  color: var(--color-error);
+  font-size: 18px;
+  cursor: pointer;
+  padding: 0 4px;
+  line-height: 1;
+  opacity: 0;
+  transition: opacity 0.15s;
+}
+.pane-header:hover .close-split-btn,
+.pane-header:hover .pane-close-btn {
+  opacity: 1;
+}
+.close-split-btn:hover,
+.pane-close-btn:hover {
+  color: var(--color-error-light);
+}
+
+/* Compare pane close controls use the same shared circular close treatment
+   as modal/title close buttons. */
+.compare-pane .pane-close-btn.close-btn {
+  width: 24px;
+  height: 24px;
+  min-width: 24px;
+  padding: 0;
+  border: 1px solid transparent;
+  border-radius: 999px;
+  background: transparent;
+  color: color-mix(in srgb, var(--fg) 58%, transparent);
+  font-size: 0;
+  opacity: 0.5;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.15s, color 0.15s, opacity 0.15s;
+}
+.compare-pane .pane-close-btn.close-btn:hover,
+.compare-pane .pane-close-btn.close-btn:focus-visible {
+  background: color-mix(in srgb, var(--fg) 10%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 12%, transparent);
+  color: var(--fg);
+  opacity: 1;
+  outline: none;
+}
+.compare-pane .pane-action-btn[data-action="expand"] {
+  width: 24px;
+  height: 24px;
+  min-width: 24px;
+  padding: 0;
+  border: 1px solid transparent;
+  border-radius: 999px;
+  justify-content: center;
+  transition: background 0.15s, color 0.15s, opacity 0.15s;
+}
+.compare-pane .pane-action-btn[data-action="expand"]:hover,
+.compare-pane .pane-action-btn[data-action="expand"]:focus-visible {
+  background: color-mix(in srgb, var(--fg) 10%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 12%, transparent);
+  color: var(--fg);
+  opacity: 1;
+  outline: none;
+}
+
+
+/* ============================================ */
+/* RESEARCH DETAILS EXPANDABLE SECTION - UPDATED */
+/* ============================================ */
+
+/* Style the details element */
+details {
+  background: color-mix(in srgb, var(--hl-string) 5%, transparent);
+  border: 1px solid color-mix(in srgb, var(--hl-string) 30%, transparent);
+  border-radius: 8px;
+  margin: 12px 0;
+  padding: 0;
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+
+details[open] {
+  background: color-mix(in srgb, var(--hl-string) 8%, transparent);
+}
+details[open] > :not(summary) {
+  animation: detail-reveal 0.25s ease-out both;
+}
+
+/* Style the summary (clickable header) - NO CURSIVE, NORMAL SIZE */
+summary {
+  cursor: pointer;
+  padding: 10px 14px;
+  background: color-mix(in srgb, var(--hl-string) 10%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--hl-string) 20%, transparent);
+  font-weight: 500;
+  font-size: 0.95em;
+  font-style: normal;
+  font-family: inherit;
+  color: var(--hl-string);
+  user-select: none;
+  list-style: none;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  transition: background 0.2s ease;
+}
+
+summary:hover {
+  background: color-mix(in srgb, var(--hl-string) 15%, transparent);
+}
+
+/* Add custom arrow */
+summary::before {
+  content: '▶';
+  display: inline-block;
+  transition: transform 0.3s ease;
+  font-size: 0.75em;  /* Smaller arrow */
+}
+
+details[open] summary::before {
+  transform: rotate(90deg);
+}
+
+/* Hide default marker in webkit browsers */
+summary::-webkit-details-marker {
+  display: none;
+}
+
+/* Style the content inside details - SMALLER, MORE COMPACT */
+details > div,
+details > p,
+details > ul,
+details > ol {
+  padding: 12px 14px;  /* Less padding */
+  animation: fadeIn 0.3s ease;
+  font-size: 0.9em;  /* Smaller text */
+  line-height: 1.5;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* Style research findings inside details - SMALLER HEADINGS */
+details h3 {
+  margin-top: 12px;
+  margin-bottom: 6px;
+  color: var(--hl-string);
+  font-size: 0.95em;
+  font-weight: 500;
+}
+
+details h4 {
+  margin-top: 10px;
+  margin-bottom: 5px;
+  color: var(--hl-string);
+  font-size: 0.9em;
+  font-weight: 500;
+}
+
+details ul {
+  margin-left: 18px;
+  margin-bottom: 10px;
+}
+
+details li {
+  margin-bottom: 5px;
+  line-height: 1.4;
+  font-size: 0.85em;  /* Smaller list items */
+}
+
+details strong {
+  color: var(--hl-string);
+  font-weight: 500;
+}
+
+details a {
+  color: var(--accent);
+  text-decoration: none;
+  transition: color 0.2s ease;
+}
+
+details a:hover {
+  color: var(--color-link-hover);
+  text-decoration: underline;
+}
+
+/* Research metadata - SMALLER */
+details .research-meta {
+  font-size: 0.8em;
+  color: var(--fg);
+  opacity: 0.8;
+  margin-top: 4px;
+}
+
+/* Source links - SMALLER */
+details .source-link {
+  display: inline-block;
+  margin-top: 3px;
+  padding: 2px 5px;
+  background: color-mix(in srgb, var(--accent) 10%, transparent);
+  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
+  border-radius: 4px;
+  font-size: 0.68em;
+  color: var(--accent);
+}
+
+details .source-link:hover {
+  background: color-mix(in srgb, var(--accent) 20%, transparent);
+  border-color: var(--accent);
+}
+
+/* Research report links - clean and subtle */
+details a {
+  color: var(--accent);
+  text-decoration: none;
+  font-weight: normal;  /* Remove bold */
+  font-size: 0.85em;
+  opacity: 0.9;
+  transition: all 0.2s ease;
+  word-break: break-all;  /* Break long URLs nicely */
+}
+
+details a:hover {
+  color: var(--color-link-hover);
+  text-decoration: underline;
+  opacity: 1;
+}
+
+/* ============================================ */
+/* CUSTOM SYNTAX HIGHLIGHTING - Theme-Reactive  */
+/* ============================================ */
+.hljs {
+  background: var(--code-bg, var(--hl-bg, var(--panel)));
+  color: var(--code-fg, var(--hl-fg, var(--fg)));
+  padding: 8px;
+  border-radius: 4px;
+}
+
+/* Keywords & control flow — purple/magenta */
+.hljs-keyword,
+.hljs-selector-tag { color: var(--hl-keyword); }
+
+/* Strings & regex — warm yellow/orange */
+.hljs-string,
+.hljs-regexp,
+.hljs-addition { color: var(--hl-string); }
+
+/* Comments & docs — muted. font-style intentionally omitted: italics shift
+   glyph widths in the highlight overlay relative to the transparent textarea
+   above it, which makes the caret drift away from the visible character. */
+.hljs-comment,
+.hljs-quote,
+.hljs-meta { color: var(--hl-comment); }
+
+/* Functions & method names — blue */
+.hljs-function,
+.hljs-title,
+.hljs-title.function_,
+.hljs-section { color: var(--hl-function); }
+
+/* Numbers & constants — distinct from strings */
+.hljs-number,
+.hljs-literal { color: var(--hl-number, var(--hl-string)); }
+
+/* Built-ins & types — teal/cyan tint */
+.hljs-built_in,
+.hljs-type,
+.hljs-class,
+.hljs-title.class_ { color: var(--hl-builtin, var(--hl-function)); }
+
+/* Variables & identifiers — fg with slight distinction */
+.hljs-variable,
+.hljs-template-variable,
+.hljs-attr { color: var(--hl-variable, var(--hl-fg, var(--fg))); }
+
+/* Operators & punctuation — slightly dimmed fg */
+.hljs-operator,
+.hljs-punctuation { color: var(--hl-fg, var(--fg)); opacity: 0.8; }
+
+/* Parameters */
+.hljs-params { color: var(--hl-params, var(--hl-fg, var(--fg))); }
+
+/* Property access, attributes in HTML/XML */
+.hljs-property,
+.hljs-selector-class,
+.hljs-selector-id { color: var(--hl-variable, var(--hl-function)); }
+
+/* Tags (HTML) */
+.hljs-tag { color: var(--hl-keyword); }
+.hljs-name { color: var(--hl-keyword); }
+
+/* Deletion/diff */
+.hljs-deletion { color: var(--red); }
+
+/* Symbol, special */
+.hljs-symbol { color: var(--hl-string); }
+.hljs-link { color: var(--hl-function); text-decoration: underline; }
+
+/* Emphasis — applied globally (chat messages, rendered markdown in docs
+   preview, etc.) but explicitly NEUTRALIZED in the doc editor's overlay so
+   the textarea-and-overlay caret alignment stays bit-perfect. Bold / italic
+   shift glyph widths and that drifts the click-to-row mapping. */
+.hljs-emphasis { font-style: italic; }
+.hljs-strong { font-weight: bold; }
+.doc-editor-highlight .hljs-emphasis,
+.doc-editor-highlight .hljs-strong {
+  font-style: normal !important;
+  font-weight: inherit !important;
+}
diff --git a/static/css/style-part-03.css b/static/css/style-part-03.css
new file mode 100644
index 000000000..45ca958cd
--- /dev/null
+++ b/static/css/style-part-03.css
@@ -0,0 +1,6010 @@
+/* Fragment 4/9 of the former static/style.css.
+ *
+ * This is a mechanical, order-preserving slice: rules were moved verbatim
+ * and this file's position in the  order of static/index.html is what
+ * keeps the cascade identical. Reordering the links, or moving a rule between
+ * fragments, can change which of two competing declarations wins.
+ *
+ * static/index.html and the PRECACHE list in static/sw.js must list every
+ * fragment in the same order. tests/test_static_stylesheet_manifest.py and
+ * tests/test_css_computed_style_snapshot.py both fail if that drifts.
+ */
+/* ===== Markdown highlighting — document editor =====
+   IMPORTANT: this is an overlay layered behind a transparent textarea, so
+   every styled token must occupy the EXACT same width as the corresponding
+   characters in the textarea. Anything that changes glyph metrics
+   (font-weight/style, padding, border, letter-spacing) makes the caret drift
+   away from the rendered glyph underneath. Color / background / text-
+   decoration are safe — they don't change layout width. */
+.doc-editor-highlight .language-markdown .hljs-section {
+  color: var(--hl-keyword, #c678dd);
+}
+.doc-editor-highlight .language-markdown .hljs-strong {
+  color: var(--hl-number, #d19a66);
+}
+.doc-editor-highlight .language-markdown .hljs-emphasis {
+  color: var(--hl-string, #e5c07b);
+}
+.doc-editor-highlight .language-markdown .hljs-bullet {
+  color: var(--hl-builtin, #56b6c2);
+}
+.doc-editor-highlight .language-markdown .hljs-code {
+  color: var(--hl-builtin, #56b6c2);
+  background: color-mix(in srgb, var(--hl-builtin, #56b6c2) 10%, transparent);
+  border-radius: 2px;
+}
+.doc-editor-highlight .language-markdown .hljs-link {
+  color: var(--hl-function, #61afef);
+  text-decoration: underline;
+}
+.doc-editor-highlight .language-markdown .hljs-quote {
+  color: var(--hl-comment, #828997);
+}
+.doc-editor-highlight .language-markdown .hljs-symbol {
+  color: var(--red);
+}
+/* Standalone [bracketed text] — scene directions, annotations */
+.doc-editor-highlight .language-markdown .md-bracket {
+  color: var(--hl-builtin, #56b6c2);
+  opacity: 0.85;
+}
+/* Heading # markers — dimmer than the heading text */
+.doc-editor-highlight .language-markdown .md-heading-marker {
+  color: var(--hl-comment, #828997);
+  font-weight: 400;
+}
+
+/* ===== CUSTOM PRESET MODAL ===== */
+
+.preset-modal-content {
+  width: min(460px, 90vw);
+  border-radius: 12px;
+  overflow: hidden;
+}
+.preset-modal-body {
+  display: flex;
+  flex-direction: column;
+  overflow-x: hidden;
+}
+
+/* Footer Start/Cancel buttons get a leading icon. Done via ::before + a masked
+   SVG (not an inline  child) because the labels are set with .textContent,
+   which would wipe a child element on every tab switch. background:currentColor
+   makes the icon follow the button's text color. */
+#save-custom-preset,
+#cancel-custom-preset {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+#save-custom-preset::before,
+#cancel-custom-preset::before {
+  content: "";
+  width: 13px; height: 13px;
+  flex-shrink: 0;
+  background-color: currentColor;
+  -webkit-mask: var(--_btn-ic) center / contain no-repeat;
+  mask: var(--_btn-ic) center / contain no-repeat;
+}
+#save-custom-preset::before {
+  --_btn-ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpolygon points='5 3 19 12 5 21 5 3'/%3E%3C/svg%3E");
+}
+#cancel-custom-preset::before {
+  --_btn-ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
+}
+
+.preset-tabs {
+  display: flex;
+  gap: 0;
+  border-bottom: 1px solid var(--border);
+  margin: 0 -10px 12px;
+  padding: 0 10px;
+  margin: 0 -16px;
+  padding: 0 16px;
+  margin-bottom: 12px;
+}
+
+.preset-tab {
+  flex: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 8px 10px;
+  background: none;
+  border: none;
+  border-bottom: 2px solid transparent;
+  color: var(--color-muted);
+  font-family: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.preset-tab-icon { flex-shrink: 0; }
+/* On narrow widths the icon + label can crowd 4 tabs — drop the labels to
+   icon-only so the row stays clean. */
+@media (max-width: 460px) {
+  .preset-tab span { display: none; }
+}
+
+.preset-tab:hover {
+  color: var(--fg);
+  background: color-mix(in srgb, var(--fg) 4%, transparent);
+}
+
+.preset-tab.active {
+  color: var(--red);
+  border-bottom-color: var(--red);
+}
+
+.preset-tab-content {
+  overflow: hidden;
+}
+.preset-tab-content.hidden {
+  display: none;
+}
+
+.preset-templates-hint {
+  font-size: 11px;
+  color: var(--color-muted);
+  margin: 0 0 8px;
+}
+
+.prompt-templates-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.prompt-template-card {
+  padding: 10px 12px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+  cursor: pointer;
+  transition: all 0.15s;
+}
+
+.prompt-template-card:hover {
+  background: color-mix(in srgb, var(--accent) 8%, transparent);
+  border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+}
+
+.prompt-template-card.selected {
+  border-color: var(--accent);
+  background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.prompt-template-name {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--fg);
+  margin-bottom: 4px;
+}
+
+.prompt-template-preview {
+  font-size: 11px;
+  color: var(--color-muted);
+  line-height: 1.4;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.preset-slider-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 12px;
+  margin-bottom: 6px;
+}
+
+.preset-slider-row label {
+  font-size: 13px;
+  color: var(--fg);
+  font-weight: 500;
+  margin: 0;
+}
+
+.preset-slider-value {
+  font-size: 12px;
+  color: var(--fg);
+  font-weight: 600;
+  min-width: 40px;
+  text-align: right;
+}
+
+.preset-range {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 100%;
+  height: 6px;
+  background: var(--border);
+  border-radius: 4px;
+  outline: none;
+  margin-bottom: 4px;
+  box-sizing: border-box;
+  display: block;
+  padding: 0;
+  margin-left: 0;
+  margin-right: 0;
+}
+
+.preset-range::-webkit-slider-thumb {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: var(--red, var(--fg));
+  cursor: pointer;
+  border: 2px solid var(--panel);
+  box-shadow: 0 1px 4px rgba(0,0,0,0.2);
+}
+
+.preset-range::-moz-range-thumb {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: var(--red, var(--fg));
+  cursor: pointer;
+  border: 2px solid var(--panel);
+  box-shadow: 0 1px 4px rgba(0,0,0,0.2);
+}
+
+.preset-range::-webkit-slider-runnable-track {
+  height: 6px;
+  border-radius: 4px;
+}
+
+.preset-range::-moz-range-track {
+  height: 6px;
+  background: var(--border);
+  border-radius: 4px;
+}
+
+.preset-temp-hints {
+  display: flex;
+  font-size: 10px;
+  color: var(--color-muted);
+  margin-top: -4px;
+  margin-bottom: 10px;
+  padding: 0 2px;
+  opacity: 0.7;
+}
+.preset-temp-hints span {
+  flex: 1;
+}
+.preset-temp-hints span:nth-child(2) {
+  text-align: center;
+}
+.preset-temp-hints span:last-child {
+  text-align: right;
+}
+
+.preset-clear-btn {
+  padding: 7px 14px;
+  background: none;
+  border: 1px solid var(--border);
+  color: var(--color-muted);
+  border-radius: 6px;
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 500;
+  transition: all 0.15s;
+}
+
+.preset-clear-btn:hover {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+.preset-hint-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 15px;
+  height: 15px;
+  border-radius: 50%;
+  border: 1px solid var(--border);
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--color-muted);
+  cursor: help;
+  vertical-align: middle;
+  margin-left: 4px;
+  transition: all 0.15s;
+}
+.preset-hint-icon:hover {
+  color: var(--fg);
+  border-color: var(--fg);
+}
+
+.preset-section-header {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--color-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin-bottom: 6px;
+  padding: 0 2px;
+}
+
+.user-template-card {
+  position: relative;
+}
+.user-template-delete {
+  background: none;
+  border: none;
+  color: var(--color-muted);
+  font-size: 13px;
+  cursor: pointer;
+  padding: 0 2px;
+  line-height: 1;
+  opacity: 0;
+  transition: opacity 0.15s, color 0.15s;
+}
+.user-template-card:hover .user-template-delete {
+  opacity: 1;
+}
+.user-template-delete:hover {
+  color: var(--color-error);
+}
+
+.preset-save-template-btn {
+  padding: 7px 14px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  border: 1px solid var(--border);
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.preset-save-template-btn:hover {
+  border-color: var(--accent, var(--red));
+  color: var(--accent, var(--red));
+}
+
+.char-prompt-wrap {
+  position: relative;
+}
+.char-prompt-wrap textarea {
+  padding-bottom: 28px;
+}
+.char-expand-btn {
+  position: absolute;
+  bottom: 14px;
+  right: 6px;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  color: var(--color-muted);
+  font-size: 11px;
+  padding: 2px 8px;
+  cursor: pointer;
+  transition: all 0.15s;
+  margin: 0;
+  z-index: 1;
+}
+.char-expand-btn:hover {
+  color: var(--red);
+  border-color: var(--red);
+}
+.char-expand-btn.expanding {
+  opacity: 0.5;
+  pointer-events: none;
+}
+
+/* Memory scope bar (My Memories / Characters) */
+.memory-scope-bar {
+  display: flex;
+  gap: 0;
+  margin: 0 0 8px 0 !important;
+  padding: 0 !important;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  overflow: hidden;
+}
+.memory-scope-btn {
+  flex: 1;
+  padding: 8px 12px !important;
+  margin: 0 !important;
+  font-size: 11px !important;
+  font-weight: 600;
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.5;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.memory-scope-btn + .memory-scope-btn {
+  border-left: 1px solid var(--border);
+}
+.memory-scope-btn.active {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  color: var(--red);
+  opacity: 1;
+}
+.memory-scope-btn:hover:not(.active) {
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+}
+.memory-char-list {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
+}
+.memory-char-chip {
+  padding: 4px 10px;
+  font-size: 11px;
+  font-weight: 500;
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: all 0.15s;
+  margin: 0;
+}
+.memory-char-chip.active {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  border-color: var(--red);
+  color: var(--red);
+}
+.memory-char-chip:hover:not(.active) {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+/* Disabled state dims the form */
+#char-fields-wrap.disabled {
+  opacity: 0.35;
+  pointer-events: none;
+  filter: grayscale(0.5);
+  transition: opacity 0.2s, filter 0.2s;
+}
+#char-fields-wrap {
+  transition: opacity 0.2s, filter 0.2s;
+}
+
+/* Name combo: input + delete btn */
+.char-name-combo {
+  display: flex;
+  gap: 4px;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.char-name-combo input,
+.char-name-combo select {
+  flex: 1;
+}
+.char-template-select {
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--fg);
+  padding: 6px 8px;
+  font-size: 13px;
+  font-family: inherit;
+  cursor: pointer;
+}
+.char-template-select:focus {
+  outline: none;
+  border-color: var(--red);
+}
+.char-action-btn {
+  background: none;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--color-muted);
+  font-size: 11px;
+  padding: 4px 8px;
+  cursor: pointer;
+  transition: all 0.15s;
+  flex-shrink: 0;
+  margin: 0 !important;
+  white-space: nowrap;
+  /* Uniform width so the trailing button column matches between the select
+     row (+ New) and the name row (Reset) — that keeps the select and the
+     name input the same width, since both fields flex:1 into the leftover. */
+  min-width: 64px;
+  text-align: center;
+}
+.char-action-btn:hover {
+  color: var(--fg);
+  border-color: var(--fg);
+}
+#char-delete-template-btn:hover {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+/* Character toggle row in preset modal */
+.preset-toggle-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 12px;
+  font-size: 13px;
+  color: var(--fg);
+}
+.preset-sub-option {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 6px;
+  padding: 8px 10px;
+  font-size: 12px;
+  color: var(--color-muted);
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+}
+.preset-mem-choice {
+  flex: 1;
+  padding: 6px 10px;
+  font-size: 12px;
+  font-weight: 500;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: all 0.15s;
+  margin: 0;
+}
+.preset-mem-choice.active {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  border-color: var(--red);
+  color: var(--red);
+}
+.preset-mem-choice:hover:not(.active) {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+
+/* ===== MEMORY MODAL ===== */
+
+.memory-modal-content {
+  width: min(560px, 90vw);
+  max-height: 78vh;
+  font-size: 12px;
+  overflow: hidden;   /* clip both axes; the inner .memory-modal-body owns scrolling.
+                         (overflow-x alone promotes overflow-y to `auto` → a stray vertical scrollbar) */
+  /* Subtle synapse-pulse — a soft radial glow that breathes in/out.
+     Layered over the existing modal bg so it shows through without
+     overpowering content. */
+  position: relative;
+  isolation: isolate;
+}
+.memory-modal-content::before { content: none; }
+@media (prefers-reduced-motion: reduce) {
+  .memory-modal-content::before { animation: none; opacity: 0.4; }
+}
+.memory-modal-content .modal-header h4 {
+  font-size: 1rem;
+}
+
+.memory-modal-body {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  overflow-y: auto;
+  overflow-x: hidden;        /* Stop synapse-pulse pseudo-elements from triggering a sideways scrollbar */
+  overscroll-behavior: contain;
+  min-height: 0;
+  /* Fill the modal-content's height so the flex chain (tab-panel → admin-card
+     → list → expanded card) is bounded. Without this the chain grows to
+     content, so an expanded skill card pushed its footer off-screen; capping
+     the preview then left it floating too high. Bounding here lets the
+     preview flex to fill and the footer pin to the bottom naturally. */
+  flex: 1 1 auto;
+}
+.memory-tabs {
+  display: flex;
+  gap: 0;
+  border-bottom: 1px solid var(--border);
+  margin: -4px -4px 0;
+  padding: 0 4px;
+  flex-shrink: 0;
+}
+#memory-modal [data-memory-scope].hidden {
+  display: none !important;
+}
+.memory-tab {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.5;
+  font-size: 12px;
+  font-family: inherit;
+  padding: 8px 14px;
+  cursor: pointer;
+  border-bottom: 2px solid transparent;
+  transition: opacity 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
+}
+.memory-tab:hover {
+  opacity: 0.8;
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+}
+.memory-tab.active {
+  opacity: 1;
+  color: var(--red);
+  border-bottom-color: var(--red);
+}
+.memory-tab-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  overflow-y: auto;
+  /* overflow-y:auto makes the browser compute overflow-x to `auto` too, which
+     produced a stray horizontal scrollbar whenever a child was slightly too
+     wide (long unbroken memory text, or the skills two-column row). Clip X
+     explicitly — inner code blocks keep their own overflow-x:auto. */
+  overflow-x: hidden;
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+}
+.memory-tab-panel.hidden { display: none; }
+/* Browse: bounded flex column so #memory-list gets remaining height (not 0px).
+   height:min(78vh,max-content) gives a definite cap when long, natural height
+   when short. flex-basis:auto (not 0) on the list avoids collapse in auto-sized
+   parents. Toolbar siblings are flex-shrink:0; only #memory-list grows. */
+#memory-modal .memory-modal-content:has(
+  .memory-tab-panel[data-memory-panel="browse"]:not(.hidden)
+) {
+  display: flex;
+  flex-direction: column;
+  max-height: 78vh;
+  height: 78vh;
+  overflow: hidden;
+}
+#memory-modal .memory-modal-content:has(
+  .memory-tab-panel[data-memory-panel="browse"]:not(.hidden)
+) .modal-header,
+#memory-modal .memory-modal-content:has(
+  .memory-tab-panel[data-memory-panel="browse"]:not(.hidden)
+) .memory-tabs {
+  flex: 0 0 auto;
+}
+#memory-modal .memory-modal-content:has(
+  .memory-tab-panel[data-memory-panel="browse"]:not(.hidden)
+) .memory-modal-body {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: hidden;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="browse"] {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: hidden;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="browse"] > .admin-card {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: hidden;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="browse"] > .admin-card > *:not(#memory-list):not(#memory-suggestions-body) {
+  flex: 0 0 auto;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="browse"] #memory-list:not(.hidden),
+#memory-modal .memory-tab-panel[data-memory-panel="browse"] #memory-suggestions-body:not(.hidden) {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow-y: auto;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="browse"] #memory-suggestions-body:not(.hidden) .memory-suggestions-header {
+  flex-shrink: 0;
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  background: var(--bg);
+}
+#memory-modal .memory-tab-panel[data-memory-panel].hidden {
+  display: none;
+}
+/* Settings cards dim + mute when their toggle is OFF (matches the
+   .memory-toolbar-toggle "off" treatment elsewhere). */
+#memory-modal .memory-tab-panel[data-memory-panel="settings"] .admin-card {
+  transition: opacity 0.15s, border-color 0.15s, background 0.15s;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="settings"] .admin-card:has(.admin-switch input:not(:checked)) {
+  opacity: 0.55;
+  border-color: color-mix(in srgb, var(--fg) 8%, transparent);
+  background: color-mix(in srgb, var(--fg) 2%, transparent);
+}
+/* Skills tab — two-column layout: skills list (left, wider) + Add Skill
+   form (right, narrower). Collapses to a single column on narrow screens. */
+.memory-tab-panel[data-memory-panel="skills"] {
+  flex-direction: row;
+  align-items: stretch;
+  gap: 10px;
+}
+.memory-tab-panel[data-memory-panel="skills"] > .admin-card:first-of-type {
+  flex: 2 1 0;
+  min-width: 0;
+}
+/* The shared chip-scroll frame is a flex-growing row by default. In the
+   vertical Skills card it must stay content-sized so #skills-list keeps the
+   remaining height for the skill cards. */
+.memory-tab-panel[data-memory-panel="skills"] > .admin-card > .doclib-chip-scroll-frame {
+  flex: 0 0 auto;
+  min-height: 25px;
+}
+.memory-tab-panel[data-memory-panel="skills"] > .admin-card:nth-of-type(2) {
+  flex: 1 1 0;
+  margin-top: 0 !important;
+  min-width: 220px;
+}
+@media (max-width: 640px) {
+  .memory-tab-panel[data-memory-panel="skills"] { flex-direction: column; }
+  .memory-tab-panel[data-memory-panel="skills"] > .admin-card:nth-of-type(2) {
+    margin-top: 12px !important;
+  }
+}
+.memory-desc {
+  margin: 0;
+  font-size: 11px;
+  line-height: 1.5;
+  /* 65% keeps this description text above WCAG AA 4.5:1 (50% was ~3.9:1). */
+  color: color-mix(in srgb, var(--fg) 65%, transparent);
+}
+
+.memory-add-row {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  height: 32px;
+}
+
+/* Skill Import beside URL field — match input height; cancel modal-body button margin. */
+.memory-add-row .theme-io-btn {
+  flex: none;
+  height: 28px;
+  box-sizing: border-box;
+  margin-top: 0;
+  padding: 5px 10px;
+}
+
+.memory-add-input {
+  flex: 1;
+  height: 28px;
+  padding: 0 10px;
+  border-radius: 6px;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 12px;
+  box-sizing: border-box;
+}
+/* Textareas need explicit vertical padding — inputs vertically center text
+   via line-height/height; textareas would otherwise pin text to the top. */
+textarea.memory-add-input {
+  height: auto;
+  padding: 6px 10px;
+  line-height: 1.4;
+}
+
+.memory-add-input:focus {
+  outline: none;
+  border-color: var(--red);
+}
+
+.memory-add-btn {
+  width: 28px;
+  height: 28px;
+  border-radius: 6px;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  color: var(--fg);
+  font-size: 16px;
+  box-sizing: border-box;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.15s;
+  flex-shrink: 0;
+}
+
+.memory-add-btn:hover {
+  background: var(--panel);
+  border-color: var(--red);
+  color: var(--red);
+}
+
+.memory-toolbar {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  padding: 4px 0 8px;
+}
+
+.memory-toolbar-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+
+
+.memory-toolbar-btn {
+  background: none;
+  border: 1px solid var(--border);
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  font-size: 11px;
+  height: 24px;
+  padding: 0 8px;
+  border-radius: 6px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: all 0.15s;
+  white-space: nowrap;
+}
+
+
+
+.memory-toolbar-btn:hover {
+  border-color: var(--fg);
+  color: var(--fg);
+}
+
+.memory-toolbar-btn.active {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  border-color: color-mix(in srgb, var(--red) 40%, transparent);
+  color: var(--red);
+}
+.email-note-actions .memory-toolbar-btn.active:hover {
+  color: var(--fg);
+}
+
+.memory-toolbar-btn.danger {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+.memory-toolbar-btn.danger:hover {
+  background: color-mix(in srgb, var(--color-error) 10%, transparent);
+}
+
+.memory-toolbar-btn:disabled {
+  opacity: 0.35;
+  cursor: default;
+  outline: none;
+}
+.memory-toolbar-btn.spinning {
+  border-color: transparent;
+  background: none;
+}
+.memory-toolbar-toggle {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  height: 24px;
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  cursor: pointer;
+  padding: 0 4px;
+  user-select: none;
+  transition: all 0.15s;
+}
+.memory-toolbar-toggle:hover {
+  color: var(--fg);
+}
+.memory-toolbar-toggle .admin-switch {
+  vertical-align: middle;
+}
+.memory-toolbar-toggle:has(input:not(:checked)) {
+  opacity: 0.7;
+}
+
+/* Bulk action bar */
+.memory-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 2px;
+  margin-bottom: 2px;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  font-size: 11px;
+}
+
+.memory-bulk-bar.hidden {
+  display: none;
+}
+
+/* Custom Memory sort picker — replaces the native 

. */ +.a11y-visually-hidden { + position: absolute !important; + width: 1px !important; height: 1px !important; + padding: 0 !important; margin: -1px !important; + overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; border: 0 !important; +} + +/* ── Custom language type picker (replaces visible chrome of native */ +.adm-provider-picker { position: relative; margin-bottom: 6px; } +.adm-provider-combo { + display: flex; + align-items: stretch; +} +.adm-provider-combo input { + flex: 1; + min-width: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.adm-provider-btn { + width: 100%; + display: flex; align-items: center; gap: 8px; + background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: 6px; + padding: 5px 8px; font-family: inherit; font-size: 12px; + cursor: pointer; text-align: left; +} +.adm-provider-combo .adm-provider-btn { + width: 128px; + flex-shrink: 0; + border-left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + justify-content: space-between; +} +.adm-provider-btn:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--border)); } +.adm-provider-current { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; } +.adm-provider-logo { + width: 14px; height: 14px; + display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; color: var(--fg); opacity: 0.85; +} +.adm-provider-logo svg { width: 14px; height: 14px; } +/* Pull the settings-select 4px closer to its logo chip so the row + reads as logo+select instead of having an extra gap. */ +.adm-model-logo + .settings-select { margin-left: -4px; } +/* Fallback rows: stretch the model select so the trash button on + the right aligns with the right edge of the Endpoint and Model + dropdowns above the fallback list. Without flex:1 the model + select shrinks to its content and the trash button rides up + tight to it. */ +.settings-fallback-row > .settings-select { flex: 1 1 0; min-width: 0; } +/* Constrain logo SVGs in settings dropdown chips. Many provider + icons in providers.js declare only viewBox (no width/height), + so without this rule the browser falls back to 300x150 and + blows up the row. */ +.settings-fallback-row > span[style*="width:18px"] svg:not([width]), +.settings-row > span[style*="width:18px"] svg:not([width]), +span[id$="-logo"] svg:not([width]) { + width: 100%; + height: 100%; + display: block; +} +.adm-provider-logo:empty { + background: color-mix(in srgb, var(--fg) 12%, transparent); + border-radius: 50%; +} +.adm-provider-caret { flex-shrink: 0; transition: transform 0.15s; } +.adm-provider-picker:has(.adm-provider-menu:not(.hidden)) .adm-provider-caret { + transform: rotate(180deg); +} +.adm-provider-menu { + position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100; + max-height: 280px; overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + padding: 4px; +} +.adm-provider-menu.hidden { display: none; } +.adm-provider-item { + display: flex; align-items: center; gap: 8px; + padding: 5px 8px; border-radius: 4px; + font-size: 12px; cursor: pointer; +} +.adm-provider-item:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } +.adm-provider-item.active { background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); } + +/* When the Appearance tab is open, the #settings-modal-opacity slider (in + the header) fades the modal so the user can see the rest of the UI react + as toggles flip. The fade is applied as a background color-mix in JS + (settings.js) rather than element opacity, so the controls/text stay + crisp — mirroring the Theme customizer's opacity slider. */ + +/* Search provider fallback chain — chips + drag-reorder */ +.search-fallback-chain { + flex: 1; display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; min-height: 28px; +} +.search-fb-chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 4px 3px 6px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + border: 1px solid var(--border); border-radius: 10px; + font-size: 11px; cursor: grab; user-select: none; +} +.search-fb-chip.dragging { opacity: 0.4; } +.search-fb-grip { opacity: 0.35; font-size: 10px; line-height: 1; cursor: grab; } +.search-fb-logo { + width: 12px; height: 12px; + display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; opacity: 0.85; +} +.search-fb-logo svg { width: 12px; height: 12px; } +.search-fb-remove { + background: none; border: none; color: var(--fg); + opacity: 0.45; cursor: pointer; + font-size: 14px; line-height: 1; padding: 0 2px; + transition: opacity 0.15s, color 0.15s; +} +.search-fb-remove:hover { opacity: 1; color: var(--red); } +.search-fb-add { + background: var(--bg); color: var(--fg); + border: 1px dashed var(--border); border-radius: 10px; + padding: 2px 6px; font-family: inherit; font-size: 11px; + outline: none; cursor: pointer; +} +.search-fb-add:focus, +.search-fb-add:hover { + border-color: var(--accent, var(--red)); + border-style: solid; +} + +.mcp-tools-search { + width: 100%; + box-sizing: border-box; + margin-bottom: 6px; + padding: 5px 8px; + font-size: 11px; + font-family: inherit; + background: color-mix(in srgb, var(--fg) 4%, transparent); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--fg); + outline: none; +} +.mcp-tools-search:focus { + border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); +} +.mcp-tools-count { + font-size: 11px; + font-weight: 600; + opacity: 0.7; +} + +/* Badges */ +.admin-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--red) 20%, transparent); + color: var(--red); + font-weight: 600; +} +.admin-badge-off { + background: color-mix(in srgb, var(--color-error) 20%, transparent); + color: var(--color-error); +} + +/* Buttons */ +.admin-btn-delete { + background: none; + border: 1px solid color-mix(in srgb, var(--color-error) 27%, transparent); + color: var(--color-error); + padding: 4px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 11px; + font-family: inherit; + transition: all 0.15s; +} +.admin-btn-delete:hover { + background: var(--color-error); + border-color: var(--color-error); + color: #fff; +} +.admin-btn-add { + padding: 4px 10px; + border: 1px solid var(--accent, var(--red)); + border-radius: 6px; + background: var(--accent, var(--red)); + color: var(--bg); + cursor: pointer; + font-weight: 600; + font-size: 11px; + font-family: inherit; + box-sizing: border-box; + transition: opacity 0.15s, background 0.15s, border-color 0.15s; +} +.admin-btn-add:hover { + background: var(--accent, var(--red)); + border-color: var(--accent, var(--red)); + opacity: 0.9; +} +.admin-btn-add:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.admin-btn-sm { + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--fg); + cursor: pointer; + font-size: 11px; +} +.admin-btn-sm:hover { + background: var(--border); + border-color: var(--red); +} +.admin-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: var(--red); + border-radius: 50%; + animation: admin-spin 0.6s linear infinite; + vertical-align: -2px; + margin-right: 2px; +} + +/* Forms */ +.admin-add-form { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.admin-add-form input { + flex: 1; + min-width: 120px; + padding: 5px 8px; + height: 32px; + box-sizing: border-box; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-family: inherit; + font-size: 12px; +} +.admin-add-form input:focus { outline: none; border-color: var(--red); } +.admin-switch-inline { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: color-mix(in srgb, var(--fg) 60%, transparent); + cursor: default; +} + +.admin-model-form { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} +.admin-model-form input, +.admin-model-form select { + padding: 5px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 12px; +} +.admin-model-form input:focus { outline: none; border-color: var(--red); } +.admin-model-form-row { + display: flex; + gap: 6px; + flex-wrap: wrap; /* let buttons drop to a new row on narrow widths + instead of overflowing the modal */ + align-items: center; +} +.admin-model-form-row input { + flex: 1 1 180px; /* api-key input takes the long axis but allows + dropping below the buttons at small widths */ + min-width: 0; /* don't refuse to shrink past content width */ +} +.admin-model-form-row select { + flex: 0 0 auto; +} +.admin-model-form-row button { + flex: 0 0 auto; + white-space: nowrap; +} +/* On narrow screens, give buttons their own row + push the Add button + to the right so it remains the obvious primary action. */ +@media (max-width: 540px) { + .admin-model-form-row input { flex-basis: 100%; } + .admin-model-form-row .admin-btn-add { margin-left: auto; } +} +.adm-ep-inline-msg { + min-height: 16px; + margin-top: 5px; + font-size: 11px; +} + +/* Endpoint items */ +.admin-ep-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: var(--bg); + border-radius: 6px; + margin-bottom: 4px; + font-size: 12px; +} +.admin-ep-info { flex: 1; overflow: hidden; } +.admin-ep-name { color: var(--fg); font-weight: 500; } +.admin-ep-detail { + color: color-mix(in srgb, var(--fg) 40%, transparent); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: -4px; +} + +/* Settings panel — tint each card's h2 title icon with the user's accent + colour. Direct-child selector (`> h2 > svg`) so SVGs nested inside + header buttons (Test, kebab, etc.) are untouched. Each title icon uses + stroke="currentColor", so setting color propagates; existing inline + opacity:0.6 stays so the tint reads soft. */ +/* Selected integration card — class beats inline borderColor reset */ +.intg-card.intg-card-active { border-color: var(--accent, var(--red)) !important; } + +/* Edge-dock resize handle: invisible at rest, accent stripe animates in + on hover. Inline background:transparent + transition come from + modalSnap.js; this rule paints the accent gradient on hover, beating + the inline style with !important. */ +.edge-dock-resize-handle-left:hover { + background: linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px) !important; +} +.edge-dock-resize-handle-right:hover { + background: linear-gradient(to left, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px) !important; +} + +[data-settings-panel] .admin-card > h2 > svg, +#theme-popup .admin-card > h2 > svg, +[data-settings-panel="added-models"] .adm-ep-section-head > svg { + color: var(--accent, var(--red)); +} diff --git a/static/css/style-part-04.css b/static/css/style-part-04.css new file mode 100644 index 000000000..ce6a9add4 --- /dev/null +++ b/static/css/style-part-04.css @@ -0,0 +1,6011 @@ +/* Fragment 5/9 of the former static/style.css. + * + * This is a mechanical, order-preserving slice: rules were moved verbatim + * and this file's position in the order of static/index.html is what + * keeps the cascade identical. Reordering the links, or moving a rule between + * fragments, can change which of two competing declarations wins. + * + * static/index.html and the PRECACHE list in static/sw.js must list every + * fragment in the same order. tests/test_static_stylesheet_manifest.py and + * tests/test_css_computed_style_snapshot.py both fail if that drifts. + */ +.admin-ep-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Status messages */ +.admin-error { color: var(--color-error); font-size: 11px; margin-top: 4px; } +.admin-success { color: var(--color-success); font-size: 11px; margin-top: 4px; } +.admin-empty { + color: color-mix(in srgb, var(--fg) 40%, transparent); + font-size: 12px; + padding: 10px 0; + text-align: center; +} +/* Endpoint-list empty states ("No endpoints configured" / "None") get a + left-aligned, accent-colored treatment so they read as "this row is your + placeholder, click Add" rather than a centered "nothing here" grey blob. */ +#adm-epList-local .admin-empty, +#adm-epList-api .admin-empty { + text-align: left; + padding: 8px 4px; + color: var(--accent-primary, var(--accent, var(--red))); +} + +/* RAG upload zone */ +.admin-rag-upload-zone { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 14px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + margin-bottom: 8px; +} +.admin-rag-upload-zone:hover, +.admin-rag-upload-zone.dragover { + border-color: var(--red); + background: color-mix(in srgb, var(--red) 7%, transparent); +} +.admin-rag-upload-zone p { + color: color-mix(in srgb, var(--fg) 50%, transparent); + font-size: 11px; + margin-top: 4px; +} +.admin-rag-icon { font-size: 18px; } +.admin-rag-dir-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} +.admin-rag-dir-row input { + flex: 1; + padding: 5px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 12px; +} +.admin-rag-dir-row input:focus { outline: none; border-color: var(--red); } +.admin-rag-dir-row button { + padding: 5px 8px; + border: none; + border-radius: 6px; + background: var(--red); + color: #fff; + cursor: pointer; + font-size: 12px; + white-space: nowrap; +} +.admin-rag-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} +.admin-rag-section-label { + font-size: 10px; + color: color-mix(in srgb, var(--fg) 50%, transparent); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 8px 0 4px; +} +.admin-rag-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + background: var(--bg); + border-radius: 6px; + margin-bottom: 4px; + font-size: 12px; +} +.admin-rag-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg); +} +.admin-rag-item-meta { + color: color-mix(in srgb, var(--fg) 40%, transparent); + font-size: 10px; + margin: 0 6px; + flex-shrink: 0; +} +.admin-rag-status { + font-size: 11px; + color: color-mix(in srgb, var(--fg) 50%, transparent); + margin-top: 6px; +} + +/* Token reveal */ +.admin-token-reveal { + margin-top: 8px; + padding: 8px; + background: color-mix(in srgb, var(--red) 8%, var(--bg)); + border: 1px solid color-mix(in srgb, var(--red) 25%, var(--border)); + border-radius: 8px; +} + +/* Selects in admin cards */ +.admin-card select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 12px; +} +.admin-card input { + font-family: inherit; + font-size: 12px; +} +.admin-card label { + font-size: 12px; +} + +/* ---- Document Library ---- */ + +.doclib-modal-content { + width: min(600px, 92vw); + max-height: 85vh; + font-size: 12px; + background: var(--bg); +} +/* Anchor doclib modal to top so only the bottom edge moves on resize */ +#doclib-modal { + align-items: flex-start; + padding-top: 8vh; +} +.doclib-modal-content .modal-header h4 { + font-size: 1rem; +} +/* The "open in new tab" email modal inherited the 1rem doclib header, which + read way bigger than the email subject in the regular (inline) reader. Pin + it smaller so the two views are consistent. */ +.email-reader-tab-modal .modal-header h4 { + font-size: 13px; + font-weight: 600; +} +/* Match the sticky modal-header to the doclib modal-content body color + (var(--bg)) instead of the global var(--panel) default — otherwise the + header reads as a darker stripe above the email list. */ +.doclib-modal-content > .modal-header { + background: var(--bg); +} +.doclib-stats { + font-size: 11px; + color: var(--fg-muted); + margin-bottom: 8px; +} +.doclib-toolbar { + display: flex; + gap: 8px; + margin-bottom: 8px; + align-items: center; +} +.doclib-search { + flex: 1; + padding: 6px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 12px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} +#archive-select-btn { height: auto; padding: 5px 8px; } +.doclib-search:focus { + border-color: var(--red); +} +.doclib-sort { + padding: 6px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 12px; + font-family: inherit; + outline: none; + cursor: pointer; +} +.doclib-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} +.doclib-chip { + padding: 2px 10px; + border-radius: 12px; + font-size: 10px; + border: 1px solid var(--border); + background: transparent; + color: var(--fg-muted); + cursor: pointer; + user-select: none; + transition: background 0.15s, border-color 0.15s; +} +.doclib-chip:hover { + border-color: var(--red); +} +.doclib-chip.active { + background: color-mix(in srgb, var(--red) 15%, transparent); + border-color: color-mix(in srgb, var(--red) 40%, transparent); + color: var(--red); +} +/* Document library — language chip row */ +.doclib-lang-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 2px 0; + max-height: 52px; + overflow-y: auto; +} +.doclib-lang-chips:empty { display: none; } +/* Mobile: keep tag chips on ONE row and scroll horizontally instead of wrapping + into a tall multi-line block (Serve, library — anywhere .doclib-lang-chips is used). */ +@media (max-width: 768px) { + .doclib-lang-chips { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + max-height: none; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .doclib-lang-chips::-webkit-scrollbar { display: none; } + .doclib-lang-chips > * { flex-shrink: 0; } +} +#doclib-tidy-btn, #doclib-select-btn, #doclib-chats-tidy-btn { + position: relative; + top: -3px; +} +/* Document library — list layout */ +.doclib-grid { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 400px; + overflow-y: auto; + padding: 0; + position: relative; +} + +/* Gallery grid domino-in cascade on open — same recipe as the document/email + libraries. Applied to #gallery-grid in gallery.js on the first render after + each open, removed after the longest delay so re-renders feel instant. */ +.gallery-just-opened > .gallery-card { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; +} +.gallery-just-opened > :nth-child(1) { animation-delay: 0.02s; } +.gallery-just-opened > :nth-child(2) { animation-delay: 0.04s; } +.gallery-just-opened > :nth-child(3) { animation-delay: 0.06s; } +.gallery-just-opened > :nth-child(4) { animation-delay: 0.08s; } +.gallery-just-opened > :nth-child(5) { animation-delay: 0.10s; } +.gallery-just-opened > :nth-child(6) { animation-delay: 0.12s; } +.gallery-just-opened > :nth-child(7) { animation-delay: 0.14s; } +.gallery-just-opened > :nth-child(8) { animation-delay: 0.16s; } +.gallery-just-opened > :nth-child(9) { animation-delay: 0.18s; } +.gallery-just-opened > :nth-child(10) { animation-delay: 0.20s; } +.gallery-just-opened > :nth-child(11) { animation-delay: 0.22s; } +.gallery-just-opened > :nth-child(12) { animation-delay: 0.24s; } +.gallery-just-opened > :nth-child(13) { animation-delay: 0.26s; } +.gallery-just-opened > :nth-child(14) { animation-delay: 0.28s; } +.gallery-just-opened > :nth-child(15) { animation-delay: 0.30s; } +.gallery-just-opened > :nth-child(16) { animation-delay: 0.32s; } +.gallery-just-opened > :nth-child(17) { animation-delay: 0.34s; } +.gallery-just-opened > :nth-child(18) { animation-delay: 0.36s; } +.gallery-just-opened > :nth-child(19) { animation-delay: 0.38s; } +.gallery-just-opened > :nth-child(20) { animation-delay: 0.40s; } +.gallery-just-opened > :nth-child(n+21) { animation-delay: 0.42s; } + +/* Tasks list domino-in cascade on open — mirrors gallery / doclib. Applied to + #tasks-list in tasks.js on the first render after each open, removed after + the longest delay so re-renders feel instant. */ +.tasks-just-opened > .task-card { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; +} +.tasks-just-opened > :nth-child(1) { animation-delay: 0.02s; } +.tasks-just-opened > :nth-child(2) { animation-delay: 0.04s; } +.tasks-just-opened > :nth-child(3) { animation-delay: 0.06s; } +.tasks-just-opened > :nth-child(4) { animation-delay: 0.08s; } +.tasks-just-opened > :nth-child(5) { animation-delay: 0.10s; } +.tasks-just-opened > :nth-child(6) { animation-delay: 0.12s; } +.tasks-just-opened > :nth-child(7) { animation-delay: 0.14s; } +.tasks-just-opened > :nth-child(8) { animation-delay: 0.16s; } +.tasks-just-opened > :nth-child(9) { animation-delay: 0.18s; } +.tasks-just-opened > :nth-child(10) { animation-delay: 0.20s; } +.tasks-just-opened > :nth-child(11) { animation-delay: 0.22s; } +.tasks-just-opened > :nth-child(12) { animation-delay: 0.24s; } +.tasks-just-opened > :nth-child(13) { animation-delay: 0.26s; } +.tasks-just-opened > :nth-child(14) { animation-delay: 0.28s; } +.tasks-just-opened > :nth-child(15) { animation-delay: 0.30s; } +.tasks-just-opened > :nth-child(16) { animation-delay: 0.32s; } +.tasks-just-opened > :nth-child(17) { animation-delay: 0.34s; } +.tasks-just-opened > :nth-child(18) { animation-delay: 0.36s; } +.tasks-just-opened > :nth-child(19) { animation-delay: 0.38s; } +.tasks-just-opened > :nth-child(20) { animation-delay: 0.40s; } +.tasks-just-opened > :nth-child(n+21) { animation-delay: 0.42s; } + +/* Document library cascade — same recipe as email library, applied per-tab + the first time content loads (chats / archive / research / documents). + Module-level Set in documentLibrary.js prevents re-firing on tab swaps + or filter/sort re-renders within the same page session. */ +.doclib-just-opened > .memory-item, +.doclib-just-opened > .doclib-card { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; +} +.doclib-just-opened > :nth-child(1) { animation-delay: 0.02s; } +.doclib-just-opened > :nth-child(2) { animation-delay: 0.04s; } +.doclib-just-opened > :nth-child(3) { animation-delay: 0.06s; } +.doclib-just-opened > :nth-child(4) { animation-delay: 0.08s; } +.doclib-just-opened > :nth-child(5) { animation-delay: 0.10s; } +.doclib-just-opened > :nth-child(6) { animation-delay: 0.12s; } +.doclib-just-opened > :nth-child(7) { animation-delay: 0.14s; } +.doclib-just-opened > :nth-child(8) { animation-delay: 0.16s; } +.doclib-just-opened > :nth-child(9) { animation-delay: 0.18s; } +.doclib-just-opened > :nth-child(10) { animation-delay: 0.20s; } +.doclib-just-opened > :nth-child(11) { animation-delay: 0.22s; } +.doclib-just-opened > :nth-child(12) { animation-delay: 0.24s; } +.doclib-just-opened > :nth-child(13) { animation-delay: 0.26s; } +.doclib-just-opened > :nth-child(14) { animation-delay: 0.28s; } +.doclib-just-opened > :nth-child(15) { animation-delay: 0.30s; } +.doclib-just-opened > :nth-child(16) { animation-delay: 0.32s; } +.doclib-just-opened > :nth-child(17) { animation-delay: 0.34s; } +.doclib-just-opened > :nth-child(18) { animation-delay: 0.36s; } +.doclib-just-opened > :nth-child(19) { animation-delay: 0.38s; } +.doclib-just-opened > :nth-child(20) { animation-delay: 0.40s; } +.doclib-just-opened > :nth-child(n+21) { animation-delay: 0.42s; } + +/* Domino cascade on first open of the email library — mirrors the sidebar + .section-just-expanded recipe so list items spring in staggered. Class + is applied to #email-lib-grid in emailLibrary.js on the first render + only, then removed after the longest delay so re-renders feel instant. */ +.email-lib-just-opened :is(.doclib-card, .date-section-header) { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; +} +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(1) { animation-delay: 0.02s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(2) { animation-delay: 0.04s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(3) { animation-delay: 0.06s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(4) { animation-delay: 0.08s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(5) { animation-delay: 0.10s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(6) { animation-delay: 0.12s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(7) { animation-delay: 0.14s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(8) { animation-delay: 0.16s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(9) { animation-delay: 0.18s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(10) { animation-delay: 0.20s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(11) { animation-delay: 0.22s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(12) { animation-delay: 0.24s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(13) { animation-delay: 0.26s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(14) { animation-delay: 0.28s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(15) { animation-delay: 0.30s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(16) { animation-delay: 0.32s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(17) { animation-delay: 0.34s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(18) { animation-delay: 0.36s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(19) { animation-delay: 0.38s; } +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(20) { animation-delay: 0.40s; } +/* Cap the cascade at 20 — beyond that they animate together so opening a + long inbox doesn't take forever to settle. */ +.email-lib-just-opened :is(.doclib-card, .date-section-header):nth-child(n+21) { animation-delay: 0.42s; } + +/* Inside the email library modal, the grid needs to grow with the modal — + but `flex: 1 1 0` collapses to 0 when the parent isn't height-constrained + (which is the non-fullscreen default). Use `auto` basis + a sensible + `min-height` floor so it shows ~400px naturally and absorbs more when + the modal is resized / fullscreened. */ +#email-lib-modal .doclib-grid { + max-height: none; + height: auto; + flex: 1 1 auto; + min-height: 400px; +} + +/* ── Mobile compose FAB (email) ── + Replaces the top-right "New" button on phones with a round mail-icon button + bottom-right that collapses to a circle while the list scrolls and springs + back out to "New" when scrolling stops. Desktop is unchanged. */ +.email-lib-fab { display: none; } +@media (max-width: 768px) { + /* The absolute FAB anchors to the email panel card. */ + #email-lib-modal .admin-card { position: relative; } + /* Hide the toolbar "New" button — the FAB is the mobile entry point. */ + #email-lib-compose-btn { display: none; } + + #email-lib-modal .email-lib-fab { + --fab-size: 56px; + display: flex; + align-items: center; + gap: 9px; + /* Hidden until the email list has rendered; JS adds .fab-revealed to pop + it in (scale-from-center). Avoids the button flashing at the top and + sliding down before _positionFab() places it. */ + transform: scale(0); + opacity: 0; + transform-origin: center center; + position: absolute; + right: calc(16px + env(safe-area-inset-right, 0px)); + bottom: calc(18px + env(safe-area-inset-bottom, 0px)); + height: var(--fab-size); + min-width: var(--fab-size); + padding: 0 20px 0 18px; + border: none; + border-radius: 999px; + background: var(--accent-primary, var(--red)); + color: #fff; + font-family: inherit; + font-size: 15px; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-shadow: 0 6px 18px rgba(0,0,0,.38), 0 2px 6px rgba(0,0,0,.28); + z-index: 30; + pointer-events: auto; + /* This (base) transition governs the EXPAND — slower + graceful. */ + transition: + padding 420ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 240ms ease, + transform 140ms ease, + background 140ms ease; + will-change: padding, transform; + } + #email-lib-modal .email-lib-fab svg { flex: 0 0 auto; display: block; } + #email-lib-modal .email-lib-fab .email-lib-fab-label { + overflow: hidden; + white-space: nowrap; + max-width: 120px; + opacity: 1; + /* EXPAND timing — label eases out a touch after the width opens up. */ + transition: + max-width 420ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 300ms 60ms cubic-bezier(0.22, 1, 0.36, 1), + margin 420ms cubic-bezier(0.22, 1, 0.36, 1); + } + /* Collapsed (while scrolling) → pure circle, icon only. We DON'T set an + explicit width: the container is auto-width and shrinks smoothly as the + padding + label max-width animate (min-width keeps it circular). Setting a + fixed width here made the collapse snap, since auto↔px width can't tween. + This (.collapsed) transition governs the COLLAPSE — kept snappy. */ + #email-lib-modal .email-lib-fab.collapsed { + padding: 0; + justify-content: center; + transition: + padding 230ms cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 200ms ease, + transform 140ms ease; + } + #email-lib-modal .email-lib-fab.collapsed .email-lib-fab-label { + max-width: 0; + opacity: 0; + margin-left: -9px; + transition: + max-width 230ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), + margin 230ms cubic-bezier(0.4, 0, 0.2, 1); + } + /* Entrance: circle-expand-from-middle pop, played once when revealed. No + `forwards` fill so the resting transform (scale 1) comes from this rule — + that keeps the :active press transform working afterwards. */ + #email-lib-modal .email-lib-fab.fab-revealed { + transform: scale(1); + opacity: 1; + animation: emailFabPop 380ms cubic-bezier(0.34, 1.56, 0.64, 1); + } + #email-lib-modal .email-lib-fab:active { + transform: scale(0.93); + filter: brightness(0.92); + box-shadow: 0 3px 10px rgba(0,0,0,.4); + } +} +@keyframes emailFabPop { + from { transform: scale(0); opacity: 0; } + 60% { transform: scale(1.06); opacity: 1; } + to { transform: scale(1); opacity: 1; } +} +@media (prefers-reduced-motion: reduce) { + #email-lib-modal .email-lib-fab, + #email-lib-modal .email-lib-fab .email-lib-fab-label { transition-duration: 1ms; } + #email-lib-modal .email-lib-fab.fab-revealed { animation: none; } +} +/* When the modal-content has explicit height (fullscreen or user-resized), + drop the floor so the grid sizes purely from flex. */ +#email-lib-modal.email-lib-fullscreen .doclib-grid, +#email-lib-modal .modal-content[style*="height"] .doclib-grid { + min-height: 0; +} +/* Document library: same fix as the email library (#74). When fullscreened + the modal-content gets an inline `height: 100vh`, but the inner + .doclib-grid stays capped at the 400px default so the list looks like a + tiny strip floating in a giant empty modal. Mirror the email recipe: + make the modal a flex column, give the body/admin-card claim of the + remaining height, and let the grid take the rest. Scoped to the + fullscreen class so windowed-mode layout is unchanged. */ +/* Inner flex chain that lets the chats/documents grid claim the full + remaining height of the modal. Applies in BOTH fullscreen AND + right-docked states — without the docked state included, dragging a + fullscreen doclib to the right snap zone breaks the flex layout + because exitFullscreen removes .doclib-fullscreen, and the grid + falls back to its base max-height: 400px showing only ~8 items. + The CSS variable :is() selector lets both states share one rule. */ +#doclib-modal.doclib-fullscreen .doclib-modal-content, +#doclib-modal.modal-right-docked .doclib-modal-content, +#doclib-modal.modal-left-docked .doclib-modal-content { + display: flex; + flex-direction: column; + overflow: hidden; +} +/* Fullscreen positioning — scoped to NOT apply when also right-docked. + Without this exclusion, dragging a fullscreen doclib to the right snap + zone keeps .doclib-fullscreen on the modal and these !important rules + override the dock's inline styles, leaving the modal stuck fullscreen + instead of becoming a side panel. */ +#doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content { + position: fixed !important; + top: 0 !important; + left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important; + right: 0 !important; + bottom: 0 !important; + width: auto !important; + max-width: none !important; + height: 100vh !important; + max-height: 100vh !important; + border-radius: 0 !important; + transform: none !important; +} +#doclib-modal.doclib-fullscreen .modal-header, +#doclib-modal.doclib-fullscreen .lib-tabs, +#doclib-modal.modal-right-docked .modal-header, +#doclib-modal.modal-right-docked .lib-tabs, +#doclib-modal.modal-left-docked .modal-header, +#doclib-modal.modal-left-docked .lib-tabs { + flex: 0 0 auto; +} +#doclib-modal.doclib-fullscreen .modal-body, +#doclib-modal.modal-right-docked .modal-body, +#doclib-modal.modal-left-docked .modal-body { + flex: 1 1 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} +#doclib-modal.doclib-fullscreen .admin-card, +#doclib-modal.modal-right-docked .admin-card, +#doclib-modal.modal-left-docked .admin-card { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; +} +#doclib-modal.doclib-fullscreen .doclib-grid:not(:has(.doclib-card-expanded)), +#doclib-modal.modal-right-docked .doclib-grid:not(:has(.doclib-card-expanded)), +#doclib-modal.modal-left-docked .doclib-grid:not(:has(.doclib-card-expanded)) { + max-height: none; + height: auto; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +/* "Reply" docks the open email modal to the left as a sidebar so the doc + panel (which slides in from the right of the chat area) is visible + side-by-side. The .modal backdrop is already pointer-events:none for + email modals — only the content takes pointer events — so the chat / + doc panel underneath stays interactive. */ +.modal.email-snap-left { + align-items: stretch; + justify-content: flex-start; + left: 0 !important; + width: 100% !important; +} +.modal.email-snap-left .modal-content { + left: var(--email-doc-split-left-x, 0px) !important; + width: var(--email-doc-split-email-w, 420px) !important; + max-width: var(--email-doc-split-email-w, 420px) !important; + border-right: 1px solid var(--border); + box-shadow: 4px 0 14px rgba(0, 0, 0, 0.18); + border-radius: 0; +} +@media (min-width: 769px) { + body.email-doc-split-active.doc-view #email-lib-modal, + body.email-doc-split-active.doc-view.email-front #email-lib-modal, + body.email-doc-split-active.doc-view .modal[id^="email-reader-"], + body.email-doc-split-active.doc-view.email-front .modal[id^="email-reader-"] { + z-index: 150 !important; + } + body.email-doc-split-active #email-lib-modal.email-snap-left, + body.email-doc-split-active #email-lib-modal.modal-left-docked { + left: var(--email-doc-split-left-x, 0px) !important; + width: var(--email-doc-split-email-w, 420px) !important; + overflow: hidden !important; + z-index: 155 !important; + } + body.email-doc-split-active #email-lib-modal.email-snap-left:not(.modal-dragging) .modal-content, + body.email-doc-split-active #email-lib-modal.modal-left-docked:not(.modal-dragging) .modal-content, + body.email-doc-split-active .modal[id^="email-reader-"].email-snap-left:not(.modal-dragging) .modal-content, + body.email-doc-split-active .modal[id^="email-reader-"].modal-left-docked:not(.modal-dragging) .modal-content { + position: absolute !important; + left: 0 !important; + top: 0 !important; + bottom: 0 !important; + right: auto !important; + width: var(--email-doc-split-email-w, 420px) !important; + max-width: var(--email-doc-split-email-w, 420px) !important; + height: 100vh !important; + max-height: 100vh !important; + transform: none !important; + margin: 0 !important; + } + body.email-doc-split-active.doc-view .doc-divider { + display: none !important; + } + body.email-doc-split-active.doc-view .doc-editor-pane { + position: fixed !important; + left: var(--email-doc-split-right-x, 420px) !important; + right: var(--right-dock-w, 0px) !important; + top: 0 !important; + bottom: 0 !important; + width: auto !important; + max-width: none !important; + height: 100vh !important; + z-index: 260 !important; + margin-top: 0 !important; + transform: none !important; + border-left: none !important; + } +} + +/* Edge docking. Docked panels are fixed to the viewport edge; the workspace + panes reserve room with margins so left + right docks can be active at the + same time without skewing the entire body box. */ +body.right-dock-active { + padding-right: 0; +} +body.left-dock-active { + padding-left: 0; +} +body.left-dock-active:not(.email-doc-split-active) .chat-container { + margin-left: var(--left-dock-w, 0px); +} +body.right-dock-active .chat-container, +body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane { + margin-right: var(--right-dock-w, 0px); +} +.modal.modal-right-docked { + align-items: stretch; + justify-content: flex-end; +} +.modal.modal-right-docked .modal-content { + border-left: 1px solid var(--border); + box-shadow: -4px 0 14px rgba(0, 0, 0, 0.18); + border-radius: 0; +} +.modal.modal-left-docked { + align-items: stretch; + justify-content: flex-start; +} +.modal.modal-left-docked .modal-content { + border-right: 1px solid var(--border); + box-shadow: 4px 0 14px rgba(0, 0, 0, 0.18); + border-radius: 0; + /* Pin transitions OFF on the dock's positioning properties. The wide + sidebar collapse/expand changes --sidebar-w instantly, which means + the docked modal's `left: calc(...)` jumps by ~240px. Any CSS + transition on `left` (inherited or otherwise) would animate that + jump as a fly-across. Same defense for right-docked / fullscreen + panels in case future themes add a generic transition. */ + transition: none !important; +} +.modal.modal-right-docked .modal-content, +#email-lib-modal.email-lib-fullscreen .modal-content, +#doclib-modal.doclib-fullscreen .doclib-modal-content { + transition: none !important; +} +.modal.modal-right-docked .email-reader-header, +.modal.modal-left-docked .email-reader-header { + flex-direction: column; + /* Stretch the meta to the full header width so the absolute + action cluster's right:0 lands at the right edge (was sizing + meta to chip content width and centering it). */ + align-items: stretch; + gap: 6px; +} +/* Docked panes: 4px between From→To and 4px between To→Cc. + Meta has its own 2px gap (From→details) so an extra 2px on + details margin-top gives a 4px From→To distance, and gap:4 on + the details flex column gives 4px between To and Cc. */ +.modal.modal-right-docked .email-reader-meta-details, +.modal.modal-left-docked .email-reader-meta-details { + margin-top: 2px; + gap: 4px; +} +/* Docked: always show To/Cc — there's vertical room reserved for + the action cluster already and the chevron toggle just adds + friction. Force the hidden attribute open and hide the chevron. */ +.modal.modal-right-docked .email-reader-meta-details[hidden], +.modal.modal-left-docked .email-reader-meta-details[hidden] { + display: flex !important; +} +.modal.modal-right-docked .email-reader-meta-toggle, +.modal.modal-left-docked .email-reader-meta-toggle { + display: none; +} +.modal.modal-right-docked .email-reader-header > .email-reader-actions, +.modal.modal-left-docked .email-reader-header > .email-reader-actions { + position: static; + align-self: flex-end; + background: none; + padding-left: 0; +} +/* Docked modal: keep all meta rows as flex so From / To / Cc + labels align at the same x. Chips already overflow-scroll + inside each row when there are too many. No per-row margin-top + here — the spacing between rows comes entirely from + .email-reader-meta-details gap so From→To and To→Cc match. */ +.modal.modal-right-docked .email-reader-meta-row:not(.email-reader-meta-from), +.modal.modal-left-docked .email-reader-meta-row:not(.email-reader-meta-from) { + padding-right: 0; +} +.modal.modal-right-docked .email-reader-meta-row strong, +.modal.modal-left-docked .email-reader-meta-row strong { + min-width: 0; +} +.modal.modal-right-docked .recipient-chip, +.modal.modal-left-docked .recipient-chip { + max-width: 100%; +} +.archive-list { + margin-top: 8px; + border-top: 1px solid var(--border); + padding-top: 8px; +} +#archive-modal .memory-sort-select, +#archive-modal .memory-toolbar-btn { + height: 32px; +} +.archive-menu-btn { + position: relative; + top: 2px; +} +#archive-chips .doclib-chip { + height: 22px; + display: inline-flex; + align-items: center; + font-size: 10px; + padding: 0 8px; +} +/* Library tab panels — Chats / Archive / Research / Documents share the + same toolbar dimensions so the sort dropdown + Select button line up + identically across tabs. */ +#doclib-panel-chats .memory-sort-select, +#doclib-panel-archive .memory-sort-select, +#doclib-panel-research .memory-sort-select, +[data-doclib-panel="documents"] .memory-sort-select { + height: 24px; + font-size: 11px; + width: 86px; +} +#doclib-panel-chats .memory-toolbar-btn, +#doclib-panel-archive .memory-toolbar-btn, +#doclib-panel-research .memory-toolbar-btn, +[data-doclib-panel="documents"] .memory-toolbar-btn { + height: 24px; + font-size: 11px; + position: relative; + top: -3px; +} +/* Research's Recent (sort) + Select sat 1px lower than the other tabs — nudge up. */ +#doclib-panel-research .memory-sort-select { position: relative; top: 1.5px; } +#doclib-panel-research .memory-toolbar-btn { top: -4.5px; } +.library-sort-picker { + width: 108px; + flex: 0 0 108px; + top: 1px; +} +.library-sort-picker .library-sort-btn { + top: 0; + width: 100%; +} +.library-sort-picker .library-sort-menu { + top: calc(100% + 4px); + width: max(100%, 154px); +} +.library-sort-picker .library-sort-item[aria-selected="true"] { + background: color-mix(in srgb, var(--accent, var(--red)) 13%, transparent); +} +#doclib-panel-chats #doclib-chats-tidy-btn, +#doclib-panel-chats #doclib-chats-select-btn, +#doclib-panel-archive #doclib-arc-select-btn { + top: 1px; +} +#doclib-panel-chats .doclib-chip-scroll-arrow { + top: calc(50% - 9px) !important; +} +[data-doclib-panel="documents"] .doclib-chip-scroll-arrow { + top: calc(50% - 9px) !important; +} +#doclib-modal .doclib-chip-scroll-arrow { + top: calc(50% - 19px) !important; +} +[data-doclib-panel="documents"] #doclib-tidy-btn, +[data-doclib-panel="documents"] #doclib-select-btn { + top: 1px; +} +#doclib-panel-research #doclib-research-tidy-btn, +#doclib-panel-research #doclib-research-select-btn { + top: -0.5px; +} +#doclib-panel-research .library-sort-picker { top: -0.5px; } +[data-doclib-panel] { padding-top: 6px !important; } +#doclib-panel-chats, #doclib-panel-archive { padding-top: 14px !important; } +#doclib-panel-chats .memory-desc, #doclib-panel-archive .memory-desc, #doclib-panel-research .memory-desc { margin-top: 7px; } +#doclib-panel-chats .memory-search-input, +#doclib-panel-archive .memory-search-input { + height: 30px !important; + min-height: 30px !important; + flex-shrink: 0; + font-size: 11px; +} +/* Unified search bar across Library tabs + Memory modal — same height, + same magnifying-glass icon at the start, consistent padding. */ +#doclib-panel-chats .memory-search-input, +#doclib-panel-archive .memory-search-input, +#doclib-panel-research .memory-search-input, +[data-doclib-panel="documents"] .memory-search-input, +#memory-modal .memory-search-input, +#tasks-modal .memory-search-input { + height: 30px; + min-height: 30px; + font-size: 11px; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: 9px center; + padding-left: 28px; +} +#doclib-panel-chats .doclib-chip { + height: 28px; +} +#doclib-panel-archive .doclib-chip { + height: 28px; +} +#doclib-panel-chats .doclib-chip, +#doclib-panel-archive .doclib-chip { + display: inline-flex; + align-items: center; + font-size: 9px; + padding: 0 8px; + border-radius: 10px; +} +.doclib-grid:has(.doclib-card-expanded) > .doclib-card:not(.doclib-card-expanded) { + display: none; +} +/* Hide everything except the grid when a card is expanded */ +.admin-card:has(.doclib-card-expanded) > *:not(.doclib-grid):not(.hwfit-cached-list) { + opacity: 0; + max-height: 0; + overflow: hidden; + pointer-events: none; + margin: 0 !important; + padding: 0 !important; + transition: opacity 0.12s ease, max-height 0.2s ease; +} +.admin-card:has(.doclib-card-expanded) > .memory-bulk-bar { + display: none; +} +.admin-card:has(.doclib-card-expanded) > .doclib-grid, +.admin-card:has(.doclib-card-expanded) > .hwfit-cached-list { + max-height: none !important; + min-height: 0 !important; + overflow: visible !important; +} +/* Firefox-mobile (no :has()) fallback: when reading an email, fully remove the + list-mode chrome (accounts row, toolbar, bulk bar, desc) — display:none so no + residual flex-gap leaves an empty strip above the reader — and zero the + admin-card gap so the grid/reader starts flush at the top. */ +#email-lib-modal.email-reading .admin-card > *:not(.doclib-grid):not(.hwfit-cached-list) { + display: none !important; +} +#email-lib-modal .admin-card:has(.doclib-card-expanded), +#email-lib-modal.email-reading .admin-card { + gap: 0 !important; + padding: 0 !important; + margin-bottom: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; +} +/* Override for chat-rows + research-rows: those expand inline with a + bounded preview (max-height: 60vh), so the grid must stay clipped + and scrollable. Without this scoping the overflow:visible above lets + the expanded chat preview escape the grid and overlap whatever's + underneath/beside the modal (e.g., a docked sibling panel) — what + the user calls "merging with other windows". */ +.admin-card:has(.doclib-chat-row.doclib-card-expanded) > .doclib-grid { + overflow: hidden auto !important; +} +/* Email modal needs the grid to STAY a constrained flex container when + expanded — overflow:visible (set above for cookbook) lets content escape + instead of letting the expanded reader fill via flex:1. We keep the same + max-height:none + min-height:0 but flip overflow back so the reader + actually fills the modal. */ +#email-lib-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid, +#email-lib-modal.email-reading .admin-card > .doclib-grid { + overflow: hidden !important; + display: flex !important; + flex: 1 1 auto !important; +} +#email-lib-modal.email-reading #email-lib-grid > .date-section-header { + display: none !important; +} +#email-lib-modal.email-reading .doclib-modal-content { + min-height: var(--email-reading-modal-min-h, auto); +} +#email-lib-modal .doclib-card.doclib-card-expanded { + flex: 1 1 auto !important; + height: 100% !important; + /* Neutral frame on the active email — the accent border + glow felt + overbearing on desktop; the size jump alone is enough signal. */ + border: 1px solid var(--border) !important; + box-shadow: 0 6px 18px rgba(0,0,0,0.12) !important; + animation: none !important; +} +/* Desktop-only, ONLY on the currently-expanded email card. Nudges the title + row down 6px / right 2px, bolds the subject, hides the timestamp from the + meta line (the reader header carries its own date), and pulls the prev/next + nav arrows in 4px. */ +@media (min-width: 769px) { + #email-lib-modal .doclib-card-expanded .email-card-titlerow, + #email-lib-modal .doclib-card-expanded .email-card-titlerow > * { + position: relative; + top: 6px; + left: 2px; + } + /* Smaller subject — the reader header below already carries weight. */ + #email-lib-modal .doclib-card-expanded .email-card-titlerow .memory-item-title { + font-weight: 700; + font-size: 12px; + } + /* A bit more breathing room around the title row so the smaller bold text + doesn't crowd the meta line below. (Timestamp kept — user changed mind.) */ + #email-lib-modal .doclib-card-expanded .email-card-titlerow { + padding: 4px 0 6px; + line-height: 1.35; + } + /* Nudge the date down/left in the meta row, and give the meta line +4px of + extra bottom space so the shifted date isn't clipped. */ + #email-lib-modal .doclib-card-expanded .email-meta-date { + position: relative; + top: 4px; + left: 0; + } + #email-lib-modal .doclib-card-expanded .memory-item-meta { + padding-bottom: 4px; + } + #email-lib-modal .doclib-card-expanded .email-card-nav-arrows { + position: relative; + left: -4px; + } +} +/* Skills modal: same mechanism as email above. The default expanded-grid + rule (overflow:visible, no flex) lets the card expand inline to ~content + height instead of filling — which is why the skill preview stopped at + partial height. Flip the grid back to a constrained flex container and + let the expanded card fill it, exactly like the email reader. */ +#memory-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid { + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; +} +#memory-modal .doclib-card.doclib-card-expanded { + flex: 1 1 auto !important; + height: 100% !important; +} +#email-lib-modal .modal-content { + transition: width 0.22s ease-out, height 0.22s ease-out; +} +@media (min-width: 769px) { + body:not(.email-doc-split-active) #email-lib-modal:not(.email-lib-fullscreen):not(.modal-left-docked):not(.modal-right-docked) .modal-content:not([style*="height"]) { + min-height: min(560px, 85vh); + } +} + +/* Serve panel specifically: the admin-card inline style is + `overflow:hidden` (so the toolbar/header don't drift), and the list + inside has overflow:visible. On short windows that combination + clipped the cards off the bottom with no scrollbar. Make the list + itself the scroll surface so the rest of the card stays put. */ +.cookbook-group[data-backend-group="Serve"] > .admin-card { + min-height: 0; +} +.cookbook-group[data-backend-group="Serve"] > .admin-card > #hwfit-cached-list, +.cookbook-group[data-backend-group="Serve"] > .admin-card > .hwfit-cached-list { + flex: 1 1 0; + min-height: 0; + overflow-y: auto !important; + overscroll-behavior: contain; +} +.cookbook-group[data-backend-group="Serve"] > .admin-card > .hwfit-cached-list .doclib-card.doclib-card-expanded { + flex: 0 0 auto !important; + overflow: visible !important; +} +/* Launch model cards use the same domino entrance as the other library lists + when a cached-model scan finishes. The class is removed after the one-shot + entrance, so filtering and sorting stay instantaneous. */ +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; +} +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(1) { animation-delay: 0.04s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(2) { animation-delay: 0.08s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(3) { animation-delay: 0.12s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(4) { animation-delay: 0.16s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(5) { animation-delay: 0.20s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(6) { animation-delay: 0.24s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(7) { animation-delay: 0.28s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(8) { animation-delay: 0.32s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(9) { animation-delay: 0.36s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(10) { animation-delay: 0.40s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(11) { animation-delay: 0.44s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(12) { animation-delay: 0.48s; } +#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card:nth-child(n+13) { animation-delay: 0.48s; } +@media (prefers-reduced-motion: reduce) { + #cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card { + animation: none; + } +} +/* Drag-and-drop visual hint for the email compose pane. Subtle accent + outline + tinted overlay so it's obvious files will attach if dropped. */ +.doc-editor-pane.email-dragover { + outline: 2px dashed var(--accent, #2563eb); + outline-offset: -8px; + background: color-mix(in srgb, var(--accent, #2563eb) 6%, transparent); +} +.doc-editor-pane.email-dragover::after { + content: 'Drop to attach'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--accent, #2563eb); + color: #fff; + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + pointer-events: none; + z-index: 1000; +} + +/* Email reader window — make the body always fill the available vertical + space regardless of how the window is positioned/resized. The user can + drag the bottom-right corner to resize; the body re-flexes automatically. */ +.email-window-modal .modal-content { + display: flex !important; + flex-direction: column !important; + resize: both; + overflow: hidden; + min-width: 320px; + min-height: 240px; + /* When user resizes/drags so the window covers a side, force the inner + body to fill all the way to the bottom. The default max-height: 85vh + (set inline by _makeDraggable's exitFullscreen) and 80vh from the + opener both cap at the *viewport* — if the browser window then changes + size (e.g. OS aero-snap), the modal stays at the old height and + truncates rows. Drop the cap whenever an explicit width OR height has + been written inline by the resize handle / drag handler. */ +} +.email-window-modal .modal-content[style*="width"], +.email-window-modal .modal-content[style*="height"] { + max-height: calc(100vh - 16px) !important; +} +.email-window-modal .email-window-body { + flex: 1 1 auto !important; + min-height: 0 !important; + /* Explicit basis of 0 makes the body grow to fill remaining space rather + than its content's intrinsic height (which was capping the thread). */ + flex-basis: 0 !important; + max-height: none !important; + overflow: auto !important; + overscroll-behavior: contain; +} + +/* Individual email reader window — fullscreen mode (drag-to-top snap or + double-click header). Same recipe as the library modal: pin to viewport + edges and let the inner body absorb the remaining height. */ +body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .modal-content { + position: fixed !important; + inset: 0 !important; + left: 0 !important; + top: 0 !important; + width: 100vw !important; + max-width: 100vw !important; + height: 100vh !important; + max-height: 100vh !important; + border-radius: 0 !important; + transform: none !important; + display: flex !important; + flex-direction: column !important; +} +body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .email-window-body { + flex: 1 1 auto !important; + min-height: 0 !important; + max-height: none !important; +} + +/* Email library modal: fullscreen mode + larger mobile sheet (mirrors the + cookbook treatment so the toolbar and bulk actions stay reachable). + Leaves whichever navigation strip is currently visible on the left + (the wide sidebar OR the icon rail — they're mutually exclusive, so + one of the two CSS vars is 0 at any given time, set by init.js). */ +body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content { + position: fixed !important; + top: 0 !important; + left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important; + right: 0 !important; + bottom: 0 !important; + width: auto !important; + max-width: none !important; + height: 100vh !important; + max-height: 100vh !important; + border-radius: 0 !important; + transform: none !important; +} +/* Mobile: the /email route adds .email-lib-fullscreen, but the desktop full- + bleed rule above squares off the corners and offsets the email by the icon + rail. On phones the email should be the normal bottom-sheet — full width, + rounded top corners. Same specificity as the rule above + later in source, + so it wins on mobile. */ +@media (max-width: 768px) { + body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content { + left: 0 !important; + right: 0 !important; + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh !important; + max-height: 100dvh !important; + border-radius: 14px 14px 0 0 !important; + border-top: 1px solid var(--border) !important; + } +} +/* Make the inner panes actually grow to fill the fullscreen container + instead of staying at their natural size. The body owns the remaining + height below the header; the admin-card + grid then expand into it. */ +#email-lib-modal.email-lib-fullscreen .modal-body { + flex: 1 1 auto !important; + min-height: 0 !important; + overflow: hidden !important; +} +#email-lib-modal.email-lib-fullscreen .modal-body > .admin-card { + flex: 1 1 auto !important; + min-height: 0 !important; +} +#email-lib-modal.email-lib-fullscreen .doclib-grid { + flex: 1 1 auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: auto !important; +} +@media (max-width: 768px) { + /* Mobile email modal sizing — keep in sync with the rule earlier in the + file. 75dvh tall always so flex children (the expanded email reader) + have a real height to grow into. */ + #email-lib-modal .modal-content { + max-height: 90dvh !important; + max-height: 90vh !important; + height: 90dvh !important; + height: 90vh !important; + } + /* Inner panes grow to fill the modal-content — without flex:1 on the + body, the expanded email reader sits in a tiny box because there's + nothing pushing it to take the remaining height. overflow:hidden + + min-height:0 lets each layer pass its constraints down. */ + #email-lib-modal .modal-body, + #email-lib-modal .admin-card { + flex: 1 1 auto !important; + min-height: 0 !important; + overflow: hidden !important; + max-height: none !important; + } + #email-lib-modal .doclib-grid { + flex: 1 1 auto !important; + min-height: 0 !important; + max-height: none !important; + overflow-y: auto !important; + } + /* modal-content keeps its scroll OFF here — the inner flex children + (doclib-grid for the list view, email-reader-body for the expanded + reader) own the scroll surfaces. Without this, double-scroll layouts + trap touches and the reader can't claim full height. */ + #email-lib-modal .modal-content { + overflow: hidden !important; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + touch-action: pan-y; + } + /* Attachment row on phones: cap to ~2 rows so it never dominates the + reader. If more chips exist, the row scrolls vertically. Smaller chip + padding + max-width keeps each chip compact so two rows usually fit + everything anyway. */ + #email-lib-modal .email-reader-atts { + padding: 4px 10px !important; + gap: 3px !important; + max-height: calc(2 * 22px + 12px); /* 2 rows × chip height + padding */ + overflow-y: auto; + align-content: flex-start; + } +#email-lib-modal .email-attachment-chip { + padding: 1px 6px !important; + font-size: 10px !important; + max-width: min(240px, calc(100vw - 44px)) !important; + line-height: 18px !important; + } +} + + +/* Mobile: only ONE scroll surface inside the cookbook modal. The + modal-content is the scroller. Everything inside (cookbook-body, + cookbook-group, all the inner lists/panels) gets overflow: visible + so touch-pan never gets trapped in a nested scroller. + Without this, three levels of overflow:auto + max-height combinations + produce dead-zone areas where swipes do nothing. */ +@media (max-width: 768px) { + #cookbook-modal .modal-content { + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + touch-action: pan-y; + } + #cookbook-modal .cookbook-body, + #cookbook-modal .cookbook-group, + #cookbook-modal .cookbook-section-body, + #cookbook-modal .hwfit-cached-list, + #cookbook-modal .doclib-grid, + #cookbook-modal .hwfit-list, + #cookbook-modal .hwfit-panel-cmd { + overflow: visible !important; + max-height: none !important; + min-height: 0 !important; + } + /* Running tmux output: cap how tall an expanded card gets (a long-lived job + can leave thousands of lines) so it doesn't balloon, but keep its own + scroll so you can read it. Outputs default collapsed on mobile now, so + you're usually looking at one expanded card at a time — no wall of nested + scrollers, and you collapse it to move past. */ + #cookbook-modal .cookbook-output-pre, + #cookbook-modal .cookbook-task .cookbook-output-pre { + max-height: 45vh !important; + min-height: 0 !important; + overflow-y: auto !important; + overflow-x: hidden !important; + overscroll-behavior: auto; /* chain to the modal at the scroll boundary */ + } + /* cookbook-body's flex:1 was needed when it owned scrolling — drop it + so the inner content drives modal-content's scroll height. */ + #cookbook-modal .cookbook-body { + flex: 0 0 auto !important; + height: auto !important; + } + /* Launch tab's cached-list normally has `flex: 1 1 0; min-height: 0` + (so it fills the modal on desktop). On mobile the parent now has + `height: auto`, which collapses `flex: 1 1 0` to ZERO PX — + models render but the list area is invisible because the flex + basis is 0 and there's no free space to grow into. Switch to + content-sized flex so the list grows with its children. */ + #cookbook-modal .cookbook-group[data-backend-group="Serve"] > .admin-card > .hwfit-cached-list, + #cookbook-modal .cookbook-group[data-backend-group="Serve"] > .admin-card > #hwfit-cached-list { + flex: 0 0 auto !important; + overflow: visible !important; + } +} +#cookbook-modal .hwfit-cached-list { + flex-shrink: 0; +} +.memory-toolbar { + transition: opacity 0.12s ease, max-height 0.2s ease; + max-height: 120px; +} +.doclib-card { + background: color-mix(in srgb, var(--fg) 3%, transparent); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s, opacity 0.3s, max-height 0.3s ease; + position: relative; +} +.doclib-card.doclib-card-deleting { + opacity: 0; + max-height: 0 !important; + overflow: hidden; + margin: 0; + padding: 0; + border-color: transparent; +} +.doclib-card.selected, +/* Universal selection highlight: any card-shaped row (documents, chats, + archive, research, skills, memories, tasks) gets the same accent outline + when its checkbox is checked. Done via :has() so the highlight follows + selection without per-renderer JS changes. The canonical checkbox class + is .memory-select-cb across every list. */ +.memory-item:has(.memory-select-cb:checked), +.doclib-card:has(.memory-select-cb:checked), +.doclib-chat-row:has(.memory-select-cb:checked) { + border-color: color-mix(in srgb, var(--red) 40%, transparent); + background: color-mix(in srgb, var(--red) 4%, transparent); + /* 2px accent outline overlaid on the 1px border — reads as a thicker + selected border without shifting layout (every card would otherwise + need a 2px transparent border to keep the same width). */ + outline: 2px solid var(--accent-primary, var(--red)); + outline-offset: -1px; +} +.doclib-card-header { + display: flex; + align-items: center; + padding: 7px 8px; + gap: 6px; + min-width: 0; +} +/* Mobile only: push the unexpanded email card's title-row text down 4px so + it sits visually centered with the surrounding card padding. Targets the + actual title-row class (.email-card-titlerow) — the earlier attempt used + .doclib-card-header which the email card builds differently. */ +@media (max-width: 768px) { + #email-lib-modal .doclib-card:not(.doclib-card-expanded) .email-card-titlerow { + margin-top: 4px; + } +} +.doclib-card-title { + font-size: 11px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} +.doclib-card-ver { + padding: 1px 6px; + border-radius: 8px; + background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent); + color: var(--accent, var(--red)); + font-size: 9px; + flex-shrink: 0; + font-weight: 600; + letter-spacing: 0.3px; + min-width: 24px; + text-align: left; + box-sizing: border-box; +} +.doclib-card-ver-muted { + background: color-mix(in srgb, var(--fg) 6%, transparent); + color: color-mix(in srgb, var(--fg) 35%, transparent); +} +.doclib-card-lang { + padding: 1px 6px; + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); + font-size: 9px; + text-align: left; + flex-shrink: 0; + min-width: 52px; + box-sizing: border-box; +} +.doclib-card-session { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 60px; + width: 100px; + max-width: 100px; + font-size: 9px; + color: var(--fg-muted); + flex-shrink: 0; + text-align: left; +} +.doclib-card-time { + flex-shrink: 0; + opacity: 0.5; + font-size: 9px; + color: var(--fg-muted); + min-width: 50px; + text-align: left; +} +/* Footer — used by archive cards */ +.doclib-card-footer { + display: contents; +} +/* Preview — hidden by default, shown on expand */ +.doclib-card-preview { + display: none; + position: relative; + padding: 8px 12px; + font-family: 'Berkeley Mono', 'SF Mono', 'Fira Code', monospace; + font-size: 9.5px; + line-height: 1.5; + color: var(--code-fg, var(--hl-fg, var(--fg))); + border-top: 1px solid color-mix(in srgb, var(--border) 30%, transparent); + margin: 0; +} +.doclib-card-preview pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; +} +.doclib-card-expanded .doclib-card-preview pre { + flex: 1 1 0; + overflow-y: auto; + min-height: 0; +} +.doclib-card-preview code.hljs { + background: none; + padding: 0; +} +/* Expanded-only action bar — inside preview. Buttons shifted up 4px by + trimming the row's top margin so they sit closer to the preview text. */ +.doclib-card-expanded-actions { + display: none; + align-items: flex-start; + gap: 6px; + padding: 8px 0 2px; + border-top: 1px solid color-mix(in srgb, var(--border) 30%, transparent); + margin-top: 4px; +} +.doclib-card-expanded-actions > .doclib-card-action-btn, +.doclib-card-expanded-actions .doclib-action-btn-row > .doclib-card-action-btn { + position: relative; + top: -4px; +} +.doclib-action-group { + display: flex; + flex-direction: column; + gap: 3px; +} +.doclib-action-btn-row { + display: flex; + gap: 6px; +} +.doclib-action-hint-row { + display: flex; + gap: 6px; +} +/* Match the chat/research footer buttons exactly: flat, bordered, + app font, accent on hover. */ +.doclib-card-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + box-sizing: border-box; + font-size: 10px; + padding: 3px 8px; + border-radius: 4px; + background: none; + border: 1px solid var(--border); + color: var(--fg-muted); + cursor: pointer; + /* These buttons live inside .doclib-card-preview, which forces a + monospace font. The chat/research footer buttons instead inherit the + modal font, which is the app font (Fira Code) on mobile and Inter on + desktop (.modal-content's Inter rule is inside @media min-width:769px). + Mirror that here so all three footers match in both contexts. */ + font-family: var(--font-family, 'Fira Code', monospace); + transition: border-color 0.15s, color 0.15s; +} +@media (min-width: 769px) { + .doclib-card-action-btn { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + letter-spacing: -0.015em; + } +} +.doclib-card-action-btn:hover { + border-color: var(--accent, var(--red)); + color: var(--accent, var(--red)); +} +.doclib-card-text-btn-danger { + color: var(--color-danger, #e06c75); + border-color: var(--color-danger, #e06c75); +} +.doclib-card-text-btn-danger:hover { + color: #ff4d4d; + border-color: #ff4d4d; +} +.doclib-card-expanded-actions > .doclib-card-action-btn { + font-size: 10px; + padding: 3px 8px; +} +/* Push the Open/Clone group to the right edge of the action bar — Delete + anchors the left, primary actions sit opposite. */ +.doclib-card-expanded-actions > .doclib-action-group { + margin-left: auto; +} +.doclib-card-expanded-actions > .doclib-expanded-delete-btn, +.doclib-card-expanded-actions > .doclib-expanded-archive-btn { + left: 0 !important; +} +.doclib-expanded-mobile-more { display: none; } +.doclib-btn-hint { + font-weight: normal; + opacity: 0.35; + font-size: 8px; + min-width: 58px; + padding: 0 8px; + box-sizing: border-box; +} +@media (max-width: 768px) { + .doclib-btn-hint { display: none !important; } +} + +/* Chat row expand preview — matches the documents-tab expand style. */ +.doclib-card-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + align-self: center; + flex-shrink: 0; + width: 14px; + height: 14px; + line-height: 0; + opacity: 0.3; + transform: rotate(0deg); + transition: transform 0.2s ease, opacity 0.15s; +} +.doclib-card-chevron svg { display: block; } +.doclib-card-expanded .doclib-card-chevron, +.task-card.expanded .doclib-card-chevron { + opacity: 0.8; + transform: rotate(180deg); +} +.doclib-card > .memory-item-actions { order: 1; } +.doclib-card > .doclib-card-chevron { order: 2; } +.doclib-chat-header .memory-item-actions, +.task-card .memory-item-actions { order: 2; } +.doclib-chat-header .doclib-card-chevron, +.task-card .doclib-card-chevron { order: 3; } +.hwfit-cached-list > .doclib-card > .memory-item-actions { order: 2; } +.hwfit-cached-list > .doclib-card > .doclib-card-chevron { order: 3; } +#email-lib-modal .doclib-card > .memory-item-actions { order: 1; align-self: center; } +#email-lib-modal .doclib-card > .doclib-card-chevron { display: none; } +.doclib-chat-row { flex-direction: column; align-items: stretch; } +.doclib-chat-row .doclib-card-chevron { + display: inline-flex; + opacity: 0.55; + transition: transform 0.2s ease, opacity 0.15s; + flex-shrink: 0; +} +.doclib-chat-row:not(.doclib-card-expanded) .doclib-card-chevron { + transform: rotate(0deg); +} +.doclib-chat-row.doclib-card-expanded .doclib-card-chevron { + opacity: 0.8; + transform: rotate(180deg); +} +/* Release .memory-item's base max-height: 200px when a chat row is + expanded. Without this, the row clips at 200px on desktop and the + preview's messages/actions visually spill past the row's box — + "colliding" with the next chat in the column. The mobile takeover + below already handles this via the same override; this rule is the + desktop-or-any-viewport equivalent. */ +.doclib-chat-row.doclib-card-expanded { + max-height: none; + overflow: visible; +} +/* When a chat row is expanded, take over the grid the same way documents + do: hide siblings, hide the admin-card toolbar, and let this row plus + its preview claim the full available height. .memory-item's default + max-height: 200px must be overridden or the preview will be tiny. */ +/* Mobile-only "card takeover" for an expanded chat — hides siblings, + hides the admin-card toolbar, and lets this row + its preview claim + the full available height. On desktop the expanded preview just + renders inline below the row with its messages constrained by + max-height + overflow:auto so the user gets a normal scroll. */ +@media (max-width: 820px) { + .doclib-grid:has(.doclib-chat-row.doclib-card-expanded) > .doclib-chat-row:not(.doclib-card-expanded) { + display: none; + } + .admin-card:has(.doclib-chat-row.doclib-card-expanded) > *:not(.doclib-grid) { + display: none; + } + .admin-card:has(.doclib-chat-row.doclib-card-expanded) > .doclib-grid { + flex: 1 1 auto !important; + max-height: none !important; + overflow-y: auto !important; + } + .doclib-chat-row.doclib-card-expanded { + flex: 1 1 auto !important; + max-height: none !important; + min-height: 0 !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + } + .doclib-chat-row.doclib-card-expanded .doclib-chat-header { + flex: 0 0 auto !important; + } + .doclib-chat-row.doclib-card-expanded .doclib-chat-preview { + flex: 1 1 auto !important; + min-height: 0 !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + padding-bottom: 4px !important; + } + .doclib-chat-row.doclib-card-expanded .doclib-chat-preview-messages { + -webkit-overflow-scrolling: touch; + max-height: none !important; + flex: 1 1 auto !important; + min-height: 0 !important; + } +} +.doclib-chat-preview { + font-size: 11px; + padding: 0 4px 6px; +} +.doclib-chat-preview .doclib-chat-open-btn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + padding: 3px 8px; + border-radius: 4px; + color: var(--fg-muted); + border: 1px solid var(--border); + background: none; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, color 0.15s; +} +.doclib-chat-preview .task-completed-report-btn { + color: var(--accent, var(--red)); + border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); +} +.doclib-chat-preview .doclib-chat-open-btn:hover { + border-color: var(--accent, var(--red)); + color: var(--accent, var(--red)); +} +.doclib-chat-preview-messages { + margin-top: 6px; + max-height: 320px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding-right: 4px; +} +/* Desktop expanded state — match the documents-tab behavior: hide + sibling chats, hide the admin-card toolbar/header, and let the + expanded row + its messages preview claim the full modal height. + Chat rows use `memory-item doclib-chat-row` (no `doclib-card` class) + so the document-tab's global rules at line 12284-12304 skip them — + these mirror those rules scoped to chat rows. The mobile media query + above (820px) keeps the same takeover behavior for phones. */ +@media (min-width: 821px) { + .doclib-grid:has(.doclib-chat-row.doclib-card-expanded) > .doclib-chat-row:not(.doclib-card-expanded) { + display: none; + } + .admin-card:has(.doclib-chat-row.doclib-card-expanded) > *:not(.doclib-grid) { + display: none; + } + .admin-card:has(.doclib-chat-row.doclib-card-expanded) > .doclib-grid { + flex: 1 1 auto !important; + max-height: none !important; + overflow: hidden auto !important; + } + .doclib-chat-row.doclib-card-expanded { + flex: 1 1 auto !important; + max-height: none !important; + min-height: 0 !important; + display: flex !important; + flex-direction: column !important; + } + .doclib-chat-row.doclib-card-expanded .doclib-chat-preview { + flex: 1 1 auto !important; + min-height: 0 !important; + display: flex !important; + flex-direction: column !important; + } + .doclib-chat-row.doclib-card-expanded .doclib-chat-preview-messages { + flex: 1 1 auto !important; + min-height: 0 !important; + max-height: none !important; + overflow-y: auto !important; + } +} +.doclib-chat-preview-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 2px 0 2px; + border-top: 1px solid color-mix(in srgb, var(--border) 30%, transparent); + margin-top: 2px; + flex-shrink: 0; + /* Whole action footer nudged down 5px. */ + position: relative; + top: 5px; +} +.doclib-chat-delete-btn, +.doclib-chat-archive-btn, +.doclib-chat-restore-btn, +.doclib-chat-discuss-btn, +.doclib-chat-copy-btn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + padding: 3px 8px; + border-radius: 4px; + background: none; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, color 0.15s; +} +.doclib-chat-archive-btn, +.doclib-chat-restore-btn, +.doclib-chat-discuss-btn, +.doclib-chat-copy-btn { + color: var(--fg-muted); + border: 1px solid var(--border); +} +.doclib-chat-restore-btn:hover { + color: var(--accent, var(--red)); + border-color: var(--accent, var(--red)); +} +.doclib-chat-archive-btn:hover, +.doclib-chat-discuss-btn:hover, +.doclib-chat-copy-btn:hover { + color: var(--accent, var(--red)); + border-color: var(--accent, var(--red)); +} +.doclib-chat-delete-btn { + color: var(--color-danger, #e06c75); + border: 1px solid var(--color-danger, #e06c75); +} +.doclib-chat-delete-btn:hover { + color: #ff4d4d; + border-color: #ff4d4d; +} +/* When a chat is archived there's no Archive button, so Delete becomes + the only left-side action and needs the auto margin to push Open right. */ +.doclib-chat-preview-actions:not(:has(.doclib-chat-archive-btn)) > .doclib-chat-delete-btn { + margin-right: auto; +} +/* Hide the "..." menu while the chat card is expanded — archive + + delete live in the preview footer instead. */ +.doclib-chat-row.doclib-card-expanded ._chat-menu { display: none; } +.doclib-chat-preview-actions.doclib-chat-preview-actions-top { + border-top: none; + border-bottom: 1px solid color-mix(in srgb, var(--border) 30%, transparent); + margin-top: 4px; + margin-bottom: 6px; + padding: 0 0 6px; + justify-content: flex-start; +} +.doclib-chat-preview-messages .doclib-chat-msg { + margin: 4px 0 10px; + padding-left: 8px; + border-left: 2px solid color-mix(in srgb, var(--border) 70%, transparent); +} +.doclib-chat-preview-messages .doclib-chat-msg.user { + border-left-color: color-mix(in srgb, var(--accent, var(--red)) 60%, transparent); +} +.doclib-chat-preview-messages .doclib-chat-msg-role { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.55; + margin-bottom: 2px; +} +.doclib-chat-preview-messages .doclib-chat-msg.user .doclib-chat-msg-role { + color: var(--accent, var(--red)); + opacity: 0.85; +} +.doclib-chat-preview-messages .doclib-chat-msg-body { + white-space: pre-wrap; + word-break: break-word; + opacity: 0.85; + line-height: 1.45; +} +/* Chat-bubble preview — mirrors the real chat layout. User bubbles hug + right with an accent tint; assistant bubbles hug left with a neutral + panel tint and a small model tag at the top. */ +.doclib-chat-bubble-row { + display: flex; + margin: 6px 0; +} +.doclib-chat-bubble-row.user { justify-content: flex-end; } +.doclib-chat-bubble-row.assistant { justify-content: flex-start; } +.doclib-chat-bubble { + max-width: 85%; + padding: 6px 10px 8px; + border-radius: 14px; + font-size: 11px; + line-height: 1.45; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--fg) 4%, transparent); +} +.doclib-chat-bubble-row.user .doclib-chat-bubble { + background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + border-color: color-mix(in srgb, var(--accent, var(--red)) 30%, transparent); + border-bottom-right-radius: 4px; +} +.doclib-chat-bubble-row.assistant .doclib-chat-bubble { + border-bottom-left-radius: 4px; +} +.doclib-chat-msg-model { + display: inline-block; + font-size: 8px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.55; + margin-bottom: 3px; + padding: 1px 6px; + border-radius: 6px; + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--fg-muted, var(--fg)); +} +.doclib-chat-bubble-body { word-break: break-word; } +.doclib-chat-bubble-body p { margin: 4px 0; } +.doclib-chat-bubble-body p:first-child { margin-top: 0; } +.doclib-chat-bubble-body p:last-child { margin-bottom: 0; } +.doclib-chat-bubble-body pre { + font-size: 10px; + margin: 4px 0; + padding: 6px 8px; + background: color-mix(in srgb, var(--bg) 60%, transparent); + border-radius: 6px; + overflow-x: auto; +} +.doclib-chat-bubble-body code { + font-size: 10px; + padding: 1px 4px; + border-radius: 3px; + background: color-mix(in srgb, var(--fg) 8%, transparent); +} +.doclib-chat-bubble-body pre code { background: none; padding: 0; } +.doclib-chat-bubble-body ul, +.doclib-chat-bubble-body ol { + margin: 4px 0; + padding-left: 18px; +} +.doclib-card-collapse-chevron { + display: none; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--color-muted, var(--fg)); + font: inherit; + opacity: 0.3; + cursor: pointer; + flex-shrink: 0; + margin: 0 0 0 4px; + transition: opacity 0.15s, background 0.15s, border-color 0.15s; +} +.doclib-card-collapse-chevron:hover { + opacity: 1; + background: color-mix(in srgb, var(--fg) 8%, transparent); + border-color: color-mix(in srgb, var(--fg) 24%, transparent); +} +.cookbook-serve-title { + display: flex; + align-items: center; + min-width: 0; +} +.cookbook-serve-title .doclib-card-collapse-chevron { + display: inline-flex; + margin-left: auto; + margin-top: 0; +} +.doclib-card-expanded .doclib-card-collapse-chevron { + display: inline-flex; +} +/* Expanded card — fills the whole grid */ +.doclib-card.doclib-card-expanded { + flex: 1; + min-height: 0; + background: color-mix(in srgb, var(--fg) 3%, transparent); + border: 1px solid var(--border); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + animation: doclib-expand 0.2s ease-out; +} +.doclib-card.doclib-card-expanded { + flex-direction: column !important; + align-items: stretch !important; + max-height: none !important; + gap: 4px !important; +} +.doclib-card.doclib-card-expanded > .memory-item-actions { display: none; } +/* Non-preview children of an expanded doc card carry an inline flex:1 from + the row layout. With the card now flex-column those children would steal + half the height and shrink the preview — pin them at auto height. */ +.doclib-card.doclib-card-expanded > div:not(.doclib-card-preview):not(.doclib-card-header):not(.memory-item-actions):not(.email-card-reader) { + flex: 0 0 auto !important; +} +/* The email reader IS the scroll container — keep its flex:1 so its + internal body can claim the rest of the card's height and scroll. */ +.doclib-card.doclib-card-expanded > .email-card-reader { + flex: 1 1 auto !important; + min-height: 0 !important; +} +.doclib-card.doclib-card-expanded .doclib-card-preview { + display: flex; + flex-direction: column; + flex: 1 1 auto !important; + /* The preview matches chat rows: document content scrolls, while the + footer stays in the card rather than becoming the last scroll item. */ + overflow: hidden; + min-height: 0; +} +.doclib-card.doclib-card-expanded .doclib-card-expanded-actions { + display: flex; + flex-shrink: 0; +} +/* Document cards can be expanded in the normal windowed library, where the + grid has no definite height. A zero-basis
 then collapses to nothing
+   while the footer remains visible. Keep the document text's intrinsic height
+   in that layout; bounded/fullscreen layouts can still shrink it and scroll. */
+#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview > pre {
+  flex: 1 1 auto;
+  min-height: 1.5em;
+}
+
+/* Collapsible skills section headers (Your skills / Built-in). */
+.skills-section-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  cursor: pointer;
+  user-select: none;
+  padding: 6px 4px 4px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  opacity: 0.6;
+  font-weight: 600;
+}
+.skills-section-header:hover { opacity: 0.9; }
+.skills-section-chevron { transition: transform 0.15s ease; flex-shrink: 0; }
+.skills-section-header.collapsed .skills-section-chevron { transform: rotate(-90deg); }
+.skills-section-count {
+  margin-left: auto;
+  opacity: 0.6;
+  font-variant-numeric: tabular-nums;
+  font-weight: normal;
+}
+.skill-card-section-hidden { display: none !important; }
+/* Warning banner shown when previewing/editing a built-in capability. */
+.skill-builtin-warn {
+  font-size: 9.5px;
+  line-height: 1.45;
+  color: var(--color-warning, #f0ad4e);
+  background: color-mix(in srgb, var(--color-warning, #f0ad4e) 12%, transparent);
+  border: 1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 35%, transparent);
+  border-radius: 6px;
+  padding: 6px 9px;
+  margin: 0 0 8px;
+  flex-shrink: 0;
+}
+
+/* #skills-list wears both .memory-list and .doclib-grid. doclib-grid's
+   max-height:400px would cap the list AND clamp an expanded card — keep
+   the memory-list fill-the-modal behaviour instead. */
+#skills-list.doclib-grid {
+  max-height: none;
+  flex: 1;
+  min-height: 0;
+}
+#skills-list.doclib-grid:has(.doclib-card-expanded) {
+  overflow: hidden;  /* expanded card owns its own scroll */
+}
+/* When a skill is expanded, hide the sibling Add-Skill form card so the
+   expanded SKILL.md uses the full panel. Otherwise the two admin-cards
+   split the height ~50/50 and the expanded skill is stuck in its half
+   while the Add form idles in the other. */
+.memory-tab-panel[data-memory-panel="skills"]:has(.doclib-card-expanded) > .admin-card:nth-of-type(2) {
+  display: none !important;
+}
+
+/* Skills cards reuse the doclib expand/footer machinery. The SKILL.md
+   preview + the edit textarea need to fill the expanded card and own
+   their own scroll, same as a document preview. */
+/* Collapsed skill rows used a smaller (0.9em code) title, so the click row
+   read slimmer than document/chat/library cards. Give the header a min-height
+   so the tap target matches the other library items. */
+.skill-card > .skill-card-header {
+  min-height: 53px;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+/* Skill rows share Tasks' card hover colors. Draft state belongs in the
+   status badge, not in a second opacity layer across the whole row. */
+/* Name + description column. Name wraps to 2 lines (skill slugs are long —
+   use the space rather than truncating to "check-model-downl…"). */
+.skill-card-textcol {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+}
+.skill-card-title-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+.skill-card-name {
+  font-family: inherit;
+  font-weight: 500;
+  font-size: 12px;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  word-break: normal;
+  line-height: normal;
+}
+.skill-card-title-row .skill-card-name { min-width: 0; }
+.skill-status-published {
+  width: 20px;
+  height: 20px;
+  padding: 0;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--color-success, #4ade80);
+  background: color-mix(in srgb, var(--color-success, #4ade80) 15%, transparent);
+  border-color: color-mix(in srgb, var(--color-success, #4ade80) 32%, transparent);
+  border-radius: 50%;
+}
+.skill-stats .skill-status-published {
+  width: 15px;
+  height: 15px;
+  margin-left: 3px;
+  vertical-align: middle;
+}
+.skill-stats .skill-status-published svg {
+  width: 9px;
+  height: 9px;
+}
+.skill-card-desc {
+  font-size: 10px;
+  opacity: 0.4;
+  margin-top: -1px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.skills-audit-model-field {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 10px;
+  font-size: 11px;
+}
+.skills-audit-model-field > span {
+  flex: 0 0 auto;
+  color: color-mix(in srgb, var(--fg) 68%, transparent);
+}
+.skills-audit-model-field select {
+  min-width: 0;
+  flex: 1;
+  height: 26px;
+  padding: 0 6px;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  background: var(--bg);
+  color: var(--fg);
+  font: inherit;
+}
+.skill-card-tags {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+  margin-top: 4px;
+}
+.skill-tag-pill {
+  padding: 1px 5px;
+  font-size: 9px;
+}
+.skill-usage-pill {
+  margin-left: auto;
+  color: color-mix(in srgb, var(--fg) 62%, transparent);
+}
+.skill-tag-filter-chip { cursor: pointer; }
+
+@media (max-width: 768px) {
+  .skill-card > .skill-card-header {
+    align-items: flex-start;
+  }
+  .skill-card-textcol {
+    min-width: 0;
+  }
+  .skill-card-tags {
+    max-width: 100%;
+    overflow: hidden;
+  }
+  .skill-card-tags .skill-tag-pill {
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+/* Right cluster — pills (right-aligned), stats, then the menu/chevron. */
+.skill-card-right {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-shrink: 0;
+  margin-left: auto;
+}
+.skill-card-right .memory-cat-badge { flex-shrink: 0; }
+.skill-status-bin {
+  background: color-mix(in srgb, var(--color-danger, #d9534f) 20%, transparent);
+  color: var(--color-danger, #d9534f);
+}
+.skill-model-pill {
+  max-width: 150px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.skill-model-student {
+  background: color-mix(in srgb, var(--fg) 10%, transparent);
+}
+.skill-audit-pass {
+  color: var(--color-success, #4ade80);
+  background: color-mix(in srgb, var(--color-success, #4ade80) 16%, transparent);
+}
+.skill-audit-needs_work {
+  color: var(--color-warning, #f0ad4e);
+  background: color-mix(in srgb, var(--color-warning, #f0ad4e) 16%, transparent);
+}
+.skill-audit-fail {
+  color: var(--color-danger, #e06c75);
+  background: color-mix(in srgb, var(--color-danger, #e06c75) 16%, transparent);
+}
+.skill-audit-inconclusive,
+.skill-audit-skipped,
+.skill-audit-unknown {
+  color: color-mix(in srgb, var(--fg) 64%, transparent);
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+}
+.skill-model-teacher {
+  background: color-mix(in srgb, var(--color-warning, #f0ad4e) 24%, transparent);
+  color: var(--color-warning, #f0ad4e);
+}
+.skill-necessity-pill {
+  max-width: 150px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.skill-necessity-duplicate {
+  background: color-mix(in srgb, var(--color-warning, #f0ad4e) 22%, transparent);
+  color: var(--color-warning, #f0ad4e);
+}
+.skill-necessity-trivial {
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+  color: color-mix(in srgb, var(--fg) 62%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 16%, transparent);
+}
+.skill-necessity-irrelevant {
+  background: color-mix(in srgb, var(--color-danger, #e06c75) 18%, transparent);
+  color: var(--color-danger, #e06c75);
+}
+.skills-summary-strip {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: nowrap;
+  overflow-x: auto;
+  overflow-y: hidden;
+  overscroll-behavior-x: contain;
+  touch-action: pan-x;
+  -webkit-overflow-scrolling: touch;
+  scrollbar-width: none;
+  margin: -3px 0 8px;
+}
+.skills-summary-strip::-webkit-scrollbar { display: none; }
+.skills-summary-strip:empty {
+  display: none;
+}
+.skills-summary-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  height: 23px;
+  padding: 0 8px;
+  border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent);
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--fg) 4%, transparent);
+  color: color-mix(in srgb, var(--fg) 70%, transparent);
+  font-size: 10px;
+  line-height: 1;
+  cursor: pointer;
+  flex: 0 0 auto;
+}
+.skills-summary-chip:hover {
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 18%, transparent);
+}
+.skills-summary-chip.active {
+  background: color-mix(in srgb, var(--accent, var(--red)) 13%, transparent);
+  border-color: color-mix(in srgb, var(--accent, var(--red)) 28%, transparent);
+  color: var(--fg);
+}
+.skills-summary-chip strong {
+  font-family: ui-monospace, 'SF Mono', 'Fira Code', monospace;
+  font-size: 10px;
+  color: var(--fg);
+}
+
+/* Shared filter/tag strip contract. These rows all sit immediately below
+   their screen's search or primary toolbar and stay one line at every width. */
+:is(
+  .skills-summary-strip,
+  #memory-category-filters,
+  .memory-category-filters:has(> .memory-cat-chip),
+  .doclib-lang-chips,
+  .doclib-chips,
+  .notes-labels-bar,
+  .tasks-activity-filters,
+  .gallery-tag-chips,
+  .gallery-album-chips,
+  .gallery-ai-tags,
+  .cal-filters
+) {
+  align-items: center;
+  flex-wrap: nowrap !important;
+  gap: 6px;
+  width: 100%;
+  max-width: 100%;
+  min-width: 0;
+  max-height: none;
+  overflow-x: auto !important;
+  overflow-y: hidden !important;
+  overscroll-behavior-x: contain;
+  touch-action: pan-x;
+  -webkit-overflow-scrolling: touch;
+  scrollbar-width: none;
+  box-sizing: border-box;
+  padding: 0;
+  margin: -3px 0 8px;
+}
+:is(
+  .skills-summary-strip,
+  #memory-category-filters,
+  .memory-category-filters:has(> .memory-cat-chip),
+  .doclib-lang-chips,
+  .doclib-chips,
+  .notes-labels-bar,
+  .tasks-activity-filters,
+  .gallery-tag-chips,
+  .gallery-album-chips,
+  .gallery-ai-tags,
+  .cal-filters
+)::-webkit-scrollbar { display: none; }
+:is(
+  .skills-summary-strip > .skills-summary-chip,
+  #memory-category-filters > .skills-summary-chip,
+  .memory-category-filters > .memory-cat-chip,
+  .doclib-lang-chips > :is(.memory-cat-chip, .skills-summary-chip),
+  .doclib-chips > .doclib-chip,
+  .notes-labels-bar > .notes-label-chip,
+  .tasks-activity-filters > .memory-cat-chip,
+  .gallery-tag-chips > .gallery-chip,
+  .gallery-album-chips > .gallery-chip,
+  .gallery-ai-tags > .gallery-ai-chip,
+  .cal-filters > .cal-filter-item
+) {
+  flex: 0 0 auto;
+  box-sizing: border-box;
+  height: 23px !important;
+  min-height: 23px;
+  padding: 0 8px !important;
+  border-radius: 6px !important;
+  font-size: 10px !important;
+  line-height: 1;
+  white-space: nowrap;
+}
+.doclib-lang-chips {
+  flex: 0 0 auto;
+  min-height: 25px;
+  padding: 1px 0 !important;
+  scroll-padding-inline: 1px;
+}
+.doclib-chip-scroll-frame {
+  position: relative;
+  display: flex;
+  align-items: center;
+  flex: 1 1 100%;
+  min-width: 0;
+  max-width: 100%;
+}
+.doclib-chip-scroll-frame > .chip-scroll-strip {
+  flex: 1 1 auto;
+  min-width: 0;
+  margin-left: 0;
+  margin-right: 0;
+}
+.doclib-chip-scroll-arrow {
+  display: none;
+  position: absolute;
+  top: calc(50% - 1px) !important;
+  transform: translateY(-50%);
+  margin-top: 0;
+  z-index: 2;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 20px;
+  padding: 0;
+  border: 1px solid color-mix(in srgb, var(--fg) 14%, transparent);
+  border-radius: 50%;
+  box-sizing: border-box;
+  background: color-mix(in srgb, var(--bg) 86%, var(--fg) 14%);
+  color: var(--accent, var(--red));
+  box-shadow: 0 6px 18px rgba(0,0,0,0.16);
+  font-size: 18px;
+  line-height: 18px;
+  cursor: pointer;
+  opacity: 0.84;
+  transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;
+}
+.doclib-chip-scroll-arrow-glyph {
+  display: inline-block;
+  position: relative;
+  top: -1px;
+  line-height: 1;
+}
+.doclib-chip-scroll-arrow:hover {
+  opacity: 1;
+  background: color-mix(in srgb, var(--bg) 76%, var(--fg) 24%);
+}
+@media (max-width: 768px) {
+  .doclib-chip-scroll-arrow { display: none !important; }
+}
+.doclib-chip-scroll-frame.has-overflow > .doclib-chip-scroll-arrow:not([hidden]) {
+  display: inline-flex;
+}
+.doclib-chip-scroll-arrow.left { left: 0; }
+.doclib-chip-scroll-arrow.right { right: 0; }
+.doclib-chip-scroll-frame.has-overflow > .chip-scroll-strip {
+  padding-left: 0 !important;
+  padding-right: 22px !important;
+}
+.doclib-chip-scroll-frame > .chip-scroll-strip.is-pointer-dragging {
+  cursor: grabbing;
+  user-select: none;
+}
+.doclib-chip-scroll-frame > .chip-scroll-strip:not(.is-pointer-dragging) {
+  cursor: grab;
+}
+/* Calendar tags are wrapped by chipScroll.js after render. Keep that wrapper
+   content-sized inside the calendar's column layout; the default flex growth
+   otherwise turns the one-line tag strip into a large empty panel. */
+#cal-body > .doclib-chip-scroll-frame:has(> .cal-filters) {
+  flex: 0 0 auto;
+  min-height: 0;
+}
+/* The completed-task filters are a compact toolbar, not a flexible panel.
+   Without this override the shared chip frame grows into the available
+   height of the card, which is especially visible the first time the mobile
+   view switches to Completed. */
+.tasks-completed-card > .doclib-chip-scroll-frame:has(> #tasks-completed-status-chips) {
+  flex: 0 0 auto;
+  min-height: 0;
+}
+/* The active Tasks filter row is also a compact toolbar.  Keep its wrapper
+   content-sized so changing a filter cannot make the mobile panel grow a
+   large empty gap between the chips and the task cards. */
+.admin-card > .doclib-chip-scroll-frame:has(> #tasks-filter-chips) {
+  flex: 0 0 auto;
+  min-height: 0;
+}
+#memory-category-filters {
+  flex: 0 0 auto;
+  min-height: 25px;
+  padding: 1px 0 !important;
+  scroll-padding-inline: 1px;
+}
+.cookbook-serve-tag-label {
+  display: inline-flex;
+  align-items: center;
+  min-width: 0;
+}
+#serve-search,
+#hwfit-cache-select,
+#serve-tags {
+  position: relative;
+  top: -2px;
+}
+#hwfit-cache-select {
+  top: -4px;
+}
+/* Calendar previously framed its tags as a separate card. It now follows the
+   same unframed placement as the other filter strips. */
+.cal-filters {
+  border: 0;
+  background: none;
+  box-shadow: none;
+}
+.skill-duplicate-keep {
+  background: color-mix(in srgb, var(--color-success, #4ade80) 22%, transparent);
+  color: var(--color-success, #4ade80);
+}
+.skill-duplicate-group {
+  font-family: ui-monospace, 'SF Mono', 'Fira Code', monospace;
+  color: color-mix(in srgb, var(--fg) 62%, transparent);
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+}
+.skill-duplicate-lower {
+  background: color-mix(in srgb, var(--color-danger, #e06c75) 14%, transparent);
+  color: var(--color-danger, #e06c75);
+}
+.skill-stats {
+  font-size: 9px;
+  font-family: monospace;
+  white-space: nowrap;
+  color: color-mix(in srgb, var(--fg) 45%, transparent);
+}
+.skill-turns-saved {
+  font-size: 9px;
+  opacity: 0.82;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+  flex: 0 0 auto;
+}
+.skill-expanded-savings {
+  display: flex;
+  align-items: center;
+  min-height: 18px;
+  margin: 0 0 5px;
+}
+.skill-turns-positive {
+  color: color-mix(in srgb, var(--color-success, #4ade80) 82%, var(--fg));
+}
+.skill-turns-zero,
+.skill-turns-unknown {
+  color: color-mix(in srgb, var(--fg) 48%, transparent);
+}
+.skill-turns-negative {
+  color: color-mix(in srgb, var(--color-danger, #e06c75) 80%, var(--fg));
+}
+.skill-conf {
+  display: inline-block;
+  min-width: 4ch;
+  text-align: right;
+  font-weight: 700;
+  color: var(--accent, var(--red));
+}
+.skill-verified,
+.skill-teachermark,
+.skill-needsmark {
+  display: inline-flex;
+  align-items: center;
+  vertical-align: middle;
+  margin-right: 3px;
+}
+.skill-verified { color: var(--accent, #4ade80); }
+.skill-teachermark { color: var(--color-warning, #f0ad4e); }
+.skill-needsmark { color: var(--color-danger, #e06c75); cursor: help; }
+
+/* The card currently being processed by an "Audit now" run glows + pulses so
+   it's obvious which one the audit is on. */
+@keyframes skill-audit-pulse {
+  0%, 100% { box-shadow: 0 0 6px 0 color-mix(in srgb, var(--accent, #4ade80) 55%, transparent); }
+  50%      { box-shadow: 0 0 16px 3px color-mix(in srgb, var(--accent, #4ade80) 85%, transparent); }
+}
+.skill-card.skill-audit-active {
+  border-color: var(--accent, #4ade80) !important;
+  animation: skill-audit-pulse 1.3s ease-in-out infinite;
+}
+
+/* A test is running for this skill — the app's whirlpool spinner is injected
+   next to the name from JS (see _setCardRunning) so the collapsed/folded card
+   still makes it obvious work is in progress. */
+/* Collapsed bar shows the kebab menu; expanded shows an up-chevron to
+   collapse. (Toggled by the .doclib-card-expanded class.) */
+.skill-kebab-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: none;
+  border: none;
+  padding: 4px;
+  margin: 0;
+  cursor: pointer;
+  color: var(--fg);
+  opacity: 0.5;
+  border-radius: 5px;
+  flex-shrink: 0;
+  position: relative;
+  top: -2px;
+}
+.skill-kebab-btn:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 10%, transparent); }
+.skill-chevron-up { display: none; align-items: center; opacity: 0.5; flex-shrink: 0; }
+/* Kebab dropdown */
+.skill-kebab-menu {
+  position: fixed;
+  /* z-index is set inline via topPortalZ() at open time (#4720); a static
+     value here loses once the modal bring-to-front counter climbs past it. */
+  min-width: auto;
+  width: max-content;
+  padding: 4px;
+  background: var(--panel, var(--bg));
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  box-shadow: 0 6px 20px rgba(0,0,0,0.22);
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+}
+.skill-kebab-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  padding: 6px 8px;
+  background: none;
+  border: none;
+  border-radius: 8px;
+  cursor: pointer;
+  color: var(--fg);
+  font-size: 11px;
+  text-align: left;
+}
+.skill-kebab-item:hover { background: color-mix(in srgb, var(--fg) 10%, transparent); }
+.skill-kebab-item.danger { color: var(--color-error, #e55); }
+.skill-kebab-item.danger:hover { background: color-mix(in srgb, var(--color-error, #e55) 15%, transparent); }
+/* Section labels separating "Your skills" from the read-only "Built-in
+   capabilities" list. */
+.skills-section-label {
+  font-size: 10px;
+  opacity: 0.5;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  margin: 10px 2px 4px;
+  flex-shrink: 0;
+}
+.skills-section-label:first-child { margin-top: 0; }
+/* When a learned skill is expanded the grid hides sibling cards; hide the
+   section labels too so they don't orphan above the collapsed list. */
+#skills-list:has(.doclib-card-expanded) .skills-section-label { display: none; }
+/* Built-in cards are read-only — no expand/hover-pointer affordance. */
+.skill-builtin-card { cursor: default; }
+.skill-builtin-card:hover { border-color: var(--border); }
+.skill-card-preview .skill-md-pre {
+  font-family: ui-monospace, 'SF Mono', 'Fira Code', monospace;
+  font-size: 11.5px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+.skill-preview-copy {
+  position: absolute;
+  top: 6px;
+  right: 8px;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  opacity: 0;
+  color: var(--fg);
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: opacity .15s, color .15s, border-color .15s;
+}
+.skill-card-preview:hover .skill-preview-copy { opacity: 0.7; }
+.skill-preview-copy:hover,
+.skill-preview-copy:focus-visible { opacity: 1 !important; }
+.skill-preview-copy.copied {
+  opacity: 1;
+  color: var(--color-save-green, #4caf50);
+  border-color: var(--color-save-green, #4caf50);
+}
+/* :has()-free expand layout. skills.js adds .skills-has-expanded to the
+   skills admin-card when a skill opens, so this works on engines that don't
+   support :has() (e.g. older Firefox mobile, where the expand stuck at
+   ~50%). Hide everything but the list so the grid fills the card; the
+   absolutely-positioned expanded card (mobile) then fills the grid. */
+.admin-card.skills-has-expanded > *:not(#skills-list) {
+  display: none !important;
+}
+.admin-card.skills-has-expanded > #skills-list.doclib-grid {
+  flex: 1 1 auto !important;
+  height: 100% !important;
+  min-height: 0 !important;
+  overflow: hidden !important;
+}
+/* Expand animation — a clean opacity fade. The mobile card snaps to a
+   full-screen overlay (position:absolute), so any translate/scale on it
+   reads as a jarring "explosion"; a pure fade just appears smoothly. (No
+   transform also keeps skills.js's height measurement accurate.) */
+@keyframes skill-card-expand {
+  from { opacity: 0; }
+  to   { opacity: 1; }
+}
+.skill-card.doclib-card-expanded {
+  animation: skill-card-expand 0.18s ease-out;
+}
+/* Switching directly from one expanded card to another: no fade (it would
+   show the previous card collapsing through the semi-transparent new one). */
+.skill-card.doclib-card-expanded.skill-expand-instant {
+  animation: none !important;
+}
+@media (prefers-reduced-motion: reduce) {
+  .skill-card.doclib-card-expanded,
+  .skill-card.doclib-card-expanded .doclib-card-preview {
+    animation: none !important;
+  }
+}
+/* The preview WRAPPER inherits flex:1 (grow) from the generic doclib expand
+   rule — that's what stretched the card and left a void / pushed the footer
+   out. Size it to content (shrink + scroll only when long) so the footer
+   sits right under the preview and stays fully visible. */
+.skill-card.doclib-card-expanded .doclib-card-preview {
+  flex: 0 1 auto !important;
+  min-height: 0;
+  /* The footer lives INSIDE this wrapper. Don't let the wrapper scroll, or
+     the footer scrolls off with the content. Keep it clipped; the 
+     inside is the scroller, so the footer stays pinned at the bottom. */
+  overflow: hidden !important;
+}
+.skill-card.doclib-card-expanded .skill-md-pre {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow-y: auto;
+}
+/* Skill footer buttons sit 4px high (shared rule sets top:-4px) — drop them
+   back down 4px so they're vertically centered in the skill card footer. */
+.skill-card .doclib-card-expanded-actions > .doclib-card-action-btn,
+.skill-card .doclib-card-expanded-actions .doclib-action-btn-row > .doclib-card-action-btn {
+  top: 0;
+}
+/* Skills select-mode bulk bar — nudge Cancel / Approve / Delete up 2px. */
+#skills-bulk-bar .memory-toolbar-btn {
+  position: relative;
+  top: -2px;
+}
+/* ── Audit-all progress panel ── */
+.skills-audit-panel {
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 8px 10px;
+  margin: 6px 0;
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+}
+.skills-audit-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.skills-audit-title { font-size: 12px; font-weight: 600; }
+.skills-audit-bar { height: 4px; border-radius: 2px; background: color-mix(in srgb, var(--fg) 12%, transparent); margin: 6px 0; overflow: hidden; }
+.skills-audit-fill { height: 100%; background: var(--accent, var(--red)); transition: width 0.3s ease; }
+.skills-audit-summary { font-size: 11px; opacity: 0.7; margin-bottom: 4px; }
+.skills-audit-log {
+  max-height: 22vh; overflow-y: auto;
+  font-family: ui-monospace, 'SF Mono', 'Fira Code', monospace;
+  font-size: 10.5px; line-height: 1.5; opacity: 0.8;
+}
+/* ── Skill test monitor + AI eval verdict ── */
+.skill-test { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
+.skill-test-log {
+  flex: 1 1 auto;
+  min-height: 120px;
+  max-height: 48vh;
+  overflow-y: auto;
+  font-family: ui-monospace, 'SF Mono', 'Fira Code', monospace;
+  font-size: 9.5px;
+  line-height: 1.45;
+  padding: 8px 10px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: var(--bg);
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+.skill-test-log > div { margin: 1px 0; }
+.skill-test-meta  { opacity: 0.5; }
+.skill-test-task  { color: var(--accent, var(--red)); font-weight: 500; font-size: 9px; opacity: 0.85; }
+.skill-test-round { opacity: 0.6; margin-top: 6px !important; }
+.skill-test-tool  { color: var(--accent, var(--red)); opacity: 0.85; }
+.skill-test-out   { opacity: 0.7; padding-left: 10px; }
+.skill-test-say   { white-space: pre-wrap; }
+.skill-test-err   { color: var(--color-danger, #e06c75); }
+.skill-eval-head  { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.skill-eval-badge {
+  font-size: 10px; font-weight: 700; letter-spacing: 0.03em;
+  padding: 2px 8px; border-radius: 10px; flex-shrink: 0;
+}
+.skill-eval-ok   { background: color-mix(in srgb, var(--color-success, #4ade80) 26%, transparent); color: var(--color-success, #4ade80); }
+.skill-eval-warn { background: color-mix(in srgb, var(--color-warning, #f0ad4e) 26%, transparent); color: var(--color-warning, #f0ad4e); }
+.skill-eval-bad  { background: color-mix(in srgb, var(--color-danger, #e06c75) 26%, transparent); color: var(--color-danger, #e06c75); }
+.skill-eval-unknown { background: color-mix(in srgb, var(--fg) 14%, transparent); }
+.skill-eval-summary { font-size: 11px; opacity: 0.85; }
+.skill-eval-issues { margin: 6px 0 0; padding-left: 18px; font-size: 11px; opacity: 0.8; }
+.skill-eval-issues li { margin: 2px 0; }
+.skill-eval-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; }
+.skill-eval-approve.suggested {
+  border-color: var(--color-success, #4ade80) !important;
+  color: var(--color-success, #4ade80) !important;
+}
+/* Already published — the button confirms the state (click to unpublish). */
+.skill-eval-approve.is-approved {
+  border-color: var(--color-success, #4ade80) !important;
+  color: var(--color-success, #4ade80) !important;
+  background: color-mix(in srgb, var(--color-success, #4ade80) 14%, transparent) !important;
+}
+/* Add-Skill form: a "rich placeholder" overlay so only the FIRST word (Title,
+   When to use, How, Tags) is accent-colored while the rest stays muted — real
+   placeholders can't be partially colored. The native placeholder is a single
+   space so :placeholder-shown still toggles the overlay. */
+.skill-ph-wrap { position: relative; }
+.skill-ph-wrap .skill-hint-input { width: 100%; box-sizing: border-box; }
+.skill-rich-ph {
+  position: absolute;
+  left: 11px; right: 11px; top: 50%;
+  transform: translateY(-50%);
+  pointer-events: none;
+  font-size: 12px;
+  line-height: 1.2;
+  color: color-mix(in srgb, var(--fg) 40%, transparent);
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+/* Textarea: align the hint to the first line instead of vertical-centering. */
+.skill-rich-ph-top { top: 7px; transform: none; }
+.skill-rich-ph .k { color: var(--accent, var(--red)); }
+/* Keep the memory-input enter hint pinned to the field's right edge instead
+   of letting it drift after the example text. */
+#new-memory-input ~ .skill-rich-ph > svg {
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translateY(-50%);
+  margin-left: 0 !important;
+}
+/* Hide the overlay once the field has real content. */
+.skill-hint-input:not(:placeholder-shown) ~ .skill-rich-ph { display: none; }
+.skill-md-editor {
+  flex: 1 1 auto;
+  min-height: 0;
+  width: 100%;
+  box-sizing: border-box;
+  resize: none;
+  font-family: ui-monospace, 'SF Mono', 'Fira Code', monospace;
+  font-size: 11.5px;
+  line-height: 1.5;
+  padding: 8px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: var(--bg);
+  color: var(--fg);
+}
+.doclib-empty {
+  text-align: center;
+  color: color-mix(in srgb, var(--fg) 35%, transparent);
+  padding: 32px 16px;
+  font-size: 12px;
+  font-style: italic;
+}
+/* Unified loading row across every Library tab (Chats / Documents / Research /
+   Archive): one opacity, one size, with the whirlpool spinner next to it. */
+.lib-loading-row {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 28px 16px;
+  font-size: 12px;
+  color: color-mix(in srgb, var(--fg) 45%, transparent);
+}
+.pdf-loading-state {
+  min-height: 120px;
+  margin: 0;
+  box-sizing: border-box;
+  flex-direction: column;
+  gap: 10px;
+  border: 0;
+  border-radius: 0;
+  background: transparent;
+  color: color-mix(in srgb, var(--fg) 68%, transparent);
+  box-shadow: none;
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: 0.01em;
+}
+.pdf-loading-state .ai-spinner-whirlpool {
+  color: var(--accent, var(--red));
+  opacity: 0.95;
+}
+.pdf-loading-state .ai-spinner-whirlpool canvas {
+  display: block;
+}
+.doclib-load-more {
+  display: block;
+  margin: 10px auto 0;
+  padding: 6px 16px;
+  background: transparent;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--fg-muted);
+  font-size: 11px;
+  cursor: pointer;
+  transition: border-color 0.15s, color 0.15s;
+  flex-shrink: 0;
+  position: relative;
+  top: 10px;
+}
+/* Soft dark gradient above the button — blends the list content into the
+   load-more strip instead of a hard cutoff. Spans the modal width and
+   sits behind/above the button. */
+.doclib-load-more::before {
+  content: "";
+  position: absolute;
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: 100%;
+  margin-bottom: 4px;
+  width: min(600px, 92vw);
+  height: 34px;
+  pointer-events: none;
+  background: linear-gradient(to top, color-mix(in srgb, #000 24%, transparent), transparent);
+}
+.doclib-load-more:hover {
+  border-color: var(--red);
+  color: var(--red);
+}
+.doclib-inline-load-more {
+  border: 0;
+  border-radius: 0;
+  padding: 0;
+  background: none;
+  top: 0;
+  font-size: 9px !important;
+  line-height: 1.2 !important;
+}
+
+/* Document library toolbar buttons */
+.doclib-toolbar-btn {
+  background: none;
+  border: 1px solid var(--border);
+  color: var(--fg-muted);
+  font-size: 11px;
+  padding: 5px 10px;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s;
+  white-space: nowrap;
+  font-family: inherit;
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+}
+.doclib-toolbar-btn:hover {
+  color: var(--fg);
+  border-color: var(--fg);
+}
+.doclib-toolbar-btn.active {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  border-color: color-mix(in srgb, var(--red) 40%, transparent);
+  color: var(--red);
+}
+.doclib-toolbar-btn.danger {
+  color: var(--color-error, #e55);
+}
+.doclib-toolbar-btn.danger:hover:not(:disabled) {
+  border-color: var(--color-error, #e55);
+}
+.doclib-toolbar-btn:disabled {
+  opacity: 0.4;
+  cursor: default;
+}
+
+/* Bulk action bar */
+.doclib-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 8px;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  font-size: 10px;
+  margin-bottom: 8px;
+}
+.doclib-bulk-bar.hidden {
+  display: none;
+}
+.doclib-bulk-check-all {
+  display: flex;
+  align-items: center;
+  gap: 3px;
+  cursor: pointer;
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  font-size: 10px;
+}
+#doclib-bulk-bar .memory-toolbar-btn {
+  position: relative;
+  top: -3px;
+}
+
+/* Custom checkboxes */
+.doclib-select-cb,
+.doclib-bulk-check-all input {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 13px;
+  height: 13px;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  background: var(--bg);
+  cursor: pointer;
+  flex-shrink: 0;
+  margin: 0;
+  position: relative;
+  transition: all 0.15s;
+}
+.doclib-select-cb:hover,
+.doclib-bulk-check-all input:hover {
+  border-color: var(--red);
+}
+.doclib-select-cb:checked,
+.doclib-bulk-check-all input:checked {
+  background: var(--red);
+  border-color: var(--red);
+}
+.doclib-select-cb:checked::after,
+.doclib-bulk-check-all input:checked::after {
+  content: '';
+  position: absolute;
+  left: 3px;
+  top: 0px;
+  width: 4px;
+  height: 8px;
+  border: solid var(--bg);
+  border-width: 0 1.5px 1.5px 0;
+  transform: rotate(45deg);
+}
+
+
+/* + button outside scroll area (when doc is on right side) */
+
+@media (max-width: 600px) {
+  .doclib-toolbar {
+    flex-direction: column;
+  }
+  .doclib-card-session,
+  .doclib-card-time {
+    display: none;
+  }
+  .doclib-card-preview {
+    max-height: 40vh;
+  }
+}
+
+/* Collapse card tags before they compete with attachment/favorite controls. */
+.email-card-tags .email-tags-more {
+  color: var(--email-tags-more-color, var(--fg));
+  background: color-mix(in srgb, var(--email-tags-more-color, var(--fg)) 18%, transparent);
+}
+.email-tags-more-single,
+[data-email-tags-count-mini] { display: none; }
+.email-tags-more-single svg { display: none; }
+.email-card-tags.email-tags-mini:not(.email-tags-expanded) > .email-tag,
+.email-card-tags.email-tags-mini:not(.email-tags-expanded) > .email-tag-extra {
+  display: none;
+}
+.email-card-tags.email-tags-mini:not(.email-tags-expanded) .email-tags-more-single,
+.email-card-tags.email-tags-expanded .email-tags-more-single {
+  display: inline-flex;
+}
+.email-card-tags.email-tags-mini:not(.email-tags-expanded) [data-email-tags-count-normal] { display: none; }
+.email-card-tags.email-tags-mini:not(.email-tags-expanded) [data-email-tags-count-mini],
+.email-card-tags.email-tags-expanded .email-tags-more-single [data-email-tags-count-mini] { display: inline; }
+
+/* Keep the reader actions in drafting order. */
+.email-reader-actions-inline > [data-act="ai-reply"] { order: 1; }
+.email-reader-actions-inline > [data-act="reply"] { order: 2; }
+.email-reader-actions-inline > [data-act="reply-all"] { order: 3; }
+.email-reader-actions-inline > [data-act="forward"] { order: 4; }
+.email-reader-actions-inline.email-reader-actions-compact > .reader-action-overflowed {
+  display: none !important;
+}
+
+/* ── Archive browser ── */
+.archive-list {
+  grid-template-columns: 1fr !important;
+  gap: 4px !important;
+}
+.archive-row {
+  flex-direction: row !important;
+}
+.archive-row .doclib-card-header {
+  flex: 1;
+  padding: 8px 10px;
+}
+.archive-row .doclib-card-title {
+  flex: 1;
+  min-width: 0;
+}
+/* Archive column layout */
+.archive-header {
+  display: flex;
+  align-items: center;
+  padding: 4px 10px;
+  gap: 6px;
+  font-size: 9px;
+  font-weight: 600;
+  opacity: 0.35;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+.archive-col-title {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-left: -4px;
+}
+.archive-col-msgs {
+  width: 40px;
+  flex-shrink: 0;
+  text-align: left;
+  margin-left: -8px;
+}
+.archive-col-model {
+  width: 90px;
+  flex-shrink: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.archive-col-time {
+  width: 55px;
+  flex-shrink: 0;
+  text-align: right;
+}
+.archive-col-menu {
+  width: 24px;
+  flex-shrink: 0;
+}
+.archive-menu-btn {
+  background: transparent;
+  border: none;
+  color: var(--fg);
+  opacity: 0.3;
+  cursor: pointer;
+  padding: 4px;
+  line-height: 0;
+  border-radius: 4px;
+  transition: background .15s, opacity .15s;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-left: 4px;
+  vertical-align: middle;
+  height: 100%;
+}
+.archive-menu-btn svg {
+  display: block;
+  margin-top: -6px;
+}
+.archive-menu-btn:hover {
+  background: color-mix(in srgb, var(--fg) 10%, transparent);
+  opacity: 1;
+}
+
+/* ── Gallery (image library) ── */
+
+.gallery-modal-content {
+  width: min(820px, 94vw);
+  max-height: 92vh;
+  font-size: 12px;
+}
+/* Keep the Gallery frame anchored while tabs or bulk-selection controls
+   change the content height. Centering the frame made every shorter tab move
+   the whole window vertically. */
+@media (min-width: 769px) {
+  #calendar-modal,
+  #gallery-modal,
+  #tasks-modal,
+  #memory-modal,
+  #doclib-modal,
+  #compare-model-overlay,
+  #research-overlay,
+  #theme-modal,
+  #settings-modal,
+  #email-lib-modal,
+  #cookbook-modal {
+    align-items: flex-start;
+    padding-top: 4vh;
+    box-sizing: border-box;
+  }
+}
+/* While the Edit tab is active the editor needs a *definite* height so
+   the right-side tool panel (`.ge-right-panel { overflow-y:auto }`)
+   scrolls INTERNALLY. Without this the modal only has `max-height`, so
+   `.gallery-editor { height:100% }` can't resolve — the editor grows to
+   its full content height, overflows the modal body, and the modal-body
+   scrollbar that appears can't be wheel-scrolled because the editor's
+   `overscroll-behavior:contain` blocks the chain. Pinning a real height
+   here makes the panel bounded and scrollable as designed. Scoped to
+   the editor view (matched via the container's inline `display:flex`)
+   so the Photos/Albums views keep sizing to their content. */
+.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) {
+  height: 92vh;
+}
+/* The editor tab owns the modal body's scroll context. Keep the landing
+   chooser and the mounted editor inside the available sheet height so a long
+   saved-project list cannot push the launch controls below the viewport. */
+.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) > .modal-body {
+  display: flex;
+  min-height: 0;
+  overflow: hidden;
+}
+.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) #gallery-editor-container {
+  min-height: 0;
+  overflow: hidden;
+}
+/* Photo-detail view sizing (issue #314).
+   The detail view is rendered as a `position:absolute; inset:0` overlay
+   *inside* `.gallery-images-container`, painted over the photo grid. Because
+   it's absolutely positioned it can't contribute to the container's height —
+   the container (and therefore the overlay's `inset:0` box) collapses to the
+   height of the grid sitting behind it. When the library only has a few
+   photos that grid is short, so the detail view is crushed: the image is
+   clipped and the metadata sidebar (`overflow-y:auto`) is squeezed into a
+   tiny, internally-scrolling strip. (With a large library the grid is tall,
+   which is why it looked fine in the demo video but cramped for users with
+   few photos.)
+   Fix: when the detail view is open, hide the grid-view siblings and drop the
+   overlay into normal flow. The container — and the window, up to its 92vh
+   max-height — then sizes to the detail's own content (image + metadata), so
+   nothing is clipped or squeezed regardless of how many photos exist. Scoped
+   via the detail element's inline `display:flex` so the grid / albums views
+   keep sizing to their own content. Works on both desktop and the mobile
+   full-screen sheet. */
+#gallery-images-container:has(> #gallery-detail[style*="flex"]) > *:not(#gallery-detail) {
+  display: none !important;
+}
+#gallery-images-container:has(> #gallery-detail[style*="flex"]) > #gallery-detail {
+  position: static;
+}
+/* Containing block for the photo-detail overlay — keeps it inside the body
+   so it sits below the modal header and the tab strip instead of covering them. */
+.gallery-images-container {
+  position: relative;
+}
+.gallery-modal-content .modal-header h4 {
+  font-size: 1rem;
+}
+.gallery-stats {
+  font-size: 10px;
+  color: color-mix(in srgb, var(--fg) 50%, transparent);
+  margin-bottom: 4px;
+}
+.gallery-toolbar {
+  display: flex;
+  gap: 6px;
+  margin-bottom: 4px;
+  align-items: center;
+}
+.gallery-toolbar-break { display: none; }
+/* Keep the hint anchored while independently seating the input in the row. */
+.gallery-search-wrap { position: relative; flex: 1; min-width: 0; display: flex; top: 0; }
+.gallery-search {
+  position: relative;
+  top: 4px;
+  flex: 1;
+  width: 100%;
+  padding: 4px 8px;
+  padding-right: 78px;   /* room for the enter hint */
+  background: var(--bg);
+  color: var(--fg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  font: inherit;
+  font-size: 12px;
+  outline: none;
+}
+.gallery-search-enter-hint {
+  position: absolute;
+  right: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--accent, var(--red));
+  pointer-events: none;
+  opacity: 0;
+  transition: opacity 0.12s;
+  white-space: nowrap;
+}
+/* Show the hint only once the user has typed something (placeholder gone). */
+.gallery-search:not(:placeholder-shown) ~ .gallery-search-enter-hint { opacity: 0.95; }
+.gallery-search:focus {
+  border-color: var(--red);
+}
+.gallery-model-filter,
+.gallery-sort {
+  padding: 4px 8px;
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--fg);
+  font: inherit;
+  font-size: 11px;
+  cursor: pointer;
+  position: relative;
+  top: 4px;
+}
+.gallery-selection-bar {
+  margin-bottom: 4px;
+  position: relative;
+  top: 2px;
+  padding-right: 18px;
+}
+.gallery-tag-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-bottom: 4px;
+}
+.gallery-chip {
+  height: 28px;
+  box-sizing: border-box;
+  padding: 0 13px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  border-radius: 14px;
+  font-size: 12px;
+  line-height: 1;
+  border: 1px solid var(--border);
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: border-color 0.15s, background 0.15s;
+}
+.gallery-chip:hover {
+  border-color: var(--red);
+}
+.gallery-chip.active {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  border-color: color-mix(in srgb, var(--red) 40%, transparent);
+  color: var(--red);
+}
+/* Album chips row */
+.gallery-album-chips {
+  display: flex; gap: 4px; flex-wrap: wrap; padding: 0 0 3px;
+}
+.gallery-chip-fav { color: var(--red); }
+.gallery-chip-fav.active { background: color-mix(in srgb, var(--red) 15%, transparent); border-color: var(--red); }
+.gallery-chip-add { opacity: 0.5; font-size: 14px; padding: 2px 10px; }
+/* Active-album indicator — appears when the Photos grid is filtered to one album. */
+.gallery-chip-active-album,
+.gallery-active-filter-pill {
+  display: inline-flex; align-items: center; gap: 4px;
+  background: color-mix(in srgb, var(--red) 14%, transparent);
+  border-color: color-mix(in srgb, var(--red) 45%, transparent);
+  position: relative;
+  top: 6px;
+}
+.gallery-active-filter-pill {
+  background: color-mix(in srgb, var(--fg) 7%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 18%, transparent);
+  color: color-mix(in srgb, var(--fg) 86%, transparent);
+}
+.gallery-clear-all-filters {
+  height: 28px;
+  border-style: dashed;
+  opacity: 0.72;
+}
+.gallery-clear-all-filters:hover {
+  opacity: 1;
+  color: var(--red);
+}
+.gallery-chip-clear {
+  background: none;
+  border: none;
+  color: inherit;
+  opacity: 0.6;
+  cursor: pointer;
+  padding: 0;
+  margin-left: 1px;
+  width: 12px;
+  height: 12px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  line-height: 1;
+  position: relative;
+  top: -4px;
+}
+.gallery-chip-clear:hover { opacity: 1; }
+
+
+
+/* Favorite button on card */
+/* Video cards: