A comprehensive design system and styling reference for the SC2 desktop application.
- Design Philosophy
- Theme System Architecture
- Color Palettes
- Typography
- Spacing & Layout
- Component Patterns
- QSS Styling Guide
- Known Issues & Fixes
- Widget Reference
- Code Patterns
-
Dark-first design - Primary themes (Cyber, Dark) assume dark backgrounds with light text. Light theme is provided for accessibility and preference.
-
Accent-driven identity - Each theme has a distinct accent color that defines its personality:
- Cyber: Electric cyan (
#00ffff) - technical, futuristic - Dark: Elegant gold (
#d4af37) - professional, refined - Light: Professional blue (
#2563eb) - clean, corporate
- Cyber: Electric cyan (
-
Consistent hierarchy - Background colors follow a predictable depth pattern:
- Primary → Secondary → Tertiary (progressively lighter in dark themes)
- Creates natural visual layering without explicit borders everywhere
-
Subtle feedback - Hover and focus states use border color changes and subtle background shifts rather than dramatic transformations.
-
QSS-first, palette-fallback - Style via QSS where possible, but use QPalette for widgets that resist stylesheet control.
All theme colors are defined in a ThemeColors dataclass with semantic naming:
@dataclass
class ThemeColors:
# Background hierarchy (dark → light in dark themes)
bg_primary: str # Main window background
bg_secondary: str # Panel backgrounds
bg_tertiary: str # Nested elements, table headers
bg_input: str # Input field backgrounds
bg_hover: str # Hover state backgrounds
bg_selected: str # Selected item backgrounds
bg_disabled: str # Disabled widget backgrounds
# Accent colors
accent: str # Primary accent (buttons, highlights)
accent_dim: str # Dimmed accent (gradients, disabled)
accent_hover: str # Accent on hover
accent_pressed: str # Accent when pressed
accent_danger: str # Error/destructive actions
accent_success: str # Success states
accent_warning: str # Warnings
accent_info: str # Informational highlights
# Text hierarchy
text_primary: str # Main text
text_secondary: str # Labels, descriptions
text_muted: str # Hints, timestamps, placeholders
text_disabled: str # Disabled text
text_accent: str # Accent-colored text
text_on_accent: str # Text on accent backgrounds
# Borders
border_primary: str # Focused/active borders
border_secondary: str # Panel borders
border_dim: str # Subtle borders
border_hover: str # Hover state borders
# Scrollbar
scrollbar_bg: str
scrollbar_handle: str
scrollbar_hover: str
# Metadata
name: str
is_dark: boolCentral controller for theme state:
theme_manager = ThemeManager(ThemeName.CYBER)
# Access current theme
colors = theme_manager.theme
stylesheet = theme_manager.stylesheet
# Switch themes
theme_manager.set_theme(ThemeName.DARK)
app.setStyleSheet(theme_manager.stylesheet)
# Query specific colors
accent = theme_manager.get_color("accent")Gold accent (#d4af37) on pure black (#000000). Professional, elegant.
Blue accent (#2563eb) on white (#ffffff). Clean, corporate-friendly.
font-family: "Segoe UI", "SF Pro Display", -apple-system, "Helvetica Neue", sans-serif;font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas", monospace;| Use | Size | Weight |
|---|---|---|
| Body text | 13px | 400 |
| Small/captions | 11px | 400 |
| Code | 12px | 400 |
| Section titles | 11px | 600, uppercase |
| Headings | 18px | 700 |
| Subheadings | 14px | 500 |
- Headings:
2pxabsolute spacing - Section titles (uppercase):
1pxspacing - Body text: Normal
8px grid system. All spacing should be multiples of 8px.
| Name | Value | Usage |
|---|---|---|
| xs | 4px | Tight spacing, icon gaps |
| sm | 8px | Related element spacing |
| md | 12px | Standard padding |
| lg | 16px | Section spacing |
| xl | 24px | Major section breaks |
| xxl | 32px | Card padding, major gaps |
| Element | Radius |
|---|---|
| Buttons, inputs | 6px |
| Cards, dialogs | 12px |
| Small elements (tags) | 4px |
| Circular | 50% |
# Card/panel padding
card_layout.setContentsMargins(32, 24, 32, 24)
# Form field spacing
form_layout.setSpacing(10)
# Button padding
padding: 8px 16px; # Standard
padding: 14px 20px; # Large/primaryStandard Button
QPushButton {
background-color: {bg_tertiary};
border: 1px solid {border_dim};
border-radius: 6px;
padding: 8px 16px;
color: {text_primary};
font-weight: 500;
}Primary Button (gradient accent)
QPushButton#primary {
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1,
stop:0 {accent_dim},
stop:1 {accent}
);
border: 1px solid {accent};
color: {text_on_accent};
font-weight: 600;
}Danger Button (outline style)
QPushButton#danger {
background-color: transparent;
border: 1px solid {accent_danger};
color: {accent_danger};
}
QPushButton#danger:hover {
background-color: {accent_danger};
color: white;
}QLineEdit {
background-color: {bg_input};
border: 1px solid {border_dim};
border-radius: 6px;
padding: 8px 12px;
color: {text_primary};
}
QLineEdit:focus {
border-color: {accent};
}QFrame#card {
background-color: {bg_secondary};
border: 1px solid {border_dim};
border-radius: 12px;
padding: 16px;
}
QFrame#card:hover {
border-color: {border_hover};
}QTableView {
background-color: {bg_secondary};
alternate-background-color: {bg_tertiary};
gridline-color: {border_dim};
border: 1px solid {border_dim};
border-radius: 8px;
}
QHeaderView::section {
background-color: {bg_tertiary};
color: {text_secondary};
font-weight: 600;
text-transform: uppercase;
border: none;
border-bottom: 1px solid {border_dim};
padding: 10px 12px;
}QSS follows CSS-like specificity but with quirks:
/* Base widget - lowest specificity */
QPushButton { }
/* Object name - higher specificity */
QPushButton#primary { }
/* Property selector */
QPushButton[primary="true"] { }
/* Child selector */
QComboBox QAbstractItemView { }
/* Pseudo-states */
QPushButton:hover { }
QPushButton:pressed { }
QPushButton:disabled { }Use setObjectName() for style variants:
button = QPushButton("Delete")
button.setObjectName("danger") # Matches QPushButton#dangerMany scrollable widgets have an internal viewport that needs explicit styling:
QTextEdit::viewport {
background-color: {bg_input};
}
QScrollArea::viewport {
background-color: transparent;
}
QTableView::viewport {
background-color: {bg_secondary};
}Qt widgets have sub-controls that can be styled:
/* Scrollbar parts */
QScrollBar::handle:vertical { }
QScrollBar::add-line:vertical { }
QScrollBar::sub-line:vertical { }
/* ComboBox parts */
QComboBox::drop-down { }
QComboBox::down-arrow { }
/* SpinBox parts */
QSpinBox::up-button { }
QSpinBox::down-button { }
/* Tab parts */
QTabBar::tab { }
QTabBar::close-button { }Problem: The dropdown popup is a separate top-level window. QSS can't style the native window container, resulting in a white frame.
Solution: Use StyledComboBox class that overrides showPopup():
from themes import StyledComboBox
combo = StyledComboBox()
combo.addItems(["Option 1", "Option 2"])
combo.set_theme_colors(theme_manager.theme)
# When theme changes:
combo.set_theme_colors(new_theme)Technical details: The fix applies FramelessWindowHint, WA_TranslucentBackground, and palette colors to the popup container at show time.
Problem: Autocomplete popups have the same issue as QComboBox.
Solution: Style the popup after setting the completer:
def fix_completer_popup(line_edit, theme):
completer = line_edit.completer()
if not completer:
return
popup = completer.popup()
if popup:
# Apply palette
palette = popup.palette()
palette.setColor(QPalette.ColorRole.Base, QColor(theme.bg_secondary))
palette.setColor(QPalette.ColorRole.Text, QColor(theme.text_primary))
palette.setColor(QPalette.ColorRole.Highlight, QColor(theme.bg_selected))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(theme.accent))
popup.setPalette(palette)
# Apply stylesheet
popup.setStyleSheet(f"""
QListView {{
background-color: {theme.bg_secondary};
border: 1px solid {theme.border_dim};
border-radius: 6px;
padding: 4px;
}}
QListView::item {{
padding: 6px 12px;
border-radius: 4px;
}}
QListView::item:hover {{
background-color: {theme.bg_hover};
}}
QListView::item:selected {{
background-color: {theme.bg_selected};
color: {theme.accent};
}}
""")Problem: Calendar popup is a complex widget with multiple sub-widgets.
Solution: Comprehensive QSS plus navigation button fixes:
QCalendarWidget {
background-color: {bg_secondary};
}
QCalendarWidget QToolButton {
background-color: {bg_tertiary};
color: {text_primary};
border: none;
border-radius: 4px;
padding: 4px 8px;
}
QCalendarWidget QMenu {
background-color: {bg_secondary};
border: 1px solid {border_dim};
}
QCalendarWidget QSpinBox {
background-color: {bg_input};
border: 1px solid {border_dim};
}
QCalendarWidget QTableView {
background-color: {bg_secondary};
selection-background-color: {accent};
selection-color: {text_on_accent};
}Problem: The corner between row and column headers may not style.
Solution: Explicit corner styling:
QTableView QTableCornerButton::section {
background-color: {bg_tertiary};
border: none;
border-bottom: 1px solid {border_dim};
border-right: 1px solid {border_dim};
}Or programmatically:
corner = table.findChild(QAbstractButton)
if corner:
corner.setStyleSheet(f"background-color: {theme.bg_tertiary};")Problem: On macOS, QMessageBox may use native buttons that ignore QSS.
Solution: Force non-native dialogs:
msg = QMessageBox()
msg.setStyleSheet(app_stylesheet)
# Or globally:
app.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeDialogs)Problem: When editing a cell, the QLineEdit may not inherit theme.
Solution: Set a styled item delegate:
class ThemedDelegate(QStyledItemDelegate):
def __init__(self, theme, parent=None):
super().__init__(parent)
self.theme = theme
def createEditor(self, parent, option, index):
editor = super().createEditor(parent, option, index)
if isinstance(editor, QLineEdit):
editor.setStyleSheet(f"""
QLineEdit {{
background-color: {self.theme.bg_input};
color: {self.theme.text_primary};
border: 2px solid {self.theme.accent};
padding: 2px 4px;
}}
""")
return editor
table.setItemDelegate(ThemedDelegate(theme))Problem: Tooltips can look different across platforms.
Solution: QSS usually works, but add palette fallback:
def set_tooltip_style(app, theme):
palette = app.palette()
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(theme.bg_tertiary))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor(theme.text_primary))
app.setPalette(palette)Problem: Tearoff menus may show unstyled handles.
Solution: Disable tearoff or style explicitly:
menu.setTearOffEnabled(False)
# Or style it:
# QMenu::tearoff { background-color: {bg_tertiary}; }These widgets work well with pure QSS:
QWidget,QFrame,QDialog,QMainWindowQLabelQLineEdit,QTextEdit,QPlainTextEditQPushButton,QToolButtonQCheckBox,QRadioButtonQGroupBoxQTabWidget,QTabBarQScrollBarQProgressBarQSliderQSpinBox,QDoubleSpinBoxQMenu,QMenuBarQToolBar,QStatusBarQSplitter
| Widget | Issue | Solution |
|---|---|---|
QComboBox |
Popup container | StyledComboBox class |
QCompleter |
Popup styling | fix_completer_popup() |
QDateEdit / QCalendarWidget |
Calendar popup | Comprehensive QSS + palette |
QTableView / QTreeView |
Corner button, cell editors | Corner QSS + ThemedDelegate |
QMessageBox |
Native buttons (macOS) | AA_DontUseNativeDialogs |
QFileDialog |
Fully native on most platforms | Accept platform styling |
QColorDialog |
Partially native | Limited styling possible |
QFontDialog |
Partially native | Limited styling possible |
Some dialogs should retain native appearance for user familiarity:
QFileDialog- Users expect system file browserQColorDialog- Platform color pickers are usually betterQPrintDialog- System print dialogs have necessary features
class ThemedWidget(QWidget):
"""Base class for theme-aware widgets."""
def __init__(self, theme_manager: ThemeManager, parent=None):
super().__init__(parent)
self.theme_manager = theme_manager
self._setup_ui()
self._apply_theme()
def _setup_ui(self):
"""Override to build UI."""
pass
def _apply_theme(self):
"""Override to apply theme-specific styling."""
theme = self.theme_manager.theme
# Apply styles...
def set_theme(self, theme_name: ThemeName):
"""Change theme and reapply styling."""
self.theme_manager.set_theme(theme_name)
self._apply_theme()def switch_theme(self, theme_name: ThemeName):
# Update manager
self.theme_manager.set_theme(theme_name)
# Update application stylesheet
app = QApplication.instance()
if app:
app.setStyleSheet(self.theme_manager.stylesheet)
# Update any widgets needing manual fixes
self.theme_combo.set_theme_colors(self.theme_manager.theme)
# Emit signal for other components
self.theme_changed.emit(theme_name)from themes import apply_widget_style
# Create widget
button = QPushButton("Save")
# Apply named style (sets objectName)
apply_widget_style(button, "primary")
# The QSS selector QPushButton#primary now appliesdef _apply_theme(self):
theme = self.theme_manager.theme
# Some values may need theme-specific adjustment
if theme.is_dark:
button_text = theme.bg_primary # Dark text on accent
shadow_opacity = 0.3
else:
button_text = "#ffffff" # White text on accent
shadow_opacity = 0.15
self.primary_button.setStyleSheet(f"""
QPushButton {{
background-color: {theme.accent};
color: {button_text};
}}
""")def apply_palette_fallback(widget, theme):
"""Apply palette colors as fallback for stubborn widgets."""
palette = widget.palette()
palette.setColor(QPalette.ColorRole.Window, QColor(theme.bg_primary))
palette.setColor(QPalette.ColorRole.WindowText, QColor(theme.text_primary))
palette.setColor(QPalette.ColorRole.Base, QColor(theme.bg_secondary))
palette.setColor(QPalette.ColorRole.Text, QColor(theme.text_primary))
palette.setColor(QPalette.ColorRole.Highlight, QColor(theme.accent))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(theme.text_on_accent))
palette.setColor(QPalette.ColorRole.Button, QColor(theme.bg_tertiary))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(theme.text_primary))
widget.setPalette(palette):hover /* Mouse over */
:pressed /* Mouse button down */
:focus /* Has keyboard focus */
:disabled /* Widget is disabled */
:enabled /* Widget is enabled */
:checked /* Checkbox/radio is checked */
:unchecked /* Checkbox/radio is unchecked */
:selected /* Item is selected */
:on /* Toggle button is on */
:off /* Toggle button is off */
:open /* ComboBox is open */
:closed /* ComboBox is closed */
:editable /* ComboBox is editable */
:read-only /* Input is read-only */
:first /* First item in list */
:last /* Last item in list */
:horizontal /* Horizontal orientation */
:vertical /* Vertical orientation *//* Linear gradient */
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1,
stop:0 #color1,
stop:0.5 #color2,
stop:1 #color3
);
/* Radial gradient */
background: qradialgradient(
cx:0.5, cy:0.5, radius:0.5,
fx:0.5, fy:0.5,
stop:0 #color1,
stop:1 #color2
);
/* Conical gradient */
background: qconicalgradient(
cx:0.5, cy:0.5, angle:0,
stop:0 #color1,
stop:1 #color2
);/* Full border */
border: 1px solid #color;
/* Individual sides */
border-top: 1px solid #color;
border-bottom: none;
/* Radius */
border-radius: 6px;
border-top-left-radius: 6px;| Version | Date | Changes |
|---|---|---|
| 1.0 | 2025-01 | Initial style guide |
Secure Cartography v2 - Network Discovery & Mapping