"""The app's visual system: semantic design tokens + one style sheet template. This replaces the previous mechanism, which was two hand-written Qt style sheets (``_DARK`` / ``_LIGHT``) that duplicated each other and hard-coded ~105 hex literals, with a single source of truth: Palette (tokens) -> _TEMPLATE (one QSS) -> stylesheet(theme) Rules of the system ------------------- * **Nothing outside this module names a colour.** Widgets that paint with ``QPainter`` (charts, canvases, syntax highlighters) call :func:`palette` and read a token. Widgets that style themselves declaratively should instead be given an ``objectName`` and styled in ``_TEMPLATE`` below. * **Tokens are semantic, not literal.** ``danger``/``text_muted``/``code_string`` — never ``blue``/``grey2``. Adding a theme means adding a :class:`Palette`, not editing a style sheet. * **No gradients, no glows.** The palette is Visual Studio Code's — "Dark Modern" and "Light Modern", taken from the shipped theme JSON. Flat surfaces, square-ish corners, one accent spent only on what the user acts on. Depth comes from the surface ramp and hairline borders, not from colour. Note the VS Code silhouette: the nav rail is *darker* than the content area, not lighter. Contrast is held to WCAG AA (4.5:1) for body text and for text on filled buttons. That is why ``accent`` and ``accent_solid`` are separate tokens: on a dark background a blue readable *as text* is too light to carry white *as a fill*, so each role gets the tint that passes. Four VS Code values fall below AA and are nudged just far enough to clear it — dark line numbers (3.59:1), light faint text on the sidebar (4.28:1), light green (4.33:1) and light amber (3.12:1). Each carries a comment naming the original value, so the deviation is auditable rather than silent. """ from __future__ import annotations from dataclasses import dataclass, asdict from string import Template @dataclass(frozen=True) class Palette: """Every colour and shape value the interface is allowed to use.""" name: str # --- surfaces: a 4-step ramp from the window back to the frontmost layer. bg: str # window / canvas backdrop surface: str # panels, cards, group boxes (NOT the nav rail) surface_raised: str # inputs, lists, trees — things you type or pick in overlay: str # menus, tooltips, popups (floats above everything) sunken: str # logs, code, terminals — things you read into hover: str # hover wash on rows, tabs, ghost buttons active: str # pressed / held state # The nav rail gets its own step rather than borrowing `surface`. It is a # permanent region of the window, not a card floating on the page. # # Following VS Code, the rail is *darker* than the content area (dark) or a # shade off white (light). The step is small on purpose — VS Code separates # the rail with a border, not a big tonal jump — so `nav_border` is doing # real work here and must stay visible. nav_bg: str nav_border: str nav_hover: str nav_selected: str # --- lines border: str # default hairline border_strong: str # hairline that must survive next to a filled surface focus_ring: str # keyboard/typing focus # --- text text: str text_muted: str # secondary copy, captions, group-box titles text_faint: str # metadata, timestamps, placeholder text_disabled: str on_accent: str # text drawn on top of a filled accent/status surface # --- accent: `accent` tints text & icons, `accent_solid` fills buttons. accent: str accent_solid: str accent_solid_hover: str accent_solid_active: str accent_soft: str # translucent wash for selected rows (QSS only) accent_soft_hover: str accent_wash: str # the same tint pre-blended to a solid, for Qt rich # text (bgcolor=, ) where alpha is ignored # --- status success: str success_soft: str warning: str warning_soft: str danger: str danger_solid: str danger_solid_hover: str danger_soft: str info: str info_soft: str purple: str purple_soft: str pink: str pink_soft: str # --- selection (text selection inside editors and inputs) selection_bg: str selection_fg: str # --- scrollbars scroll_handle: str scroll_handle_hover: str # --- code & terminal code_bg: str code_fg: str code_gutter_bg: str code_gutter_fg: str code_selection: str code_comment: str code_keyword: str code_type: str code_func: str code_attr: str code_string: str code_number: str code_error: str # --- diff / inline change badges diff_add_bg: str diff_add_fg: str diff_del_bg: str diff_del_fg: str # --- charts chart_grid: str chart_label: str # --- conversation & graph node roles role_user: str role_assistant: str role_tool: str role_result: str role_error: str # --- shape & type radius_sm: int radius: int radius_lg: int font_family: str font_size: int font_mono: str _FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif' _MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace' DARK = Palette( name="dark", # ---- VS Code "Dark Modern" ---------------------------------------------- # Values taken from the shipped theme JSON. Where VS Code's own choice falls # below WCAG AA it is nudged just far enough to pass; each such value carries # a note with VS Code's original and the measured ratio. bg="#1F1F1F", # editor.background surface="#252526", # panel / card surface_raised="#313131", # input.background overlay="#252526", # menus, tooltips sunken="#181818", # logs, terminals — below the ramp hover="#2A2D2E", # list.hoverBackground active="#37373D", # list.inactiveSelectionBackground # The sidebar is DARKER than the editor — that is the VS Code silhouette. nav_bg="#181818", # sideBar.background nav_border="#2B2B2B", # sideBar.border nav_hover="#2A2D2E", nav_selected="#04395E", # list.activeSelectionBackground border="#2B2B2B", # panel.border border_strong="#3C3C3C", # input.border focus_ring="#0078D4", # focusBorder text="#CCCCCC", # editor.foreground text_muted="#9D9D9D", # descriptionForeground text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces text_disabled="#5A5A5A", on_accent="#FFFFFF", accent="#4DAAFC", # textLink.foreground — accent as TEXT accent_solid="#0078D4", # button.background — accent as FILL accent_solid_hover="#026EC1", accent_solid_active="#005FB8", accent_soft="rgba(0,120,212,0.22)", accent_soft_hover="rgba(0,120,212,0.32)", accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha success="#89D185", # gitDecoration added success_soft="rgba(137,209,133,0.16)", warning="#CCA700", # editorWarning warning_soft="rgba(204,167,0,0.16)", danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels) danger_solid="#C4302B", danger_solid_hover="#D9433C", danger_soft="rgba(241,76,76,0.16)", info="#4DAAFC", info_soft="rgba(77,170,252,0.16)", purple="#C586C0", # Dark+ syntax purple purple_soft="rgba(197,134,192,0.16)", pink="#D16D9E", pink_soft="rgba(209,109,158,0.16)", selection_bg="#264F78", # editor.selectionBackground selection_fg="#FFFFFF", scroll_handle="#4E4E4E", # scrollbarSlider scroll_handle_hover="#5A5A5A", code_bg="#1F1F1F", code_fg="#CCCCCC", code_gutter_bg="#1F1F1F", # VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA. code_gutter_fg="#858D97", code_selection="#264F78", code_comment="#6A9955", # ---- Dark+ syntax, unchanged -------------- code_keyword="#569CD6", code_type="#4EC9B0", code_func="#DCDCAA", code_attr="#9CDCFE", code_string="#CE9178", code_number="#B5CEA8", code_error="#F44747", diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended diff_add_fg="#89D185", diff_del_bg="#4B1818", # diffEditor removed, pre-blended diff_del_fg="#F76464", chart_grid="#2B2B2B", chart_label="#9D9D9D", role_user="#4DAAFC", role_assistant="#4EC9B0", role_tool="#C586C0", role_result="#89D185", role_error="#F14C4C", radius_sm=3, # VS Code is squarer than the previous look radius=4, radius_lg=6, font_family=_FONT, font_size=13, font_mono=_MONO, ) LIGHT = Palette( name="light", # ---- VS Code "Light Modern" --------------------------------------------- bg="#FFFFFF", # editor.background surface="#F8F8F8", # sideBar / panel surface_raised="#FFFFFF", # input.background overlay="#FFFFFF", sunken="#F3F3F3", hover="#F2F2F2", # list.hoverBackground active="#E8E8E8", # list.activeSelectionBackground nav_bg="#F8F8F8", # sideBar.background nav_border="#E5E5E5", # sideBar.border nav_hover="#F2F2F2", nav_selected="#E4E6F1", # active row, tinted toward the accent border="#E5E5E5", border_strong="#CECECE", # input.border focus_ring="#005FB8", # focusBorder text="#3B3B3B", # editor.foreground text_muted="#616161", # VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there. text_faint="#6E6E6E", text_disabled="#A0A0A0", on_accent="#FFFFFF", accent="#005FB8", # textLink / button accent_solid="#005FB8", accent_solid_hover="#0258A8", accent_solid_active="#004C97", accent_soft="rgba(0,95,184,0.10)", accent_soft_hover="rgba(0,95,184,0.16)", accent_wash="#E6EEF8", # VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted. success="#317A2D", success_soft="#DFF3DE", warning="#8F6500", # VS Code #BF8803 = 3.12:1 warning_soft="#FBF0D0", danger="#CD3131", # editorError danger_solid="#CD3131", danger_solid_hover="#B82A2A", danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it info="#005FB8", info_soft="#DDEBF9", purple="#6F42C1", purple_soft="#EDE7FA", pink="#B3247E", pink_soft="#FAE3F0", selection_bg="#ADD6FF", # editor.selectionBackground selection_fg="#000000", scroll_handle="#C1C1C1", scroll_handle_hover="#A6A6A6", code_bg="#FFFFFF", code_fg="#3B3B3B", code_gutter_bg="#F8F8F8", code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter code_selection="#ADD6FF", code_comment="#008000", # ---- Light+ syntax ------------------------ code_keyword="#0000FF", code_type="#267F99", code_func="#795E26", code_attr="#E50000", code_string="#A31515", code_number="#098658", code_error="#CD3131", diff_add_bg="#DBF4DB", diff_add_fg="#1E6F1A", diff_del_bg="#FBE3E3", diff_del_fg="#B82A2A", chart_grid="#E5E5E5", chart_label="#616161", role_user="#005FB8", role_assistant="#267F99", role_tool="#6F42C1", role_result="#317A2D", role_error="#CD3131", radius_sm=3, radius=4, radius_lg=6, font_family=_FONT, font_size=13, font_mono=_MONO, ) _PALETTES = {"dark": DARK, "light": LIGHT} # --------------------------------------------------------------------------- # The one style sheet. `$token` placeholders are filled from the Palette above; # use `${token}px` where a unit follows the name. # # Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome. # --------------------------------------------------------------------------- _TEMPLATE = Template(""" /* ---- reset ------------------------------------------------------------ */ * { font-family: $font_family; font-size: ${font_size}px; } QWidget { background: $bg; color: $text; } QMainWindow::separator { background: $border; width: 1px; height: 1px; } QSplitter::handle { background: $border; } QSplitter::handle:horizontal { width: 1px; } QSplitter::handle:vertical { height: 1px; } QSplitter::handle:hover { background: $border_strong; } QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; } QToolTip { background: $overlay; color: $text; border: 1px solid $border_strong; border-radius: ${radius}px; padding: 5px 9px; } /* Icons are drawn at text scale, not as decoration. */ QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; } QTreeWidget#navrail { qproperty-iconSize: 16px 16px; } /* ---- shell ------------------------------------------------------------ */ QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; } QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; } QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; } QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; } QWidget#contentArea { background: $bg; } /* Rail rows sit on nav_bg, so they need their own hover/selected steps — the generic ones are tuned against `bg` and wash out here. The active item also carries a 2px accent marker, so which section you are in survives even at a glance or for anyone who cannot separate the two greys. */ QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item { padding: 6px 4px; border-radius: ${radius}px; } QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover { background: $nav_hover; } QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected { background: $nav_selected; color: $text; border-left: 2px solid $accent; font-weight: 600; } /* Rows the project gate is holding shut: still listed, visibly not open. */ QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; } /* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it from the list above so "occasional" reads apart from "everyday". */ QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; } /* Segmented control: two-to-four choices shown side by side (language, theme) instead of a drop-list you must open to see what the options even are. */ QPushButton#segItem { background: $surface_raised; color: $text_muted; border: 1px solid $border; padding: 4px 12px; margin: 0; border-radius: 0; } QPushButton#segItem:hover { background: $hover; color: $text; } QPushButton#segItem:checked { background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600; } /* Table of contents down the left of the long dialogs (Settings, Task editor). */ QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; } QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; } QListWidget#sectionIndex::item:hover { background: $hover; } QListWidget#sectionIndex::item:selected { background: $nav_selected; color: $text; font-weight: 600; } /* The strip under the typing box: agent · routing · usage · folder. Reads as status, not as a second toolbar, so the eye lands on the input first. */ QWidget#composerStatus { border-top: 1px solid $border; background: transparent; } QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; } QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox { background: transparent; border: none; color: $text_muted; font-size: 11px; padding: 2px 6px; border-radius: ${radius_sm}px; } QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover { background: $hover; color: $text; } /* Folder: the current path, written as the screen's title. */ QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; } /* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and quieter than the app's main tab bars, since they switch a view, not a page. */ QTabBar#viewTabs::tab { background: transparent; color: $text_muted; border: none; padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px; } QTabBar#viewTabs::tab:hover { background: $hover; color: $text; } QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; } /* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so "which list am I looking at" is answered on screen, not in a tooltip. */ QPushButton#co4eSectionHdr { color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; background: transparent; border: none; text-align: left; padding: 2px 0; } QPushButton#co4eSectionHdr:hover { color: $text; } /* Account row at the foot of the rail: who you are + the settings that follow you (provider, language, theme). Separated by a hairline like the group above. */ QWidget#navAccount { border-top: 1px solid $nav_border; } QWidget#navAccount QComboBox { background: $surface_raised; border: 1px solid $nav_border; color: $text; padding: 3px 6px; border-radius: ${radius}px; } /* RECENTS section label — quiet, so the thread titles under it read first. */ QLabel#navSectionHdr { color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; padding: 8px 8px 2px 8px; background: transparent; } QTreeWidget#navRecents { border-top: 1px solid $nav_border; } /* Rail header — the primary action, so it is the one filled button up there. */ QPushButton#navNewChatBtn { background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600; padding: 7px 10px; border-radius: ${radius}px; text-align: left; } QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; } QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; } QComboBox#navProjectPick { background: $surface_raised; border: 1px solid $nav_border; color: $text; padding: 4px 8px; border-radius: ${radius}px; } QPushButton#navSettingsBtn { background: transparent; border: none; color: $text_muted; padding: 6px 8px; text-align: left; border-radius: ${radius}px; margin: 2px 6px 6px 6px; } QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; } QPushButton#navSettingsBtn:pressed { background: $active; } /* ---- surfaces --------------------------------------------------------- */ QGroupBox { background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px; padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600; } QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; } QScrollArea { background: transparent; border: none; } QAbstractScrollArea::corner { background: transparent; } /* ---- tabs: an underline, not a pill. ------------------------------------- The old pill tabs read as buttons and fought the real buttons for attention. A 2px rule under the active label is quieter and unambiguous. */ QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; } QTabBar { background: transparent; qproperty-drawBase: 0; } QTabBar::tab { background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0; border: none; border-bottom: 2px solid transparent; font-weight: 500; } QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; } QTabBar::tab:hover:!selected { color: $text; background: $hover; } /* Co4E flow strip — browser-style tabs, so these stay enclosed. */ QTabBar#flowTabs::tab { background: $surface; color: $text_muted; border: 1px solid $border; border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; } QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; } QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; } QPushButton#flowAddBtn { background: transparent; color: $text_muted; border: 1px solid $border; border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px; } QPushButton#flowAddBtn:hover { background: $hover; color: $text; } /* Co4E icon sidebar — no chrome until it is the active one. */ QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; } QTabBar#co4eSideTabs::tab { background: transparent; color: $text_muted; border: none; border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0; } QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; } QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; } QGraphicsView#co4eCanvas { background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; } /* ---- text entry & item views ------------------------------------------ */ QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox, QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget { background: $surface_raised; color: $text; border: 1px solid $border; border-radius: ${radius}px; selection-background-color: $selection_bg; selection-color: $selection_fg; } QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; } QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; } QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; } QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled { background: $surface; color: $text_disabled; } QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; } QTreeView::item:hover, QListView::item:hover { background: $hover; } QTreeView::item:selected, QListView::item:selected, QTableView::item:selected { background: $accent_soft; color: $text; } QHeaderView::section { background: $bg; color: $text_muted; border: none; border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600; } /* ---- buttons ----------------------------------------------------------- Default is a quiet outline. Weight is reserved for #primary / #danger, so at most one button per view should carry a fill. */ QPushButton { background: $surface_raised; color: $text; border: 1px solid $border_strong; border-radius: ${radius}px; padding: 7px 14px; font-weight: 500; } QPushButton:hover { background: $hover; border-color: $border_strong; } QPushButton:pressed { background: $active; } QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; } QPushButton:focus { border: 1px solid $focus_ring; } QPushButton#primary { background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600; } QPushButton#primary:hover { background: $accent_solid_hover; } QPushButton#primary:pressed { background: $accent_solid_active; } QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; } QPushButton#danger { background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600; } QPushButton#danger:hover { background: $danger_solid_hover; } QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; } /* Ghost buttons: nav section headers and icon-only chrome. */ QPushButton#navMenuBtn { background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px; font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left; } QPushButton#navMenuBtn:hover { background: $hover; color: $text; } QPushButton#navMenuBtn:pressed { background: $active; } QToolButton { background: transparent; color: $text_muted; border: none; border-radius: ${radius}px; padding: 5px; } QToolButton:hover { background: $hover; color: $text; } QToolButton:pressed { background: $active; } QToolButton::menu-indicator { image: none; } /* ---- pickers ----------------------------------------------------------- */ QComboBox { background: $surface_raised; color: $text; border: 1px solid $border_strong; border-radius: ${radius}px; padding: 6px 10px; } QComboBox:hover { background: $hover; } QComboBox:focus { border-color: $focus_ring; } QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; } QComboBox::drop-down { border: none; width: 20px; } QComboBox QAbstractItemView { background: $overlay; color: $text; border: 1px solid $border_strong; border-radius: ${radius}px; padding: 4px; outline: none; selection-background-color: $accent_soft; selection-color: $text; } QMenu { background: $overlay; color: $text; border: 1px solid $border_strong; border-radius: ${radius}px; padding: 4px; } QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; } QMenu::item:selected { background: $accent_soft; color: $text; } QMenu::item:disabled { color: $text_disabled; } QMenu::separator { height: 1px; background: $border; margin: 4px 6px; } QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; } QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; } QMenuBar::item:selected { background: $hover; } /* ---- toggles ----------------------------------------------------------- */ QCheckBox, QRadioButton { spacing: 8px; background: transparent; } QCheckBox::indicator, QRadioButton::indicator { width: 16px; height: 16px; background: $surface_raised; border: 1px solid $border_strong; border-radius: ${radius_sm}px; } QRadioButton::indicator { border-radius: 9px; } QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; } QCheckBox::indicator:checked, QRadioButton::indicator:checked { background: $accent_solid; border-color: $accent_solid; } QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { background: $surface; border-color: $border; } QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled { background: $border_strong; border-color: $border_strong; } QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; } QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; } QSlider::handle:horizontal { width: 14px; height: 14px; margin: -6px 0; border-radius: 7px; background: $surface_raised; border: 1px solid $border_strong; } QSlider::handle:horizontal:hover { border-color: $accent; } QProgressBar { background: $surface; border: none; border-radius: 3px; height: 6px; text-align: center; color: $text_muted; } QProgressBar::chunk { background: $accent_solid; border-radius: 3px; } /* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */ QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; } QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; } QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; } QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; } QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; } QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; } QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; } QScrollBar::add-page, QScrollBar::sub-page { background: none; } /* ---- badges & inline text tones --------------------------------------- One shape, six tones. Pick by meaning: badgeSuccess for a finished run, badgeDanger for a failed one — not by which colour looks nice. */ QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger, QLabel#badgePurple, QLabel#badgePink { border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600; } QLabel#badge { background: $info_soft; color: $info; } QLabel#badgeSuccess { background: $success_soft; color: $success; } QLabel#badgeWarn { background: $warning_soft; color: $warning; } QLabel#badgeDanger { background: $danger_soft; color: $danger; } QLabel#badgePurple { background: $purple_soft; color: $purple; } QLabel#badgePink { background: $pink_soft; color: $pink; } QLabel { background: transparent; } QLabel#hint { color: $text_muted; } QLabel#faint { color: $text_faint; } QLabel#warning { color: $warning; font-weight: 600; } QLabel#error { color: $danger; font-weight: 600; } QLabel#success { color: $success; font-weight: 600; } QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; } /* ---- code, terminals & logs ------------------------------------------- These read as "sunken" surfaces: the eye goes in, not across. */ QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView { background: $code_bg; color: $code_fg; border: none; font-family: $font_mono; selection-background-color: $code_selection; } QLineEdit#termInput { background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border; font-family: $font_mono; border-radius: 0; padding: 7px 10px; } QLineEdit#termInput:focus { border-top-color: $accent; } /* The help-agent dock styles itself from these same tokens — it is a floating overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */ """) def resolve_theme(theme: str) -> str: """Resolve ``'system'`` to ``'dark'``/``'light'`` from the OS colour scheme.""" if theme in _PALETTES: return theme try: from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication app = QApplication.instance() if app is not None: scheme = app.styleHints().colorScheme() return "light" if scheme == Qt.ColorScheme.Light else "dark" except Exception: pass return "dark" def palette(theme: str) -> Palette: """The token set for ``theme``. Painting code reads its colours from here.""" return _PALETTES[resolve_theme(theme)] # The theme the running app is currently showing. Painting code (paintEvent, # QSyntaxHighlighter, canvas items) reads it via current_palette() instead of # re-reading config.json — that used to cost a file open per repaint. _active_theme = "dark" def set_active_theme(theme: str) -> str: """Record the theme the app just applied. Call this next to every ``QApplication.setStyleSheet(stylesheet(...))``. Returns the resolved name.""" global _active_theme _active_theme = resolve_theme(theme) return _active_theme def current_theme() -> str: """The resolved theme ('dark'/'light') the app is showing right now.""" return _active_theme def current_palette() -> Palette: """Tokens for the theme the app is showing right now.""" return _PALETTES[_active_theme] def stylesheet(theme: str) -> str: """The application-wide Qt style sheet for ``theme``.""" return _TEMPLATE.substitute(asdict(palette(theme))) def role_colors(theme: str) -> dict[str, str]: """Conversation/graph node colours keyed by role.""" p = palette(theme) return { "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool, "result": p.role_result, "error": p.role_error, }