Files
cowork-local/theme.py
T
Nam Pham Dinh ThanhandClaude Opus 5 bcdfe43c7c Keep the project picker reachable while the rail is collapsed
The picker was hidden outright at 54px, on the grounds that a combo box there
could only show a stub of a name. True, but it left the collapsed rail with no
way to change project at all — and the audit page's own inventory of the 11
collapsible controls says the rail's fold "giữ nguyên toàn bộ".

A folder QToolButton stands in for it: same items, and choosing one moves the
combo, so _on_rail_project_pick stays the single code path. Its tooltip carries
the current project name, which the combo could not have shown anyway. It
matches the + button's width and drops its menu arrow — at 42px the arrow
would take a third of the row to say nothing.

check_nav now collapses the rail and requires a reachable picker whose menu
matches the combo item for item, then picks a row and asserts the project
actually changed. Hiding the button fails it.

15/15 checkers pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:41:03 +09:00

782 lines
34 KiB
Python

"""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=, <table>) 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: 22px 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; }
QScrollArea#navScroll { background: transparent; border: none; }
QScrollArea#navScroll > QWidget > QWidget { background: transparent; }
/* Monitoring ▸ Overview reads as titled sections down one column, the way the
audit page draws it — a quiet caps heading with the content flat underneath,
not six bordered boxes competing with the cards inside them. */
QGroupBox#monSection {
background: transparent; border: none; margin-top: 16px;
padding: 6px 0 0 0; font-weight: 700;
}
QGroupBox#monSection::title {
subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0;
padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px;
}
/* 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. */
/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The
wireframe writes these as small accent text; as full buttons they were the
loudest thing in the sidebar and each cost a row of height. */
QPushButton#co4eSectionAction {
background: transparent; border: none; color: $accent;
font-size: 11px; font-weight: 600; padding: 1px 4px;
border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px;
}
QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; }
QPushButton#co4eSectionAction:pressed { background: $active; }
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;
}
/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow —
the arrow would eat a third of the width for no information. */
QToolButton#navProjectPickMini {
background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px;
padding: 4px; qproperty-iconSize: 16px 16px;
}
QToolButton#navProjectPickMini:hover { background: $nav_hover; }
QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; }
QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; }
QPushButton#navSettingsBtn {
background: transparent; border: none; color: $text_muted;
padding: 6px 8px 6px 4px; text-align: left; border-radius: ${radius}px;
/* A button leaves less air between icon and label than a tree row
does, so the word sat 3px left of Dashboard's. Widening the icon
box (the glyph stays 16px, left-aligned) closes the gap. */
qproperty-iconSize: 19px 16px;
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
so its icon has to start on their x. A 6px margin put it at 14 — near
enough the middle of the collapsed 54px rail to look centred. */
margin: 2px 0px 6px 0px;
}
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,
}