From 6e05ef7776acdf7bb49f1787bfa71512202a9878 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 08:46:11 -0400 Subject: [PATCH 1/2] UI refactor P0: design-token foundation + full hex/font sweep behind a permanent gate First phase of the UI refactor plan (doc/UI_QA_AUDIT.md section 7). Foundation - graphlink_styles.py is THE token source (recorded decision: extending the existing THEME_TOKENS/runtime-injection pipeline instead of introducing a parallel JSON file that would fork truth): - STRUCTURE_TOKENS: theme-independent scales - spacing on a 4px grid (--gl-space-1..8), radius sm/md/lg, type ramp 12/13/14/16/20 (nothing under 12px - audit finding D2), three elevation shadow levels (finding B9), motion 150/200ms + easing (finding F4). Exported to every island and registered under Tailwind v4's PROPER namespaces (spacing/radius/ text/font-weight/shadow/duration/ease - not bogus color utilities), with Qt-side integer mirrors (SPACE_PX/RADIUS_PX/TEXT_PX/ELEVATION_PARAMS) parsed from the same dict so the two representations cannot drift. - New per-theme "surface" group: the clean neutral role vocabulary (window/node_body/inset/inset_deep/field/border/border_strong/divider/ handle/handle_hover/chrome_inactive + a 6-tier text ramp). Dark values equal the exact hexes that dominated the swept files, so the migration is essentially byte-neutral in the default theme; mono/muted derive from each theme's existing neutrals. - New per-theme "syntax" group: PythonHighlighter's palette relocated from graphlink_pycoder.py so code-node colors theme with everything else. - FONT_FAMILY_NAME (bare name for QFont) beside the existing QSS stack; get_surface_color()/get_syntax_color() lookups in graphlink_config. Sweep - ~500 hardcoded hex literals and ~40 string-pasted "Segoe UI" occurrences across ~35 UI files replaced with token lookups (4 parallel sweep passes + a centrally-adjudicated finalization: near-role neutrals deduped onto the role scale within a sanctioned sub-perceptual tolerance; semantic/data colors either matched byte-exact to existing semantic roles, promoted into the new groups, or explicitly allowlisted). Module-level scrollbar style constants became functions so stylesheets resolve the LIVE theme instead of freezing at import. Persisted scene-data defaults (container color) deduped onto one documented constant. Gate - tests/test_ui_token_acceptance.py codifies the P0 acceptance criterion permanently: no hex literal and no "Segoe UI" outside graphlink_styles.py, excepting a small reasoned allowlist (export CSS, user-pickable presets, persisted data defaults, the per-theme QMenu table that phase P8 retires); plus structure-scale conformance (4px grid, 12px type floor, 3 elevation levels). Verified: full pytest 1742 passed (acceptance gate green, export round-trip/count/shape tests updated for the new groups); vitest 533 passed; all 19 islands rebuilt against the extended token sheet; real app launch clean (window up, zero log errors). Co-Authored-By: Claude Fable 5 --- .../graphlink_canvas/graphlink_canvas_base.py | 22 +- .../graphlink_canvas_chart_item.py | 25 +- .../graphlink_canvas_container.py | 22 +- .../graphlink_canvas_dialogs.py | 36 +-- .../graphlink_canvas_frame.py | 34 +-- .../graphlink_canvas_navigation_pin.py | 19 +- .../graphlink_canvas/graphlink_canvas_note.py | 27 +-- graphlink_app/graphlink_config.py | 34 ++- graphlink_app/graphlink_connections.py | 16 +- graphlink_app/graphlink_context_menu.py | 25 +- graphlink_app/graphlink_conversation_node.py | 39 ++-- graphlink_app/graphlink_html_view.py | 132 +++++------ graphlink_app/graphlink_lod.py | 39 ++-- .../graphlink_nodes/graphlink_node_chat.py | 51 ++-- .../graphlink_nodes/graphlink_node_code.py | 15 +- .../graphlink_node_document.py | 47 ++-- .../graphlink_nodes/graphlink_node_image.py | 10 +- .../graphlink_node_thinking.py | 29 +-- .../graphlink_plugins/common/combo.py | 16 +- .../graphlink_plugin_artifact.py | 180 ++++++++------- .../graphlink_plugin_code_sandbox.py | 210 ++++++++--------- .../graphlink_plugin_gitlink.py | 166 ++++++------- .../graphlink_plugin_portal.py | 4 +- graphlink_app/graphlink_pycoder.py | 159 ++++++------- graphlink_app/graphlink_scene.py | 8 +- .../graphlink_session/deserializers.py | 3 +- graphlink_app/graphlink_styles.py | 218 +++++++++++++++++- .../graphlink_library_dialog.py | 8 +- graphlink_app/graphlink_view.py | 4 +- graphlink_app/graphlink_web.py | 55 ++--- graphlink_app/graphlink_widgets/overlays.py | 31 +-- graphlink_app/graphlink_widgets/scrolling.py | 9 +- graphlink_app/graphlink_widgets/splash.py | 23 +- .../tests/test_css_custom_properties.py | 21 +- .../tests/test_tailwind_theme_css.py | 30 ++- graphlink_app/tests/test_theme_tokens.py | 10 +- .../tests/test_ui_token_acceptance.py | 184 +++++++++++++++ web_ui/src/lib/tokens/gl-theme.css | 47 ++++ web_ui/src/lib/tokens/gl-vars-dev.css | 47 ++++ 39 files changed, 1328 insertions(+), 727 deletions(-) create mode 100644 graphlink_app/tests/test_ui_token_acceptance.py diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_base.py b/graphlink_app/graphlink_canvas/graphlink_canvas_base.py index 34ec440..c38de02 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_base.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_base.py @@ -4,7 +4,7 @@ from PySide6.QtCore import Qt, QRectF, QTimer, Signal from PySide6.QtGui import QPainter, QColor, QBrush, QPen -from graphlink_config import get_current_palette +from graphlink_config import get_current_palette, get_surface_color class HoverAnimationMixin: @@ -145,19 +145,19 @@ def __init__(self, parent=None): self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setStyleSheet( - """ - QLineEdit { + f""" + QLineEdit {{ background-color: rgba(26, 26, 26, 0.96); - color: #f1f1f1; - border: 1px solid #5a5a5a; + color: {get_surface_color("text_strong")}; + border: 1px solid {get_surface_color("handle")}; border-radius: 5px; padding: 4px 8px; - selection-background-color: #5a5a5a; - selection-color: #ffffff; - } - QLineEdit:focus { - border-color: #7a7a7a; - } + selection-background-color: {get_surface_color("handle")}; + selection-color: {get_surface_color("text_bright")}; + }} + QLineEdit:focus {{ + border-color: {get_surface_color("text_muted")}; + }} """ ) self.editingFinished.connect(self._emit_commit_if_needed) diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_chart_item.py b/graphlink_app/graphlink_canvas/graphlink_canvas_chart_item.py index e15b325..d53d553 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_chart_item.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_chart_item.py @@ -19,9 +19,10 @@ from PySide6.QtCore import Qt, QRectF, QPointF, QSizeF, QTimer, QStandardPaths from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QFont, QPainterPath, QImage, QLinearGradient -from graphlink_config import get_current_palette, get_graph_node_colors +from graphlink_config import get_current_palette, get_graph_node_colors, get_surface_color from graphlink_chart_data import ChartDataError, canonicalize_chart_data from graphlink_context_menu import create_context_menu +from graphlink_styles import FONT_FAMILY_NAME class ChartItem(QGraphicsItem): @@ -79,9 +80,9 @@ def __init__(self, data, pos, parent=None, parent_content_node=None): self.aspect_ratio_locked = True self._base_aspect_ratio = self.DEFAULT_WIDTH / self.DEFAULT_HEIGHT self.resize_start_aspect_ratio = self._base_aspect_ratio - self.font_family = "Segoe UI" + self.font_family = FONT_FAMILY_NAME self.font_size = 10 - self.font_color = QColor("#DDDDDD") + self.font_color = QColor(get_surface_color("text_primary")) self.figure = Figure(figsize=(6, 4), dpi=160) self.canvas = FigureCanvasAgg(self.figure) @@ -216,12 +217,12 @@ def _mpl_rgba(color, alpha=1.0): return (color.redF(), color.greenF(), color.blueF(), alpha) def _build_theme(self, palette): - surface = QColor("#1B1B1B") - panel = QColor("#151515") - border = QColor("#474747") - text = QColor("#F7F7F7") - muted = QColor("#A5A5A5") - grid = QColor("#9A9A9A") + surface = QColor(get_surface_color("window")) + panel = QColor(get_surface_color("inset_deep")) + border = QColor(get_surface_color("divider")) + text = QColor(get_surface_color("text_bright")) + muted = QColor(get_surface_color("text_label")) + grid = QColor(get_surface_color("text_label")) primary = QColor(palette.AI_NODE) secondary = QColor(palette.USER_NODE) selection = QColor(palette.SELECTION) @@ -810,7 +811,7 @@ def _render_chart_content(self, theme): return self.chart_image def update_font_settings(self, family, size, color): - self.font_family = str(family or "Segoe UI") + self.font_family = str(family or FONT_FAMILY_NAME) self.font_size = max(6, min(32, int(size))) self.font_color = QColor(color) self.generate_chart() @@ -925,7 +926,7 @@ def paint(self, painter, option, widget=None): painter.drawText(header_rect.adjusted(12, 0, -120, 0), Qt.AlignmentFlag.AlignVCenter, self.title) badge_rect = QRectF(self.width - 102, 8, 90, 24) - painter.setPen(QPen(self._with_alpha(QColor("#FFFFFF"), 65), 1)) + painter.setPen(QPen(self._with_alpha(QColor(get_surface_color("text_bright")), 65), 1)) painter.setBrush(QBrush(node_colors["badge_fill"])) painter.drawRoundedRect(badge_rect, 12, 12) painter.setPen(QPen(self.font_color)) @@ -943,7 +944,7 @@ def paint(self, painter, option, widget=None): if self.hovered or self.isSelected(): handle_size = 10 handle_rect = QRectF(self.width - handle_size, self.height - handle_size, handle_size, handle_size) - painter.setPen(QPen(QColor("#FFFFFF"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")))) painter.drawLine(handle_rect.topLeft(), handle_rect.bottomRight()) painter.drawLine( handle_rect.topLeft() + QPointF(0, handle_size / 2), diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_container.py b/graphlink_app/graphlink_canvas/graphlink_canvas_container.py index 1a612bb..e1b2df7 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_container.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_container.py @@ -8,7 +8,15 @@ from .graphlink_canvas_base import CanvasHeaderLineEdit, GhostFrame, update_connections_for_items from .graphlink_canvas_dialogs import ColorPickerDialog -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_surface_color + +# The container's user-facing body color DEFAULT. Persisted scene DATA (saved +# into and restored from chat JSON, see graphlink_session/serializers + +# deserializers), not theme chrome - deliberately NOT a THEME_TOKENS lookup: +# a saved container must keep its color across theme switches, exactly like +# DEFAULT_GRID_COLOR in graphlink_grid_view_settings. The one allowed literal +# home for this value (see tests/test_ui_token_acceptance.py's allowlist). +DEFAULT_CONTAINER_COLOR = "#3a3a3a" class Container(QGraphicsItem): @@ -46,7 +54,7 @@ def __init__(self, items, parent=None): self.expanded_rect = QRectF() # Caches the size before collapsing. self.rect = QRectF() - self.color = "#3a3a3a" + self.color = DEFAULT_CONTAINER_COLOR self.header_color = None # Rects for hover detection of UI buttons in the header. @@ -351,7 +359,7 @@ def paint(self, painter, option, widget=None): self.collapse_button_rect = QRectF(self.rect.right() - 34, self.rect.top() + 10, 24, 24) # Draw the title text in collapsed mode. - painter.setPen(QColor("#ffffff")) + painter.setPen(QColor(get_surface_color("text_bright"))) font = canvas_font(self.scene(), delta=2, weight=QFont.Weight.Bold) painter.setFont(font) title_rect = QRectF(self.rect.left() + 14, self.rect.top(), self.rect.width() - 56, self.rect.height()) @@ -360,7 +368,7 @@ def paint(self, painter, option, widget=None): expand_icon = qta.icon( 'fa5s.expand-arrows-alt', - color='#ffffff' if self.collapse_button_hovered or self.hovered else '#9a9a9a', + color=get_surface_color("text_bright") if self.collapse_button_hovered or self.hovered else get_surface_color("text_label"), ) expand_icon.paint(painter, self.collapse_button_rect.adjusted(3, 3, -3, -3).toRect()) return @@ -407,7 +415,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, self.collapse_button_rect.adjusted(4, 4, -4, -4).toRect()) # Draw Color Button - painter.setPen(QPen(QColor("#ffffff") if self.color_button_hovered else node_colors["border"])) + painter.setPen(QPen(QColor(get_surface_color("text_bright")) if self.color_button_hovered else node_colors["border"])) painter.setBrush(QBrush(header_base_color)) painter.drawEllipse(self.color_button_rect) @@ -419,7 +427,7 @@ def paint(self, painter, option, widget=None): painter.drawEllipse(center + QPointF(6, 0), 2, 2) # Draw the title, either in display or editing mode. - painter.setPen(QPen(QColor("#ffffff"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")))) font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) text_rect = self._header_text_rect() @@ -522,7 +530,7 @@ def show_color_picker(self): if dialog.exec() == QDialog.DialogCode.Accepted: color, color_type = dialog.get_selected_color() if color_type == "default": - self.color = "#3a3a3a" + self.color = DEFAULT_CONTAINER_COLOR self.header_color = None elif color_type == "full": self.color = color diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_dialogs.py b/graphlink_app/graphlink_canvas/graphlink_canvas_dialogs.py index f347671..b901efc 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_dialogs.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_dialogs.py @@ -5,7 +5,7 @@ QDialog, QVBoxLayout, QWidget, QHBoxLayout, QLineEdit, QLabel, QPushButton, QGraphicsDropShadowEffect, QTextEdit, QGridLayout, QApplication ) -from graphlink_config import get_current_palette, get_semantic_color +from graphlink_config import get_current_palette, get_semantic_color, get_surface_color class ColorPickerDialog(QDialog): """ @@ -74,7 +74,7 @@ def __init__(self, parent=None): def create_section(title, color_type, names_list): label = QLabel(title) - label.setStyleSheet("color: #CCCCCC; font-size: 10px; margin-top: 5px;") + label.setStyleSheet(f"color: {get_surface_color('text_soft')}; font-size: 10px; margin-top: 5px;") main_layout.addWidget(label) grid_layout = QGridLayout() @@ -91,18 +91,18 @@ def create_section(title, color_type, names_list): btn.setCursor(Qt.CursorShape.PointingHandCursor) style = f""" - QPushButton {{ background-color: {color_data["color"]}; border: 2px solid #3F3F3F; border-radius: 14px; }} - QPushButton:hover {{ border: 2px solid #FFFFFF; }} + QPushButton {{ background-color: {color_data["color"]}; border: 2px solid {get_surface_color("border")}; border-radius: 14px; }} + QPushButton:hover {{ border: 2px solid {get_surface_color("text_bright")}; }} """ if color_type == "header": style = f""" QPushButton {{ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {color_data["color"]}, stop:0.4 {color_data["color"]}, - stop:0.41 #3F3F3F, stop:1 #3F3F3F); - border: 2px solid #3F3F3F; border-radius: 14px; + stop:0.41 {get_surface_color("border")}, stop:1 {get_surface_color("border")}); + border: 2px solid {get_surface_color("border")}; border-radius: 14px; }} - QPushButton:hover {{ border: 2px solid #FFFFFF; }} + QPushButton:hover {{ border: 2px solid {get_surface_color("text_bright")}; }} """ btn.setStyleSheet(style) btn.setProperty("frame_color_data", color_data) @@ -124,20 +124,20 @@ def create_section(title, color_type, names_list): main_layout.addStretch() - self.setStyleSheet(""" - QDialog { background: transparent; } - QWidget#colorPickerContainer { background-color: #252525; border-radius: 8px; } - QLabel#colorPickerTitle { color: #FFFFFF; font-size: 12px; font-weight: bold; } - QPushButton { background-color: #3F3F3F; border-radius: 5px; padding: 8px; } - QPushButton:hover { background-color: #555555; } - QPushButton#colorPickerCloseButton { + self.setStyleSheet(f""" + QDialog {{ background: transparent; }} + QWidget#colorPickerContainer {{ background-color: {get_surface_color("node_body")}; border-radius: 8px; }} + QLabel#colorPickerTitle {{ color: {get_surface_color("text_bright")}; font-size: 12px; font-weight: bold; }} + QPushButton {{ background-color: {get_surface_color("border")}; border-radius: 5px; padding: 8px; }} + QPushButton:hover {{ background-color: {get_surface_color("handle")}; }} + QPushButton#colorPickerCloseButton {{ background-color: transparent; border-radius: 12px; padding: 0px; - } - QPushButton#colorPickerCloseButton:hover { - background-color: #4A4A4A; - } + }} + QPushButton#colorPickerCloseButton:hover {{ + background-color: {get_surface_color("border_strong")}; + }} """) self.selected_color = None diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py b/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py index 926efa3..640e075 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_frame.py @@ -10,7 +10,7 @@ from .graphlink_canvas_base import CanvasHeaderLineEdit, update_connections_for_items from .graphlink_canvas_dialogs import ColorPickerDialog -from graphlink_config import canvas_font, get_current_palette, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_semantic_color, get_surface_color class Frame(QGraphicsItem): @@ -47,8 +47,8 @@ def __init__(self, nodes, parent=None): # Load icons for the lock/unlock button. # QtAwesome's legacy ``fa`` prefix is not installed in current releases; # use the Font Awesome 5 solid set used by the rest of the canvas. - self.lock_icon = qta.icon('fa5s.lock', color='#ffffff') - self.unlock_icon = qta.icon('fa5s.unlock-alt', color='#ffffff') + self.lock_icon = qta.icon('fa5s.lock', color=get_surface_color("text_bright")) + self.unlock_icon = qta.icon('fa5s.unlock-alt', color=get_surface_color("text_bright")) self.lock_icon_hover = qta.icon('fa5s.lock', color=get_semantic_color("status_info").name()) self.unlock_icon_hover = qta.icon('fa5s.unlock-alt', color=get_semantic_color("status_success").name()) @@ -57,7 +57,7 @@ def __init__(self, nodes, parent=None): self.is_collapsed = False self.expanded_rect = QRectF() self.rect = QRectF() - self.color = "#2d2d2d" + self.color = get_surface_color("node_body") self.header_color = None self.collapse_button_rect = QRectF(0, 0, 24, 24) @@ -557,7 +557,7 @@ def show_color_picker(self): if dialog.exec() == QDialog.DialogCode.Accepted: color, color_type = dialog.get_selected_color() if color_type == "default": - self.color = "#2d2d2d" + self.color = get_surface_color("node_body") self.header_color = None elif color_type == "full": self.color = color @@ -615,7 +615,7 @@ def paint(self, painter, option, widget=None): if self.is_collapsed: base_color = QColor(self.color) - outline_color = palette.SELECTION if self.isSelected() else palette.AI_NODE if self.hovered else QColor("#555555") + outline_color = palette.SELECTION if self.isSelected() else palette.AI_NODE if self.hovered else QColor(get_surface_color("handle")) path = QPainterPath() path.addRoundedRect(self.rect, 10, 10) @@ -627,7 +627,7 @@ def paint(self, painter, option, widget=None): self.lock_button_rect = QRectF() self.color_button_rect = QRectF() - painter.setPen(QPen(QColor("#ffffff"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")))) font = canvas_font(self.scene(), delta=1, weight=QFont.Weight.Bold) painter.setFont(font) title_rect = QRectF(self.rect.left() + 14, self.rect.top(), self.rect.width() - 56, self.rect.height()) @@ -636,7 +636,7 @@ def paint(self, painter, option, widget=None): expand_icon = qta.icon( 'fa5s.expand-arrows-alt', - color='#ffffff' if self.collapse_button_hovered or self.hovered else '#9a9a9a', + color=get_surface_color("text_bright") if self.collapse_button_hovered or self.hovered else get_surface_color("text_label"), ) expand_icon.paint(painter, self.collapse_button_rect.adjusted(3, 3, -3, -3).toRect()) return @@ -653,7 +653,7 @@ def paint(self, painter, option, widget=None): elif self.hovered: outline_color = palette.AI_NODE else: - outline_color = QColor("#555555") + outline_color = QColor(get_surface_color("handle")) path = QPainterPath() path.addRoundedRect(self.rect, 10, 10) @@ -688,19 +688,19 @@ def paint(self, painter, option, widget=None): # Draw collapse button. self.collapse_button_rect = QRectF(self.rect.right() - 102, self.rect.top() + 8, 24, 24) - painter.setPen(QPen(QColor("#ffffff") if self.collapse_button_hovered else QColor("#555555"))) - painter.setBrush(QBrush(QColor("#3f3f3f"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")) if self.collapse_button_hovered else QColor(get_surface_color("handle")))) + painter.setBrush(QBrush(QColor(get_surface_color("border")))) painter.drawEllipse(self.collapse_button_rect) collapse_icon = qta.icon( 'fa5s.compress-arrows-alt', - color='#ffffff' if self.collapse_button_hovered else '#bbbbbb', + color=get_surface_color("text_bright") if self.collapse_button_hovered else get_surface_color("text_secondary"), ) collapse_icon.paint(painter, self.collapse_button_rect.adjusted(4, 4, -4, -4).toRect()) # Draw lock button. self.lock_button_rect = QRectF(self.rect.right() - 68, self.rect.top() + 8, 24, 24) - painter.setPen(QPen(palette.USER_NODE if self.lock_button_hovered else QColor("#555555"))) - painter.setBrush(QBrush(QColor("#3f3f3f"))) + painter.setPen(QPen(palette.USER_NODE if self.lock_button_hovered else QColor(get_surface_color("handle")))) + painter.setBrush(QBrush(QColor(get_surface_color("border")))) painter.drawEllipse(self.lock_button_rect) icon = self.lock_icon_hover if self.is_locked and self.lock_button_hovered else self.lock_icon if self.is_locked else self.unlock_icon_hover if self.lock_button_hovered else self.unlock_icon icon_size = 18 @@ -711,10 +711,10 @@ def paint(self, painter, option, widget=None): # Draw color button. self.color_button_rect = QRectF(self.rect.right() - 34, self.rect.top() + 8, 24, 24) - painter.setPen(QPen(QColor("#ffffff") if self.color_button_hovered else QColor("#555555"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")) if self.color_button_hovered else QColor(get_surface_color("handle")))) painter.setBrush(QBrush(QColor(self.header_color if self.header_color else self.color))) painter.drawEllipse(self.color_button_rect) - icon_color = QColor("#ffffff"); icon_color.setAlpha(180) + icon_color = QColor(get_surface_color("text_bright")); icon_color.setAlpha(180) painter.setPen(QPen(icon_color)) circle_size, spacing = 4, 3 total_width = (circle_size * 3) + (spacing * 2) @@ -725,7 +725,7 @@ def paint(self, painter, option, widget=None): painter.drawEllipse(QRectF(x_pos, y_pos, circle_size, circle_size)) # Draw title (note). - painter.setPen(QPen(QColor("#ffffff"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")))) font = canvas_font(self.scene()) painter.setFont(font) text_rect = self._header_text_rect() diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_navigation_pin.py b/graphlink_app/graphlink_canvas/graphlink_canvas_navigation_pin.py index 33ce115..305ce1e 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_navigation_pin.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_navigation_pin.py @@ -9,6 +9,9 @@ from PySide6.QtCore import Qt, QRectF, Signal from PySide6.QtGui import QPainter, QColor, QPen, QFont, QFontMetrics, QPainterPath +from graphlink_config import get_surface_color +from graphlink_styles import FONT_FAMILY_NAME + class NavigationPin(QGraphicsObject): """ @@ -75,15 +78,15 @@ def paint(self, painter, option, widget=None): selected = self.isSelected() active = selected or self.hovered - marker_fill = QColor("#c8c8c8" if selected else "#a0a0a0" if self.hovered else "#777777") - marker_border = QColor("#f2f2f2" if selected else "#bdbdbd" if self.hovered else "#555555") - surface = QColor("#252525") + marker_fill = QColor(get_surface_color("text_soft") if selected else get_surface_color("text_label") if self.hovered else get_surface_color("text_muted")) + marker_border = QColor(get_surface_color("text_strong") if selected else get_surface_color("text_secondary") if self.hovered else get_surface_color("handle")) + surface = QColor(get_surface_color("node_body")) # A beacon-style marker makes navigation pins visually distinct from # connection pins: rounded square body, inset waypoint core, stem, and # ground contact. It deliberately uses grayscale only. if active: - halo = QColor("#d8d8d8") + halo = QColor(get_surface_color("text_primary")) halo.setAlpha(35 if not selected else 55) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(halo) @@ -104,8 +107,8 @@ def paint(self, painter, option, widget=None): painter.drawEllipse(QRectF(-5.0, 25.0, 10.0, 7.0)) if active: - label_surface = QColor("#242424") - label_border = QColor("#777777" if selected else "#555555") + label_surface = QColor(get_surface_color("node_body")) + label_border = QColor(get_surface_color("text_muted") if selected else get_surface_color("handle")) painter.setPen(QPen(label_border, 1.0)) painter.setBrush(label_surface) painter.drawRoundedRect(self._LABEL_RECT, 10.0, 10.0) @@ -114,10 +117,10 @@ def paint(self, painter, option, widget=None): painter.setPen(QPen(label_border, 1.0)) painter.drawLine(0.0, self._LABEL_RECT.bottom(), 0.0, -15.0) - font = QFont("Segoe UI", 8) + font = QFont(FONT_FAMILY_NAME, 8) font.setWeight(QFont.Weight.DemiBold if selected else QFont.Weight.Normal) painter.setFont(font) - painter.setPen(QColor("#f0f0f0")) + painter.setPen(QColor(get_surface_color("text_strong"))) title = QFontMetrics(font).elidedText( str(self.title or "Waypoint"), Qt.TextElideMode.ElideRight, 142 ) diff --git a/graphlink_app/graphlink_canvas/graphlink_canvas_note.py b/graphlink_app/graphlink_canvas/graphlink_canvas_note.py index 33678bd..46b4417 100644 --- a/graphlink_app/graphlink_canvas/graphlink_canvas_note.py +++ b/graphlink_app/graphlink_canvas/graphlink_canvas_note.py @@ -12,7 +12,8 @@ ) from .graphlink_canvas_dialogs import ColorPickerDialog -from graphlink_config import canvas_font, get_current_palette +from graphlink_config import canvas_font, get_current_palette, get_surface_color +from graphlink_styles import FONT_FAMILY_NAME from graphlink_widgets import ScrollBar @@ -58,7 +59,7 @@ def __init__(self, pos, parent=None): # Geometry and appearance self.width = self.DEFAULT_WIDTH self.height = self.DEFAULT_HEIGHT - self.color = "#2d2d2d" + self.color = get_surface_color("node_body") self.header_color = None # State for in-place text editing @@ -119,7 +120,7 @@ def _setup_document(self): Configures the QTextDocument for rendering, applying styles from the scene and converting the Markdown content to HTML. """ - font_family, font_size, color = "Segoe UI", 10, "#dddddd" + font_family, font_size, color = FONT_FAMILY_NAME, 10, get_surface_color("text_primary") if self.scene(): font_family = self.scene().font_family @@ -128,7 +129,7 @@ def _setup_document(self): stylesheet = f""" p, ul, ol, li, blockquote {{ color: {color}; font-family: '{font_family}'; font-size: {font_size}pt; }} - pre {{ background-color: #1e1e1e; padding: 8px; border-radius: 4px; white-space: pre-wrap; font-family: Consolas, monospace; }} + pre {{ background-color: {get_surface_color("window")}; padding: 8px; border-radius: 4px; white-space: pre-wrap; font-family: Consolas, monospace; }} """ self.document.setDefaultStyleSheet(stylesheet) @@ -219,9 +220,9 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - pen = QPen(QColor("#555555")) + pen = QPen(QColor(get_surface_color("handle"))) if self.isSelected(): pen = QPen(palette.SELECTION, 2) - elif self.hovered: pen = QPen(QColor("#ffffff"), 2) + elif self.hovered: pen = QPen(QColor(get_surface_color("text_bright")), 2) # Special outline for system prompt notes. if self.is_system_prompt: @@ -231,8 +232,8 @@ def paint(self, painter, option, widget=None): painter.setPen(pen) gradient = QLinearGradient(QPointF(0, 0), QPointF(0, self.height)) - gradient.setColorAt(0, QColor("#4a4a4a")) - gradient.setColorAt(1, QColor("#2d2d2d")) + gradient.setColorAt(0, QColor(get_surface_color("border_strong"))) + gradient.setColorAt(1, QColor(get_surface_color("node_body"))) painter.setBrush(QBrush(gradient)) painter.drawPath(path) @@ -256,16 +257,16 @@ def paint(self, painter, option, widget=None): # Draw header icons for special note types. icon_rect = QRectF(10, (self.HEADER_HEIGHT - 16) / 2, 16, 16) if self.is_system_prompt: - qta.icon('fa5s.cog', color='#ffffff').paint(painter, icon_rect.toRect()) + qta.icon('fa5s.cog', color=get_surface_color("text_bright")).paint(painter, icon_rect.toRect()) elif self.is_summary_note: - qta.icon('fa5s.object-group', color='#ffffff').paint(painter, icon_rect.toRect()) + qta.icon('fa5s.object-group', color=get_surface_color("text_bright")).paint(painter, icon_rect.toRect()) # Draw the color picker button. self.color_button_rect = QRectF(self.width - 34, 8, 24, 24) - painter.setPen(QPen(QColor("#ffffff") if self.color_button_hovered else QColor("#555555"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")) if self.color_button_hovered else QColor(get_surface_color("handle")))) painter.setBrush(QBrush(header_base_color)) painter.drawEllipse(self.color_button_rect) - icon_color = QColor("#ffffff"); icon_color.setAlpha(180) + icon_color = QColor(get_surface_color("text_bright")); icon_color.setAlpha(180) painter.setPen(QPen(icon_color)) circle_size, spacing = 4, 3 total_width = (circle_size * 3) + (spacing * 2) @@ -276,7 +277,7 @@ def paint(self, painter, option, widget=None): painter.drawEllipse(QRectF(x_pos, y_pos, circle_size, circle_size)) # --- Content Rendering --- - painter.setPen(QPen(QColor("#ffffff"))) + painter.setPen(QPen(QColor(get_surface_color("text_bright")))) font = canvas_font(self.scene()) painter.setFont(font) diff --git a/graphlink_app/graphlink_config.py b/graphlink_app/graphlink_config.py index c4bf96c..8833f73 100644 --- a/graphlink_app/graphlink_config.py +++ b/graphlink_app/graphlink_config.py @@ -1,6 +1,6 @@ from PySide6.QtGui import QColor, QFont from PySide6.QtWidgets import QApplication -from graphlink_styles import THEME_TOKENS, THEMES +from graphlink_styles import FONT_FAMILY_NAME, THEME_TOKENS, THEMES CURRENT_THEME = "dark" @@ -15,15 +15,41 @@ def canvas_font(scene=None, delta=0, weight=QFont.Weight.Normal): reach their headers. Keeping this small helper in the shared config module makes those headers follow the same family and scale as document-backed nodes. """ - family = getattr(scene, "font_family", "Segoe UI") if scene else "Segoe UI" + family = getattr(scene, "font_family", FONT_FAMILY_NAME) if scene else FONT_FAMILY_NAME base_size = getattr(scene, "font_size", 10) if scene else 10 font = QFont(family, max(1, int(base_size) + int(delta)), weight) return font -def canvas_font_color(scene=None, fallback="#DDDDDD"): +def canvas_font_color(scene=None, fallback=None): color = getattr(scene, "font_color", None) if scene else None - return QColor(color) if color is not None else QColor(fallback) + if color is not None: + return QColor(color) + if fallback is not None: + return QColor(fallback) + # UI-refactor P0: the default falls back to the theme's primary text + # surface role instead of a hardcoded literal. + return QColor(get_surface_color("text_primary")) + + +def get_syntax_color(name: str): + """Look up a syntax-highlight role's hex string for the current theme + (keyword/builtin/number/string/comment/function). Sweep adjudication + moved PythonHighlighter's palette into THEME_TOKENS; this is its lookup.""" + return THEME_TOKENS[CURRENT_THEME]["syntax"][name] + + +def get_surface_color(name: str): + """Look up a neutral surface/text role's hex string for the current theme. + + UI-refactor P0 (doc/UI_QA_AUDIT.md section 7): the lookup the hex-literal + sweep migrated node/canvas/widget chrome onto. Mirrors + get_semantic_color()'s table-lookup shape but returns the raw hex string + rather than a QColor - the dominant call sites are f-string stylesheets, + and QColor construction is one wrap away for the painting sites that + need it.""" + tokens = THEME_TOKENS[CURRENT_THEME]["surface"] + return tokens[name] def is_monochrome_theme(): diff --git a/graphlink_app/graphlink_connections.py b/graphlink_app/graphlink_connections.py index 1c20dfb..ce6b93c 100644 --- a/graphlink_app/graphlink_connections.py +++ b/graphlink_app/graphlink_connections.py @@ -9,7 +9,7 @@ from graphlink_canvas.graphlink_canvas_base import iter_scene_connection_lists from graphlink_canvas_items import Container, Frame, Note -from graphlink_config import get_current_palette +from graphlink_config import get_current_palette, get_surface_color from graphlink_pycoder import PyCoderNode from graphlink_conversation_node import ConversationNode from graphlink_html_view import HtmlViewNode @@ -103,7 +103,7 @@ def paint(self, painter, option, widget=None): elif self.hover: color = palette.AI_NODE else: - color = QColor("#FFFFFF") + color = QColor(get_surface_color("text_bright")) painter.setPen(QPen(color.darker(120), 1)) painter.setBrush(QBrush(color)) @@ -947,13 +947,13 @@ def drawArrow(self, painter, pos, color): def paint(self, painter, option, widget=None): """Paints the dashed connection line.""" painter.setRenderHint(QPainter.RenderHint.Antialiasing) - pen = QPen(QColor("#888888"), 1.5, Qt.PenStyle.DashLine) + pen = QPen(QColor(get_surface_color("chrome_inactive")), 1.5, Qt.PenStyle.DashLine) painter.setPen(pen) painter.drawPath(self.path) if self.is_animating: for arrow in self.arrows: - self.drawArrow(painter, arrow['pos'], QColor("#888888")) + self.drawArrow(painter, arrow['pos'], QColor(get_surface_color("chrome_inactive"))) def hoverEnterEvent(self, event): """Handles hover enter event.""" @@ -1396,7 +1396,7 @@ def paint(self, painter, option, widget=None): palette = get_current_palette() painter.setRenderHint(QPainter.RenderHint.Antialiasing) - pen_color = QColor("#A2A2A2") # A soft gray-blue + pen_color = QColor(get_surface_color("text_label")) if self.hover: pen_color = pen_color.lighter(130) @@ -1928,7 +1928,7 @@ def update_path(self): def paint(self, painter, option, widget=None): """Paints the gray dashed line for the summary connection.""" painter.setRenderHint(QPainter.RenderHint.Antialiasing) - pen = QPen(QColor("#888888"), 1.5, Qt.PenStyle.DashLine) + pen = QPen(QColor(get_surface_color("chrome_inactive")), 1.5, Qt.PenStyle.DashLine) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.drawPath(self.path) @@ -1954,9 +1954,9 @@ def drawArrow(self, painter, pos, opacity): painter.translate(point) painter.rotate(-angle) - color = QColor("#888888") + color = QColor(get_surface_color("chrome_inactive")) if self.hover: - color = QColor("#BBBBBB") + color = QColor(get_surface_color("text_secondary")) color.setAlphaF(opacity) painter.setBrush(color) diff --git a/graphlink_app/graphlink_context_menu.py b/graphlink_app/graphlink_context_menu.py index 920e2ef..703c7a1 100644 --- a/graphlink_app/graphlink_context_menu.py +++ b/graphlink_app/graphlink_context_menu.py @@ -15,35 +15,36 @@ from PySide6.QtGui import QColor, QPalette from PySide6.QtWidgets import QApplication, QMenu, QWidget -from graphlink_config import get_current_palette, is_monochrome_theme, is_muted_theme +from graphlink_config import get_current_palette, get_surface_color, is_monochrome_theme, is_muted_theme +from graphlink_styles import FONT_FAMILY def _colors() -> dict[str, str]: """Return the menu colors for the active Graphlink theme.""" if is_monochrome_theme(): return { - "surface": "#2A2A2A", - "text": "#DDDDDD", - "border": "#444444", + "surface": get_surface_color("field"), + "text": get_surface_color("text_primary"), + "border": get_surface_color("border"), "hover": "#666666", "disabled": "#777777", } if is_muted_theme(): return { - "surface": "#232323", - "text": "#D1D1D1", - "border": "#383838", + "surface": get_surface_color("field"), + "text": get_surface_color("text_primary"), + "border": get_surface_color("border"), "hover": "#707070", "disabled": "#707070", } return { - "surface": "#272727", - "text": "#DCDCDC", - "border": "#424242", + "surface": get_surface_color("field"), + "text": get_surface_color("text_primary"), + "border": get_surface_color("divider"), "hover": get_current_palette().SELECTION.name(), - "disabled": "#767676", + "disabled": get_surface_color("text_muted"), } @@ -57,7 +58,7 @@ def context_menu_stylesheet() -> str: border: 1px solid {colors['border']}; border-radius: 8px; padding: 4px; - font-family: 'Segoe UI', sans-serif; + font-family: {FONT_FAMILY}; font-size: 12px; }} QMenu::item {{ diff --git a/graphlink_app/graphlink_conversation_node.py b/graphlink_app/graphlink_conversation_node.py index b0aa523..67d3765 100644 --- a/graphlink_app/graphlink_conversation_node.py +++ b/graphlink_app/graphlink_conversation_node.py @@ -7,7 +7,8 @@ from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QTextDocument, QAction, QCursor, QFont import qtawesome as qta import markdown -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color, get_surface_color +from graphlink_styles import FONT_FAMILY from graphlink_canvas_items import HoverAnimationMixin from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu @@ -29,12 +30,12 @@ def __init__(self, text, is_user, timestamp=None, parent=None): self.document = QTextDocument() palette = get_current_palette() - self.document.setDefaultStyleSheet(""" - p, ul, ol, li, blockquote { color: #e0e0e0; margin: 0; font-family: 'Segoe UI'; font-size: 12px; } - pre { background-color: #1e1e1e; padding: 8px; border-radius: 4px; white-space: pre-wrap; font-family: Consolas, monospace; } - a { color: %s; } - .timestamp { color: #666666; font-size: 9px; } - """ % palette.AI_NODE.name()) + self.document.setDefaultStyleSheet(f""" + p, ul, ol, li, blockquote {{ color: {get_surface_color("text_primary")}; margin: 0; font-family: {FONT_FAMILY}; font-size: 12px; }} + pre {{ background-color: {get_surface_color("window")}; padding: 8px; border-radius: 4px; white-space: pre-wrap; font-family: Consolas, monospace; }} + a {{ color: {palette.AI_NODE.name()}; }} + .timestamp {{ color: {get_surface_color("handle_hover")}; font-size: 9px; }} + """) html_content = markdown.markdown(text, extensions=['fenced_code', 'tables']) # Append timestamp to the HTML @@ -138,7 +139,7 @@ def paint(self, painter, option, widget=None): painter.setRenderHint(QPainter.RenderHint.Antialiasing) path = QPainterPath() path.addRoundedRect(self.boundingRect(), 15, 15) - painter.setBrush(QColor("#323232")) + painter.setBrush(get_semantic_color("conversation_ai_bubble")) painter.setPen(Qt.PenStyle.NoPen) painter.drawPath(path) @@ -182,9 +183,9 @@ def __init__(self, parent_node, parent=None): self.widget = QWidget() self.widget.setObjectName("conversationMainWidget") self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#conversationMainWidget { background-color: transparent; color: #e0e0e0; } - QWidget#conversationMainWidget QLabel { background-color: transparent; } + self.widget.setStyleSheet(f""" + QWidget#conversationMainWidget {{ background-color: transparent; color: {get_surface_color("text_primary")}; }} + QWidget#conversationMainWidget QLabel {{ background-color: transparent; }} """) self._message_items = [] @@ -305,7 +306,7 @@ def _setup_ui(self): self.internal_view.setRenderHint(QPainter.RenderHint.Antialiasing) self.internal_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.internal_view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.internal_view.setStyleSheet("background-color: #1a1a1a; border: 1px solid #333; border-radius: 6px;") + self.internal_view.setStyleSheet(f"background-color: {get_surface_color('window')}; border: 1px solid {get_surface_color('border')}; border-radius: 6px;") main_layout.addWidget(self.internal_view) input_layout = QHBoxLayout() @@ -340,7 +341,7 @@ def _update_button_style(self): hover = self._blend_color(button_colors["hover"], accent, 0.30) pressed = self._blend_color(button_colors["pressed"], accent, 0.24) border = self._blend_color(button_colors["border"], accent, 0.42) - icon_color = button_colors["muted_icon"] if self._cancel_pending else QColor("#ffffff") + icon_color = button_colors["muted_icon"] if self._cancel_pending else QColor(get_surface_color("text_bright")) icon_name = 'fa5s.stop' tooltip = "Cancelling..." if self._cancel_pending else "Cancel response" else: @@ -516,13 +517,13 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2d2d2d")) - + painter.setBrush(QColor(get_surface_color("field"))) + node_color = node_colors["border"] pen = QPen(node_color, 1.5) if self.isSelected(): pen = QPen(palette.SELECTION, 2) - elif self.hovered: pen = QPen(QColor("#ffffff"), 2) + elif self.hovered: pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(pen) painter.drawPath(path) @@ -561,7 +562,7 @@ def paint(self, painter, option, widget=None): return if self.is_collapsed: - painter.setPen(QColor("#ffffff")) + painter.setPen(QColor(get_surface_color("text_bright"))) font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Conversation") @@ -570,7 +571,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRect(10, 10, 20, 20)) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - expand_icon = qta.icon('fa5s.expand-arrows-alt', color='#ffffff' if self.hovered else '#888888') + expand_icon = qta.icon('fa5s.expand-arrows-alt', color=get_surface_color("text_bright") if self.hovered else get_surface_color("chrome_inactive")) expand_icon.paint(painter, QRect(int(self.width - 30), 10, 20, 20)) else: if self.hovered: @@ -579,7 +580,7 @@ def paint(self, painter, option, widget=None): painter.setPen(QColor(255, 255, 255, 150)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(6,6,-6,-6), 4, 4) - icon_pen = QPen(QColor("#ffffff"), 2) + icon_pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(icon_pen) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) diff --git a/graphlink_app/graphlink_html_view.py b/graphlink_app/graphlink_html_view.py index 1bd7a7c..4e39b55 100644 --- a/graphlink_app/graphlink_html_view.py +++ b/graphlink_app/graphlink_html_view.py @@ -6,7 +6,7 @@ from PySide6.QtCore import QRectF, Qt, Signal, QPoint, QRect from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QCursor, QFont import qtawesome as qta -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color, get_surface_color from graphlink_canvas_items import HoverAnimationMixin from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu @@ -45,27 +45,27 @@ def __init__(self, parent_node, parent=None): layout.addWidget(self.web_view) def _inject_scrollbar_style(self): - css = """ - ::-webkit-scrollbar { + css = f""" + ::-webkit-scrollbar {{ width: 10px; height: 10px; - background-color: #252525; - } - ::-webkit-scrollbar-track { - background-color: #252525; + background-color: {get_surface_color("node_body")}; + }} + ::-webkit-scrollbar-track {{ + background-color: {get_surface_color("node_body")}; border-radius: 5px; - } - ::-webkit-scrollbar-thumb { - background-color: #555555; + }} + ::-webkit-scrollbar-thumb {{ + background-color: {get_surface_color("handle")}; border-radius: 5px; - border: 1px solid #252525; - } - ::-webkit-scrollbar-thumb:hover { - background-color: #6A6A6A; - } - ::-webkit-scrollbar-corner { + border: 1px solid {get_surface_color("node_body")}; + }} + ::-webkit-scrollbar-thumb:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + ::-webkit-scrollbar-corner {{ background: transparent; - } + }} """ js = f""" (function() {{ @@ -148,9 +148,9 @@ def __init__(self, parent_node, parent=None): self.widget = QWidget() self.widget.setObjectName("htmlViewMainWidget") self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#htmlViewMainWidget { background-color: transparent; color: #E0E0E0; } - QWidget#htmlViewMainWidget QLabel { background-color: transparent; } + self.widget.setStyleSheet(f""" + QWidget#htmlViewMainWidget {{ background-color: transparent; color: {get_surface_color("text_primary")}; }} + QWidget#htmlViewMainWidget QLabel {{ background-color: transparent; }} """) self._setup_ui() @@ -209,15 +209,15 @@ def _setup_ui(self): self.splitter = QSplitter(Qt.Orientation.Vertical) self.splitter.splitterMoved.connect(self._on_splitter_moved) - self.splitter.setStyleSheet(""" - QSplitter::handle:vertical { + self.splitter.setStyleSheet(f""" + QSplitter::handle:vertical {{ height: 8px; - background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3F3F3F, stop:0.5 #555555, stop:1 #3F3F3F); - } - QSplitter::handle:vertical:hover { - background: %s; - } - """ % get_semantic_color("status_success").name()) + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {get_surface_color("border")}, stop:0.5 {get_surface_color("handle")}, stop:1 {get_surface_color("border")}); + }} + QSplitter::handle:vertical:hover {{ + background: {get_semantic_color("status_success").name()}; + }} + """) # --- HTML Input Pane --- input_container = QWidget() @@ -246,13 +246,13 @@ def _setup_ui(self): preview_header_layout.addStretch() self.popout_button = QPushButton() - self.popout_button.setIcon(qta.icon('fa5s.external-link-alt', color='#ccc')) + self.popout_button.setIcon(qta.icon('fa5s.external-link-alt', color=get_surface_color("text_soft"))) self.popout_button.setFixedSize(28, 28) self.popout_button.setToolTip("Open Preview in a New Window") self.popout_button.clicked.connect(self._handle_popout) - self.popout_button.setStyleSheet(""" - QPushButton { border: 1px solid #555; border-radius: 4px; } - QPushButton:hover { background-color: #4F4F4F; } + self.popout_button.setStyleSheet(f""" + QPushButton {{ border: 1px solid {get_surface_color("handle")}; border-radius: 4px; }} + QPushButton:hover {{ background-color: {get_surface_color("border_strong")}; }} """) preview_header_layout.addWidget(self.popout_button) @@ -267,7 +267,7 @@ def _setup_ui(self): self.web_view = QWebEngineView() _harden_preview_web_view(self.web_view) self._inject_scrollbar_style() - self.web_view.setStyleSheet("background-color: #FFFFFF; border-radius: 4px;") + self.web_view.setStyleSheet("background-color: white; border-radius: 4px;") webview_layout.addWidget(self.web_view) else: self.popout_button.setEnabled(False) @@ -278,8 +278,8 @@ def _setup_ui(self): ) error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) error_label.setStyleSheet( - "background-color: #252525; border: 1px dashed #555;" - "color: #888; border-radius: 4px; padding: 20px;" + f"background-color: {get_surface_color('node_body')}; border: 1px dashed {get_surface_color('handle')};" + f"color: {get_surface_color('chrome_inactive')}; border-radius: 4px; padding: 20px;" ) webview_layout.addWidget(error_label) self.render_button.setEnabled(False) @@ -294,12 +294,12 @@ def _setup_ui(self): self.splitter_state = self.splitter.sizes() for widget in [self.html_input]: - widget.setStyleSheet(""" - QTextEdit { - background-color: #252525; border: 1px solid #3F3F3F; - color: #CCCCCC; border-radius: 4px; padding: 5px; + widget.setStyleSheet(f""" + QTextEdit {{ + background-color: {get_surface_color("node_body")}; border: 1px solid {get_surface_color("border")}; + color: {get_surface_color("text_soft")}; border-radius: 4px; padding: 5px; font-family: Consolas, Monaco, monospace; - } + }} """) button_colors = get_neutral_button_colors() @@ -323,34 +323,34 @@ def _setup_ui(self): border-color: {button_colors["border"].darker(105).name()}; }} QPushButton:disabled {{ - background-color: #2B2B2B; - border-color: #353535; - color: #7B7B7B; + background-color: {get_surface_color("field")}; + border-color: {get_surface_color("border")}; + color: {get_surface_color("text_muted")}; }} """) def _inject_scrollbar_style(self): - css = """ - ::-webkit-scrollbar { + css = f""" + ::-webkit-scrollbar {{ width: 10px; height: 10px; - background-color: #252525; - } - ::-webkit-scrollbar-track { - background-color: #252525; + background-color: {get_surface_color("node_body")}; + }} + ::-webkit-scrollbar-track {{ + background-color: {get_surface_color("node_body")}; border-radius: 5px; - } - ::-webkit-scrollbar-thumb { - background-color: #555555; + }} + ::-webkit-scrollbar-thumb {{ + background-color: {get_surface_color("handle")}; border-radius: 5px; - border: 1px solid #252525; - } - ::-webkit-scrollbar-thumb:hover { - background-color: #6A6A6A; - } - ::-webkit-scrollbar-corner { + border: 1px solid {get_surface_color("node_body")}; + }} + ::-webkit-scrollbar-thumb:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + ::-webkit-scrollbar-corner {{ background: transparent; - } + }} """ js = f""" (function() {{ @@ -445,16 +445,16 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) - + painter.setBrush(QColor(get_surface_color("field"))) + node_color = node_colors["border"] pen = QPen(node_color, 1.5) if self.isSelected(): pen = QPen(palette.SELECTION, 2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2) - + pen = QPen(QColor(get_surface_color("text_bright")), 2) + painter.setPen(pen) painter.drawPath(path) @@ -490,7 +490,7 @@ def paint(self, painter, option, widget=None): return if self.is_collapsed: - painter.setPen(QColor("#FFFFFF")) + painter.setPen(QColor(get_surface_color("text_bright"))) font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "HTML Renderer") @@ -499,7 +499,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRect(10, 10, 20, 20)) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - expand_icon = qta.icon('fa5s.expand-arrows-alt', color='#FFFFFF' if self.hovered else '#888888') + expand_icon = qta.icon('fa5s.expand-arrows-alt', color=get_surface_color("text_bright") if self.hovered else get_surface_color("chrome_inactive")) expand_icon.paint(painter, QRect(int(self.width - 30), 10, 20, 20)) else: if self.hovered: @@ -508,7 +508,7 @@ def paint(self, painter, option, widget=None): painter.setPen(QColor(255, 255, 255, 150)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(6,6,-6,-6), 4, 4) - icon_pen = QPen(QColor("#FFFFFF"), 2) + icon_pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(icon_pen) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) diff --git a/graphlink_app/graphlink_lod.py b/graphlink_app/graphlink_lod.py index 8e3f186..3fba550 100644 --- a/graphlink_app/graphlink_lod.py +++ b/graphlink_app/graphlink_lod.py @@ -3,6 +3,9 @@ from PySide6.QtCore import QPointF, QRectF, Qt from PySide6.QtGui import QBrush, QColor, QFont, QFontMetrics, QLinearGradient, QPainter, QPainterPath, QPen +from graphlink_config import get_surface_color +from graphlink_styles import FONT_FAMILY_NAME + LOD_FULL_THRESHOLD = 0.72 LOD_SUMMARY_THRESHOLD = 0.3 @@ -291,14 +294,14 @@ def draw_lod_card( panel_path.addRoundedRect(panel_rect, border_radius, border_radius) gradient = QLinearGradient(QPointF(panel_rect.left(), panel_rect.top()), QPointF(panel_rect.left(), panel_rect.bottom())) - gradient.setColorAt(0, QColor("#2B2B2B")) - gradient.setColorAt(1, QColor("#1B1B1B")) + gradient.setColorAt(0, QColor(get_surface_color("field"))) + gradient.setColorAt(1, QColor(get_surface_color("window"))) painter.setBrush(QBrush(gradient)) border_color = accent.lighter(110) border_width = 1.35 if hovered: - border_color = QColor("#FFFFFF") + border_color = QColor(get_surface_color("text_bright")) border_width = 1.9 if selected: border_color = selection_color @@ -346,7 +349,7 @@ def draw_lod_card( chip_block_height = 0.0 if badge: badge_font = _fit_font_to_height( - _scaled_font("Segoe UI", 7, scale=min(detail_scale, 1.42), weight=QFont.Weight.DemiBold, max_scale=1.42), + _scaled_font(FONT_FAMILY_NAME, 7, scale=min(detail_scale, 1.42), weight=QFont.Weight.DemiBold, max_scale=1.42), _lod_font_height_limit( panel_rect.height(), zoom, @@ -365,11 +368,11 @@ def draw_lod_card( painter.setBrush(QColor(accent.red(), accent.green(), accent.blue(), 64)) painter.setPen(QPen(QColor(accent.red(), accent.green(), accent.blue(), 116), 1.0)) painter.drawRoundedRect(chip_rect, chip_height / 2, chip_height / 2) - painter.setPen(QColor("#FAFAFA")) + painter.setPen(QColor(get_surface_color("text_bright"))) painter.drawText(chip_rect, Qt.AlignmentFlag.AlignCenter, badge) footer_font = _fit_font_to_height( - _scaled_font("Segoe UI", 9, scale=min(detail_scale, 2.05), weight=QFont.Weight.DemiBold, max_scale=2.05), + _scaled_font(FONT_FAMILY_NAME, 9, scale=min(detail_scale, 2.05), weight=QFont.Weight.DemiBold, max_scale=2.05), _lod_font_height_limit( panel_rect.height(), zoom, @@ -417,7 +420,7 @@ def draw_lod_card( ) glyph_font = _fit_font_to_height( - _scaled_font("Segoe UI", 16, scale=detail_scale, weight=QFont.Weight.DemiBold, max_scale=2.8), + _scaled_font(FONT_FAMILY_NAME, 16, scale=detail_scale, weight=QFont.Weight.DemiBold, max_scale=2.8), _lod_font_height_limit( orb_rect.height(), zoom, @@ -428,7 +431,7 @@ def draw_lod_card( min_point_size=8.0, ) painter.setFont(glyph_font) - painter.setPen(QColor("#F7F7F7")) + painter.setPen(QColor(get_surface_color("text_bright"))) painter.drawText(orb_rect, Qt.AlignmentFlag.AlignCenter, initials_for_title(title)) painter.setBrush(QColor(255, 255, 255, 18)) @@ -439,7 +442,7 @@ def draw_lod_card( footer_rect.adjusted(10.0, 0.0, -10.0, 0.0), title, font=footer_font, - color=QColor("#F6F6F6"), + color=QColor(get_surface_color("text_bright")), max_lines=1, alignment=Qt.AlignmentFlag.AlignHCenter, ) @@ -467,7 +470,7 @@ def draw_lod_card( meta_text_x = content_left if badge: badge_font = _fit_font_to_height( - _scaled_font("Segoe UI", 7, scale=min(detail_scale, 1.5), weight=QFont.Weight.DemiBold, max_scale=1.5), + _scaled_font(FONT_FAMILY_NAME, 7, scale=min(detail_scale, 1.5), weight=QFont.Weight.DemiBold, max_scale=1.5), _lod_font_height_limit( panel_rect.height(), zoom, @@ -490,13 +493,13 @@ def draw_lod_card( painter.setBrush(QColor(accent.red(), accent.green(), accent.blue(), 74)) painter.setPen(QPen(QColor(accent.red(), accent.green(), accent.blue(), 116), 1.0)) painter.drawRoundedRect(badge_rect, badge_height / 2, badge_height / 2) - painter.setPen(QColor("#F7F7F7")) + painter.setPen(QColor(get_surface_color("text_bright"))) painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge) meta_text_x = badge_rect.right() + 10.0 if subtitle: subtitle_font = _fit_font_to_height( - _scaled_font("Segoe UI", 8, scale=min(detail_scale, 1.52), weight=QFont.Weight.Medium, max_scale=1.52), + _scaled_font(FONT_FAMILY_NAME, 8, scale=min(detail_scale, 1.52), weight=QFont.Weight.Medium, max_scale=1.52), _lod_font_height_limit( panel_rect.height(), zoom, @@ -511,7 +514,7 @@ def draw_lod_card( QRectF(meta_text_x, header_rect.top(), max(18.0, content_right - meta_text_x), header_rect.height()), subtitle, font=subtitle_font, - color=QColor("#B6B6B6"), + color=QColor(get_surface_color("text_secondary")), max_lines=1, ) @@ -521,7 +524,7 @@ def draw_lod_card( title_font = _fit_font_to_height( _scaled_font( - "Segoe UI", + FONT_FAMILY_NAME, 12 if compact_summary else 13, scale=min(detail_scale, 2.45 if compact_summary else 2.1), weight=QFont.Weight.DemiBold, @@ -545,7 +548,7 @@ def draw_lod_card( title_rect, title, font=title_font, - color=QColor("#FAFAFA"), + color=QColor(get_surface_color("text_bright")), max_lines=title_line_limit, line_gap=2.0, ) @@ -560,7 +563,7 @@ def draw_lod_card( preview_font = _fit_font_to_height( _scaled_font( - "Segoe UI", + FONT_FAMILY_NAME, 9 if compact_summary else 10, scale=min(detail_scale, 1.9 if compact_summary else 1.7), max_scale=1.9 if compact_summary else 1.7, @@ -586,7 +589,7 @@ def draw_lod_card( preview_inner_rect, preview or " ", font=preview_font, - color=QColor("#DCDCDC"), + color=QColor(get_surface_color("text_primary")), max_lines=preview_line_limit, line_gap=2.0, ) @@ -622,7 +625,7 @@ def draw_lod_card( painter.drawPath(panel_path) if search_match: - search_pen = QPen(QColor("#C6C6C6"), 2.2) + search_pen = QPen(QColor(get_surface_color("text_secondary")), 2.2) search_pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) painter.setPen(search_pen) painter.setBrush(Qt.BrushStyle.NoBrush) diff --git a/graphlink_app/graphlink_nodes/graphlink_node_chat.py b/graphlink_app/graphlink_nodes/graphlink_node_chat.py index 174da48..4007b2a 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_chat.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_chat.py @@ -15,8 +15,9 @@ from PySide6.QtWidgets import QGraphicsItem from graphlink_canvas_items import Container, Frame, HoverAnimationMixin -from graphlink_config import get_current_palette, get_semantic_color, is_monochrome_theme +from graphlink_config import get_current_palette, get_graph_node_colors, get_semantic_color, get_surface_color, is_monochrome_theme from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text +from graphlink_styles import FONT_FAMILY_NAME from graphlink_widgets import ScrollBar @@ -143,15 +144,15 @@ def _surface_colors(self): accent = self._role_accent() monochrome = is_monochrome_theme() - body_start = self._mix_color(QColor("#282828"), accent, 0.04 if not monochrome else 0.02) - body_end = self._mix_color(QColor("#1A1A1A"), accent, 0.02 if not monochrome else 0.01) - header_start = self._mix_color(QColor("#323232"), accent, 0.30 if not monochrome else 0.08) - header_end = self._mix_color(QColor("#1C1C1C"), accent, 0.18 if not monochrome else 0.04) - badge_fill = self._mix_color(QColor("#2B2B2B"), accent, 0.58 if not monochrome else 0.12) - badge_text = QColor("#FCFCFC") if self.is_user else QColor("#F6F6F6") - descriptor_text = self._mix_color(QColor("#A5A5A5"), accent, 0.14 if not monochrome else 0.04) - content_panel_fill = QColor("#141414") - content_panel_border = self._mix_color(QColor("#393939"), accent, 0.08 if not monochrome else 0.03) + body_start = self._mix_color(QColor(get_surface_color("field")), accent, 0.04 if not monochrome else 0.02) + body_end = self._mix_color(QColor(get_surface_color("window")), accent, 0.02 if not monochrome else 0.01) + header_start = self._mix_color(get_graph_node_colors()["header_end"], accent, 0.30 if not monochrome else 0.08) + header_end = self._mix_color(QColor(get_surface_color("window")), accent, 0.18 if not monochrome else 0.04) + badge_fill = self._mix_color(QColor(get_surface_color("field")), accent, 0.58 if not monochrome else 0.12) + badge_text = QColor(get_surface_color("text_bright")) + descriptor_text = self._mix_color(QColor(get_surface_color("text_label")), accent, 0.14 if not monochrome else 0.04) + content_panel_fill = QColor(get_surface_color("window")) + content_panel_border = self._mix_color(QColor(get_surface_color("border")), accent, 0.08 if not monochrome else 0.03) return { "accent": accent, @@ -169,11 +170,11 @@ def _surface_colors(self): def _get_default_stylesheet(self, color, font_family, font_size): base_text = QColor(color) accent = self._role_accent() - muted_text = self._mix_color(base_text, QColor("#CFCFCF"), 0.18 if not is_monochrome_theme() else 0.04) + muted_text = self._mix_color(base_text, QColor(get_surface_color("text_soft")), 0.18 if not is_monochrome_theme() else 0.04) link_color = accent.lighter(125) - quote_border = self._mix_color(QColor("#545454"), accent, 0.55 if not is_monochrome_theme() else 0.10) - code_bg = self._mix_color(QColor("#141414"), accent, 0.10 if not is_monochrome_theme() else 0.03) - table_border = self._mix_color(QColor("#404040"), accent, 0.26 if not is_monochrome_theme() else 0.06) + quote_border = self._mix_color(QColor(get_surface_color("border_strong")), accent, 0.55 if not is_monochrome_theme() else 0.10) + code_bg = self._mix_color(QColor(get_surface_color("window")), accent, 0.10 if not is_monochrome_theme() else 0.03) + table_border = self._mix_color(QColor(get_surface_color("border")), accent, 0.26 if not is_monochrome_theme() else 0.06) return f""" body {{ @@ -199,7 +200,7 @@ def _get_default_stylesheet(self, color, font_family, font_size): margin-bottom: 0.2em; }} h1, h2, h3, h4, h5, h6 {{ - color: #FFFFFF; + color: {get_surface_color("text_bright")}; font-family: '{font_family}'; font-weight: bold; margin-top: 0.2em; @@ -260,9 +261,9 @@ def _get_default_stylesheet(self, color, font_family, font_size): """ def _setup_document(self): - font_family = "Segoe UI" + font_family = FONT_FAMILY_NAME font_size = 10 - color = "#DDDDDD" + color = get_surface_color("text_primary") if self.scene(): font_family = self.scene().font_family @@ -394,7 +395,7 @@ def _paint_header(self, painter, current_width): painter.setPen(QPen(colors["content_panel_border"], 1)) painter.drawLine(10, self.HEADER_HEIGHT, current_width - 10, self.HEADER_HEIGHT) - font_family = self.scene().font_family if self.scene() else "Segoe UI" + font_family = self.scene().font_family if self.scene() else FONT_FAMILY_NAME badge_font = QFont(font_family, 8, QFont.Weight.DemiBold) painter.setFont(badge_font) badge_metrics = QFontMetrics(badge_font) @@ -424,7 +425,7 @@ def _paint_collapse_button(self, painter, button_x): painter.setPen(QColor(255, 255, 255, 110)) painter.drawRoundedRect(self.collapse_button_rect, 4, 4) - icon_pen = QPen(QColor("#FFFFFF"), 1.8) + icon_pen = QPen(QColor(get_surface_color("text_bright")), 1.8) painter.setPen(icon_pen) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) @@ -483,7 +484,7 @@ def paint(self, painter, option, widget=None): if self.isSelected() and not is_dragging: pen = QPen(palette.SELECTION, 2.2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2) + pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(Qt.PenStyle.NoPen) painter.drawPath(path) @@ -529,14 +530,14 @@ def paint(self, painter, option, widget=None): docked_child_count = self.docked_child_count() if docked_child_count: - indicator_color = QColor("#A2A2A2").lighter(130) + indicator_color = QColor(get_surface_color("text_label")).lighter(130) painter.setBrush(indicator_color) painter.setPen(Qt.PenStyle.NoPen) badge_width = 26 if docked_child_count < 10 else 32 badge_rect = QRectF(current_width - badge_width - 38, 8, badge_width, 18) painter.drawRoundedRect(badge_rect, 9, 9) - painter.setPen(QColor("#141414")) - count_font = QFont(self.scene().font_family if self.scene() else "Segoe UI", 8, QFont.Weight.Bold) + painter.setPen(QColor(get_surface_color("window"))) + count_font = QFont(self.scene().font_family if self.scene() else FONT_FAMILY_NAME, 8, QFont.Weight.Bold) painter.setFont(count_font) painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, str(docked_child_count)) @@ -569,10 +570,10 @@ def paint(self, painter, option, widget=None): painter.drawPath(path) if self.is_collapsed: - font_family = self.scene().font_family if self.scene() else "Segoe UI" + font_family = self.scene().font_family if self.scene() else FONT_FAMILY_NAME snippet_font = QFont(font_family, 9) painter.setFont(snippet_font) - painter.setPen(QColor("#F3F3F3")) + painter.setPen(QColor(get_surface_color("text_strong"))) metrics = QFontMetrics(snippet_font) text_to_show = self.text.split('\n')[0].strip() or "[Empty]" elided_text = metrics.elidedText(text_to_show, Qt.TextElideMode.ElideRight, current_width - 26) diff --git a/graphlink_app/graphlink_nodes/graphlink_node_code.py b/graphlink_app/graphlink_nodes/graphlink_node_code.py index 441c0f5..e9369ff 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_code.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_code.py @@ -4,8 +4,9 @@ from PySide6.QtWidgets import QApplication, QGraphicsItem from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import canvas_font, canvas_font_color, get_current_palette, get_graph_node_colors, get_semantic_color +from graphlink_config import canvas_font, canvas_font_color, get_current_palette, get_graph_node_colors, get_semantic_color, get_surface_color from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text +from graphlink_styles import FONT_FAMILY_NAME from graphlink_widgets import ScrollBar try: @@ -28,7 +29,7 @@ def __init__(self, style='monokai'): def highlight(self, code, language): if not PYGMENTS_AVAILABLE: - return f'
{code}
' + return f'
{code}
' try: if not language: lexer = guess_lexer(code) @@ -83,9 +84,9 @@ def __init__(self, code, language, parent_content_node, parent=None): def _set_document_style(self): scene = self.scene() - family = getattr(scene, "font_family", "Segoe UI") + family = getattr(scene, "font_family", FONT_FAMILY_NAME) size = max(1, int(getattr(scene, "font_size", 10))) - color = canvas_font_color(scene, "#dddddd").name() + color = canvas_font_color(scene, get_surface_color("text_primary")).name() stylesheet = self.highlighter.get_stylesheet() stylesheet += f" body, pre {{ font-family: '{family}'; font-size: {size}pt; color: {color}; }}" self.document.setDefaultStyleSheet(stylesheet) @@ -143,7 +144,7 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#1e1e1e")) + painter.setBrush(QColor(get_surface_color("window"))) is_dragging = self.scene() and getattr(self.scene(), 'is_rubber_band_dragging', False) @@ -178,7 +179,7 @@ def paint(self, painter, option, widget=None): painter.setBrush(node_colors["header_start"]) painter.drawPath(header_path) - painter.setPen(QColor("#cccccc")) + painter.setPen(QColor(get_surface_color("text_soft"))) font = canvas_font(self.scene(), delta=-1) painter.setFont(font) metrics = QFontMetrics(font) @@ -190,7 +191,7 @@ def paint(self, painter, option, widget=None): ) painter.drawText(label_rect, Qt.AlignmentFlag.AlignVCenter, label_text) - copy_icon = qta.icon('fa5s.copy', color='#cccccc') + copy_icon = qta.icon('fa5s.copy', color=get_surface_color("text_soft")) copy_icon.paint(painter, QRectF(self.width - 28, 7, 16, 16).toRect()) painter.save() diff --git a/graphlink_app/graphlink_nodes/graphlink_node_document.py b/graphlink_app/graphlink_nodes/graphlink_node_document.py index 735f050..1a957cb 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_document.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_document.py @@ -8,8 +8,9 @@ from graphlink_audio import format_duration from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_surface_color from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text +from graphlink_styles import FONT_FAMILY_NAME from graphlink_widgets import ScrollBar @@ -178,9 +179,9 @@ def _should_show_audio_preview(self, content, audio_details): return True def _setup_document(self): - font_family = "Segoe UI" + font_family = FONT_FAMILY_NAME font_size = 10 - color = "#DDDDDD" + color = get_surface_color("text_primary") if self.scene(): font_family = self.scene().font_family @@ -192,13 +193,13 @@ def _setup_document(self): f"p {{ margin: 0 0 0.45em 0; }}" "table.meta { width: 100%; border-collapse: collapse; margin: 0 0 12px 0; }" "table.meta td { padding: 6px 0; vertical-align: top; }" - "td.meta-label { color: #979797; font-size: 9pt; font-weight: 600; width: 88px; min-width: 88px; max-width: 88px; padding-right: 18px; white-space: nowrap; }" - "td.meta-value { color: #F1F1F1; font-size: 9.5pt; }" - "p.attachment-kicker { color: #A0A0A0; font-size: 8.5pt; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 4px 0; }" - "p.attachment-title { color: #F7F7F7; font-size: 12pt; font-weight: 700; margin: 0 0 12px 0; }" - "p.section-label { color: #979797; font-size: 8.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin: 6px 0 8px 0; }" - "div.preview { background: #121212; border: 1px solid #393939; border-radius: 10px; padding: 10px 12px; }" - "pre.preview-text { margin: 0; color: #DDDDDD; font-family: 'Consolas', 'Courier New', monospace; white-space: pre-wrap; }" + f"td.meta-label {{ color: {get_surface_color('text_label')}; font-size: 9pt; font-weight: 600; width: 88px; min-width: 88px; max-width: 88px; padding-right: 18px; white-space: nowrap; }}" + f"td.meta-value {{ color: {get_surface_color('text_strong')}; font-size: 9.5pt; }}" + f"p.attachment-kicker {{ color: {get_surface_color('text_label')}; font-size: 8.5pt; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 4px 0; }}" + f"p.attachment-title {{ color: {get_surface_color('text_bright')}; font-size: 12pt; font-weight: 700; margin: 0 0 12px 0; }}" + f"p.section-label {{ color: {get_surface_color('text_label')}; font-size: 8.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin: 6px 0 8px 0; }}" + f"div.preview {{ background: {get_surface_color('inset_deep')}; border: 1px solid {get_surface_color('border')}; border-radius: 10px; padding: 10px 12px; }}" + f"pre.preview-text {{ margin: 0; color: {get_surface_color('text_primary')}; font-family: 'Consolas', 'Courier New', monospace; white-space: pre-wrap; }}" ) self.document.setDefaultStyleSheet(stylesheet) self.document.setHtml(self._build_attachment_html()) @@ -376,29 +377,29 @@ def _paint_action_button(self, painter, rect, icon_name, hovered): painter.setBrush(button_bg) painter.setPen(button_border) painter.drawRoundedRect(rect, 4, 4) - icon = qta.icon(icon_name, color="#FFFFFF" if hovered else "#D7D7D7") + icon = qta.icon(icon_name, color=get_surface_color("text_bright") if hovered else get_surface_color("text_primary")) icon.paint(painter, rect.adjusted(3, 3, -3, -3).toRect()) def _paint_collapsed_state(self, painter, current_width, current_height, pen): node_colors = get_graph_node_colors() - painter.setBrush(QColor("#272727")) + painter.setBrush(QColor(get_surface_color("field"))) painter.setPen(pen) painter.drawRoundedRect(0, 0, current_width, current_height, current_height / 2, current_height / 2) - icon = qta.icon(self._icon_name(), color="#D9D9D9") + icon = qta.icon(self._icon_name(), color=get_surface_color("text_primary")) icon.paint(painter, QRectF(12, 14, 16, 16).toRect()) - title_font = QFont(self.scene().font_family if self.scene() else "Segoe UI", 9, QFont.Weight.Bold) + title_font = QFont(self.scene().font_family if self.scene() else FONT_FAMILY_NAME, 9, QFont.Weight.Bold) painter.setFont(title_font) - painter.setPen(QColor("#F3F3F3")) + painter.setPen(QColor(get_surface_color("text_strong"))) title_metrics = QFontMetrics(title_font) title_width = current_width - 84 elided_title = title_metrics.elidedText(self.title or self._subtitle_text(), Qt.TextElideMode.ElideRight, title_width) painter.drawText(QRectF(36, 9, title_width, 16), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, elided_title) - subtitle_font = QFont(self.scene().font_family if self.scene() else "Segoe UI", 8) + subtitle_font = QFont(self.scene().font_family if self.scene() else FONT_FAMILY_NAME, 8) painter.setFont(subtitle_font) - painter.setPen(QColor("#B1B1B1")) + painter.setPen(QColor(get_surface_color("text_secondary"))) subtitle_metrics = QFontMetrics(subtitle_font) subtitle_text = subtitle_metrics.elidedText(self.preview_label or self._subtitle_text(), Qt.TextElideMode.ElideRight, title_width) painter.drawText(QRectF(36, 26, title_width, 14), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, subtitle_text) @@ -444,7 +445,7 @@ def paint(self, painter, option, widget=None): else: path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) + painter.setBrush(QColor(get_surface_color("field"))) painter.setPen(pen) painter.drawPath(path) @@ -454,10 +455,10 @@ def paint(self, painter, option, widget=None): painter.setBrush(node_colors["header_start"]) painter.drawPath(header_path) - icon = qta.icon(self._icon_name(), color="#CCCCCC") + icon = qta.icon(self._icon_name(), color=get_surface_color("text_soft")) icon.paint(painter, QRectF(10, 7, 16, 16).toRect()) - painter.setPen(QColor("#CCCCCC")) + painter.setPen(QColor(get_surface_color("text_soft"))) title_font = canvas_font(self.scene(), delta=-1, weight=QFont.Weight.Bold) painter.setFont(title_font) title_metrics = QFontMetrics(title_font) @@ -473,12 +474,12 @@ def paint(self, painter, option, widget=None): painter.setBrush(QColor(255, 255, 255, 18)) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(badge_rect, 8, 8) - painter.setPen(QColor("#D9D9D9")) + painter.setPen(QColor(get_surface_color("text_primary"))) painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_metrics.elidedText(badge_text, Qt.TextElideMode.ElideRight, int(badge_rect.width() - 10))) panel_rect = self._content_panel_rect() - painter.setBrush(QColor("#1C1C1C")) - painter.setPen(QPen(QColor("#404040"), 1)) + painter.setBrush(QColor(get_surface_color("window"))) + painter.setPen(QPen(QColor(get_surface_color("border")), 1)) painter.drawRoundedRect(panel_rect, 11, 11) painter.save() diff --git a/graphlink_app/graphlink_nodes/graphlink_node_image.py b/graphlink_app/graphlink_nodes/graphlink_node_image.py index c24428a..300c7cd 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_image.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_image.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import QGraphicsItem from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import canvas_font, canvas_font_color, get_current_palette, get_graph_node_colors, get_semantic_color +from graphlink_config import canvas_font, canvas_font_color, get_current_palette, get_graph_node_colors, get_semantic_color, get_surface_color from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text @@ -59,7 +59,7 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) + painter.setBrush(QColor(get_surface_color("field"))) is_dragging = self.scene() and getattr(self.scene(), 'is_rubber_band_dragging', False) @@ -94,10 +94,10 @@ def paint(self, painter, option, widget=None): painter.setBrush(node_colors["header_start"]) painter.drawPath(header_path) - icon = qta.icon('fa5s.image', color='#CCCCCC') + icon = qta.icon('fa5s.image', color=get_surface_color("text_soft")) icon.paint(painter, QRectF(10, 7, 16, 16).toRect()) - painter.setPen(QColor("#CCCCCC")) + painter.setPen(QColor(get_surface_color("text_soft"))) font = canvas_font(self.scene(), delta=-1) painter.setFont(font) metrics = QFontMetrics(font) @@ -126,7 +126,7 @@ def paint(self, painter, option, widget=None): painter.drawImage(target_rect, self.image) else: painter.save() - placeholder_color = canvas_font_color(self.scene(), "#B1B1B1") + placeholder_color = canvas_font_color(self.scene(), get_surface_color("text_secondary")) placeholder_color.setAlpha(180) painter.setPen(QPen(placeholder_color, 1, Qt.PenStyle.DashLine)) painter.setBrush(QColor(255, 255, 255, 10)) diff --git a/graphlink_app/graphlink_nodes/graphlink_node_thinking.py b/graphlink_app/graphlink_nodes/graphlink_node_thinking.py index dcf9569..62862aa 100644 --- a/graphlink_app/graphlink_nodes/graphlink_node_thinking.py +++ b/graphlink_app/graphlink_nodes/graphlink_node_thinking.py @@ -5,8 +5,9 @@ from PySide6.QtWidgets import QGraphicsItem from graphlink_canvas_items import Container, HoverAnimationMixin -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_surface_color from graphlink_lod import draw_lod_card, lod_mode_for_item, preview_text +from graphlink_styles import FONT_FAMILY_NAME from graphlink_widgets import ScrollBar @@ -52,9 +53,9 @@ def __init__(self, thinking_text, parent_content_node, parent=None): self._recalculate_geometry() def _setup_document(self): - font_family = "Segoe UI" + font_family = FONT_FAMILY_NAME font_size = 9 - color = "#B0B0B0" + color = get_surface_color("text_secondary") if self.scene(): font_family = self.scene().font_family @@ -64,10 +65,10 @@ def _setup_document(self): stylesheet = ( f"body {{ color: {color}; font-family: '{font_family}'; font-size: {font_size}pt; margin: 0; }}" f"p {{ color: {color}; margin: 0 0 0.6em 0; }}" - "p.thinking-kicker { color: #9E9E9E; font-size: 8.5pt; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 8px 0; }" - "blockquote { border-left: 3px solid #545454; padding-left: 10px; margin: 0.35em 0 0.8em 0; color: #CCCCCC; }" - "pre { background: #121212; border: 1px solid #393939; border-radius: 8px; padding: 10px 12px; margin: 0.35em 0 0.8em 0; color: #DCDCDC; }" - "code { background: #121212; border-radius: 4px; padding: 1px 4px; color: #DCDCDC; }" + f"p.thinking-kicker {{ color: {get_surface_color('text_label')}; font-size: 8.5pt; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 8px 0; }}" + f"blockquote {{ border-left: 3px solid {get_surface_color('border_strong')}; padding-left: 10px; margin: 0.35em 0 0.8em 0; color: {get_surface_color('text_soft')}; }}" + f"pre {{ background: {get_surface_color('inset_deep')}; border: 1px solid {get_surface_color('border')}; border-radius: 8px; padding: 10px 12px; margin: 0.35em 0 0.8em 0; color: {get_surface_color('text_primary')}; }}" + f"code {{ background: {get_surface_color('inset_deep')}; border-radius: 4px; padding: 1px 4px; color: {get_surface_color('text_primary')}; }}" "pre code { background: transparent; padding: 0; }" "ul, ol { margin: 0 0 0.75em 0; padding-left: 20px; }" "li { margin-bottom: 0.2em; }" @@ -166,7 +167,7 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) + painter.setBrush(QColor(get_surface_color("field"))) is_dragging = self.scene() and getattr(self.scene(), 'is_rubber_band_dragging', False) @@ -204,10 +205,10 @@ def paint(self, painter, option, widget=None): painter.setBrush(node_colors["header_start"]) painter.drawPath(header_path) - icon = qta.icon('fa5s.brain', color='#CCCCCC') + icon = qta.icon('fa5s.brain', color=get_surface_color("text_soft")) icon.paint(painter, QRectF(10, 7, 16, 16).toRect()) - painter.setPen(QColor("#CCCCCC")) + painter.setPen(QColor(get_surface_color("text_soft"))) font = canvas_font(self.scene(), delta=-1, weight=QFont.Weight.Bold) painter.setFont(font) title_metrics = QFontMetrics(font) @@ -217,17 +218,17 @@ def paint(self, painter, option, widget=None): title_text = title_metrics.elidedText("Assistant's Thoughts", Qt.TextElideMode.ElideRight, int(title_rect.width())) painter.drawText(title_rect, Qt.AlignmentFlag.AlignVCenter, title_text) - button_bg_color = QColor("#555") if self.dock_button_hovered else QColor("#444") + button_bg_color = QColor(get_surface_color("handle")) if self.dock_button_hovered else QColor(get_surface_color("divider")) painter.setBrush(button_bg_color) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(self.dock_button_rect, 4, 4) - dock_icon_color = "#FFFFFF" if self.dock_button_hovered else "#AAAAAA" + dock_icon_color = get_surface_color("text_bright") if self.dock_button_hovered else get_surface_color("text_label") dock_icon = qta.icon('fa5s.arrow-up', color=dock_icon_color) dock_icon.paint(painter, self.dock_button_rect.adjusted(3, 3, -3, -3).toRect()) panel_rect = self._content_panel_rect() - painter.setBrush(QColor("#1C1C1C")) - painter.setPen(QPen(QColor("#404040"), 1)) + painter.setBrush(QColor(get_surface_color("window"))) + painter.setPen(QPen(QColor(get_surface_color("border")), 1)) painter.drawRoundedRect(panel_rect, 11, 11) painter.save() diff --git a/graphlink_app/graphlink_plugins/common/combo.py b/graphlink_app/graphlink_plugins/common/combo.py index 5352066..4c54212 100644 --- a/graphlink_app/graphlink_plugins/common/combo.py +++ b/graphlink_app/graphlink_plugins/common/combo.py @@ -34,6 +34,8 @@ ) from shiboken6 import isValid as _is_qt_object_valid +from graphlink_config import get_surface_color + def _qt_object_is_alive(obj): """Return whether a Qt wrapper still owns a live C++ object.""" @@ -92,8 +94,8 @@ def apply_style(self, accent_color="#656565"): self.setStyleSheet( f""" QFrame#pluginComboPopupFrame {{ - background-color: #222222; - border: 1px solid #3A3A3A; + background-color: {get_surface_color("node_body")}; + border: 1px solid {get_surface_color("border")}; border-radius: 10px; }} QFrame#pluginComboPopupShell {{ @@ -102,29 +104,29 @@ def apply_style(self, accent_color="#656565"): }} QListWidget#pluginComboPopupList {{ background: transparent; - color: #FFFFFF; + color: {get_surface_color("text_bright")}; border: none; outline: none; padding: 2px; }} QListWidget#pluginComboPopupList::item {{ background: transparent; - color: #FFFFFF; + color: {get_surface_color("text_bright")}; border: none; border-radius: 6px; min-height: 26px; padding: 6px 10px; }} QListWidget#pluginComboPopupList::item:hover {{ - background-color: #2F2F2F; + background-color: {get_surface_color("field")}; }} QListWidget#pluginComboPopupList::item:selected {{ background-color: {accent_color}; - color: #FFFFFF; + color: {get_surface_color("text_bright")}; }} QListWidget#pluginComboPopupList::item:selected:hover {{ background-color: {accent_color}; - color: #FFFFFF; + color: {get_surface_color("text_bright")}; }} """ ) diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py b/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py index 8fc4e17..9ea3e43 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_artifact.py @@ -10,55 +10,57 @@ from PySide6.QtGui import QPainter, QColor, QPen, QPainterPath, QFont, QBrush, QLinearGradient import qtawesome as qta from graphlink_config import canvas_font, get_current_palette -from graphlink_config import get_semantic_color +from graphlink_config import get_semantic_color, get_surface_color +from graphlink_styles import FONT_FAMILY from graphlink_canvas_items import HoverAnimationMixin from graphlink_connections import ConnectionItem from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu -ARTIFACT_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #252525; +def artifact_scrollbar_style(): + return f""" + QScrollBar:vertical {{ + background: {get_surface_color("node_body")}; width: 10px; margin: 0px; border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #555555; + }} + QScrollBar::handle:vertical {{ + background-color: {get_surface_color("handle")}; min-height: 25px; border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #6A6A6A; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + }} + QScrollBar::handle:vertical:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: none; - } - QScrollBar:horizontal { - background: #252525; + }} + QScrollBar:horizontal {{ + background: {get_surface_color("node_body")}; height: 10px; margin: 0px; border-radius: 5px; - } - QScrollBar::handle:horizontal { - background-color: #555555; + }} + QScrollBar::handle:horizontal {{ + background-color: {get_surface_color("handle")}; min-width: 25px; border-radius: 5px; - } - QScrollBar::handle:horizontal:hover { - background-color: #6A6A6A; - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + }} + QScrollBar::handle:horizontal:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0px; background: none; - } - QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + }} + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; - } + }} """ @@ -156,9 +158,9 @@ def __init__(self, parent_node, parent=None): self.widget = QWidget() self.widget.setObjectName("artifactMainWidget") self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#artifactMainWidget { background-color: transparent; color: #E0E0E0; font-family: 'Segoe UI', sans-serif; } - QWidget#artifactMainWidget QLabel { background-color: transparent; } + self.widget.setStyleSheet(f""" + QWidget#artifactMainWidget {{ background-color: transparent; color: {get_surface_color("text_primary")}; font-family: {FONT_FAMILY}; }} + QWidget#artifactMainWidget QLabel {{ background-color: transparent; }} """) self._setup_ui() @@ -255,7 +257,7 @@ def _setup_ui(self): artifact_color = get_semantic_color("artifact") artifact_hover_color = artifact_color.lighter(115) artifact_icon_text = artifact_color.name() - artifact_button_icon = "#1E1E1E" if artifact_color.lightness() > 150 else "#F3F3F3" + artifact_button_icon = get_surface_color("window") if artifact_color.lightness() > 150 else get_surface_color("text_strong") main_layout = QVBoxLayout(self.widget) main_layout.setContentsMargins(15, 15, 15, 15) main_layout.setSpacing(10) @@ -276,17 +278,17 @@ def _setup_ui(self): # --- Separator Line --- line = QFrame() line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet("background-color: #3F3F3F; border: none; height: 1px;") + line.setStyleSheet(f"background-color: {get_surface_color('border')}; border: none; height: 1px;") main_layout.addWidget(line) # --- Main Splitter --- self.splitter = QSplitter(Qt.Orientation.Horizontal) self.splitter.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.splitter.setStyleSheet(""" - QSplitter::handle:horizontal { + self.splitter.setStyleSheet(f""" + QSplitter::handle:horizontal {{ width: 8px; - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #3F3F3F, stop:0.5 #555555, stop:1 #3F3F3F); - } + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {get_surface_color("border")}, stop:0.5 {get_surface_color("handle")}, stop:1 {get_surface_color("border")}); + }} """ + f""" QSplitter::handle:horizontal:hover {{ background: {artifact_icon_text}; }} """) @@ -300,18 +302,18 @@ def _setup_ui(self): # Chat Log self.chat_display = QTextEdit() self.chat_display.setReadOnly(True) - self.chat_display.setStyleSheet(""" - QTextEdit { - background-color: #1E1E1E; - border: 1px solid #3F3F3F; + self.chat_display.setStyleSheet(f""" + QTextEdit {{ + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 6px; padding: 10px; - } - """ + ARTIFACT_SCROLLBAR_STYLE) - self.chat_display.document().setDefaultStyleSheet(""" - p, ul, ol, li { color: #D4D4D4; font-family: 'Segoe UI', sans-serif; font-size: 13px; line-height: 1.4; margin-top: 2px; margin-bottom: 8px;} - pre { background-color: #2D2D2D; padding: 8px; border-radius: 4px; font-family: Consolas, monospace; color: #D8D8D8; } - code { background-color: #3F3F3F; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; } + }} + """ + artifact_scrollbar_style()) + self.chat_display.document().setDefaultStyleSheet(f""" + p, ul, ol, li {{ color: {get_surface_color("text_soft")}; font-family: {FONT_FAMILY}; font-size: 13px; line-height: 1.4; margin-top: 2px; margin-bottom: 8px;}} + pre {{ background-color: {get_surface_color("field")}; padding: 8px; border-radius: 4px; font-family: Consolas, monospace; color: {get_surface_color("text_primary")}; }} + code {{ background-color: {get_surface_color("border")}; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; }} """) left_layout.addWidget(self.chat_display, stretch=1) @@ -319,12 +321,12 @@ def _setup_ui(self): input_container = QWidget() input_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) input_container.setMinimumHeight(72) - input_container.setStyleSheet(""" - QWidget { - background-color: #1E1E1E; - border: 1px solid #3F3F3F; + input_container.setStyleSheet(f""" + QWidget {{ + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; - } + }} """) input_layout = QHBoxLayout(input_container) input_layout.setContentsMargins(12, 10, 10, 10) @@ -338,16 +340,16 @@ def _setup_ui(self): self.instruction_input.setWordWrapMode(self.instruction_input.wordWrapMode()) self.instruction_input.setMinimumHeight(50) self.instruction_input.setMaximumHeight(110) - self.instruction_input.setStyleSheet(""" - QTextEdit { + self.instruction_input.setStyleSheet(f""" + QTextEdit {{ background-color: transparent; border: none; - color: #FFFFFF; + color: {get_surface_color("text_bright")}; font-size: 13px; - font-family: 'Segoe UI', sans-serif; + font-family: {FONT_FAMILY}; padding: 2px 0px; - } - """ + ARTIFACT_SCROLLBAR_STYLE) + }} + """ + artifact_scrollbar_style()) self.instruction_input.textChanged.connect(self._on_instruction_changed) self.instruction_input.submit_requested.connect(self._on_instruction_submit_requested) input_layout.addWidget(self.instruction_input, stretch=1) @@ -360,7 +362,7 @@ def _setup_ui(self): self.update_button.setStyleSheet(f""" QPushButton {{ background-color: {artifact_icon_text}; border: none; border-radius: 20px; margin: 0px; }} QPushButton:hover {{ background-color: {artifact_hover_color.name()}; }} - QPushButton:disabled {{ background-color: #444444; }} + QPushButton:disabled {{ background-color: {get_surface_color("divider")}; }} """) self.update_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) input_layout.addWidget(self.update_button) @@ -375,44 +377,44 @@ def _setup_ui(self): self.tabs = QTabWidget() self.tabs.setStyleSheet(f""" - QTabWidget::pane {{ border: 1px solid #3F3F3F; background: #1E1E1E; border-radius: 6px; border-top-left-radius: 0px; }} - QTabBar::tab {{ background: transparent; color: #888888; padding: 6px 16px; border: none; font-weight: bold; font-size: 12px; margin-right: 4px; }} + QTabWidget::pane {{ border: 1px solid {get_surface_color("border")}; background: {get_surface_color("window")}; border-radius: 6px; border-top-left-radius: 0px; }} + QTabBar::tab {{ background: transparent; color: {get_surface_color("chrome_inactive")}; padding: 6px 16px; border: none; font-weight: bold; font-size: 12px; margin-right: 4px; }} QTabBar::tab:selected {{ color: {artifact_icon_text}; border-bottom: 2px solid {artifact_icon_text}; }} - QTabBar::tab:hover:!selected {{ color: #FFFFFF; }} + QTabBar::tab:hover:!selected {{ color: {get_surface_color("text_bright")}; }} """) self.raw_editor = QPlainTextEdit() self.raw_editor.setPlaceholderText("The raw markdown / code will appear here. You can manually edit it.") - self.raw_editor.setStyleSheet(""" - QPlainTextEdit { - background-color: #1E1E1E; - color: #D8D8D8; + self.raw_editor.setStyleSheet(f""" + QPlainTextEdit {{ + background-color: {get_surface_color("window")}; + color: {get_surface_color("text_primary")}; font-family: Consolas, Monaco, monospace; border: none; padding: 12px; font-size: 13px; - } - """ + ARTIFACT_SCROLLBAR_STYLE) + }} + """ + artifact_scrollbar_style()) self.raw_editor.textChanged.connect(self._on_content_changed) self.preview_display = QTextEdit() self.preview_display.setReadOnly(True) self.preview_display.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.preview_display.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.preview_display.setStyleSheet(""" - QTextEdit { - background-color: #1E1E1E; + self.preview_display.setStyleSheet(f""" + QTextEdit {{ + background-color: {get_surface_color("window")}; border: none; padding: 12px; border-radius: 6px; - } - """ + ARTIFACT_SCROLLBAR_STYLE) - self.preview_display.document().setDefaultStyleSheet(""" - p, ul, ol, li { color: #E0E0E0; font-family: 'Segoe UI', sans-serif; font-size: 13px; line-height: 1.5; margin-bottom: 10px; } - h1, h2, h3, h4 { color: #FFFFFF; font-weight: bold; margin-top: 15px; margin-bottom: 8px; } - pre { background-color: #2D2D2D; padding: 10px; border-radius: 6px; font-family: Consolas, monospace; color: #D8D8D8; } - code { background-color: #3F3F3F; color: #D8D8D8; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; } - blockquote { border-left: 3px solid #555555; padding-left: 10px; color: #AAAAAA; } + }} + """ + artifact_scrollbar_style()) + self.preview_display.document().setDefaultStyleSheet(f""" + p, ul, ol, li {{ color: {get_surface_color("text_primary")}; font-family: {FONT_FAMILY}; font-size: 13px; line-height: 1.5; margin-bottom: 10px; }} + h1, h2, h3, h4 {{ color: {get_surface_color("text_bright")}; font-weight: bold; margin-top: 15px; margin-bottom: 8px; }} + pre {{ background-color: {get_surface_color("field")}; padding: 10px; border-radius: 6px; font-family: Consolas, monospace; color: {get_surface_color("text_primary")}; }} + code {{ background-color: {get_surface_color("border")}; color: {get_surface_color("text_primary")}; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; }} + blockquote {{ border-left: 3px solid {get_surface_color("handle")}; padding-left: 10px; color: {get_surface_color("text_label")}; }} """) self.tabs.addTab(self.raw_editor, "Markdown") @@ -518,12 +520,12 @@ def set_running_state(self, is_running): if is_running: self.update_button.setEnabled(True) self.update_button.setToolTip("Stop generation") - self.update_button.setIcon(qta.icon('fa5s.stop', color='#DDDDDD')) - self.update_button.setStyleSheet("QPushButton { background-color: #444444; border: none; border-radius: 20px; margin: 0px; } QPushButton:hover { background-color: #555555; }") + self.update_button.setIcon(qta.icon('fa5s.stop', color=get_surface_color("text_primary"))) + self.update_button.setStyleSheet(f"QPushButton {{ background-color: {get_surface_color('divider')}; border: none; border-radius: 20px; margin: 0px; }} QPushButton:hover {{ background-color: {get_surface_color('handle')}; }}") else: artifact_color = get_semantic_color("artifact") artifact_hover_color = artifact_color.lighter(115) - artifact_button_icon = "#1E1E1E" if artifact_color.lightness() > 150 else "#F3F3F3" + artifact_button_icon = get_surface_color("window") if artifact_color.lightness() > 150 else get_surface_color("text_strong") self.update_button.setEnabled(True) self.update_button.setToolTip("") self.update_button.setIcon(qta.icon('fa5s.arrow-up', color=artifact_button_icon)) @@ -541,16 +543,16 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) - + painter.setBrush(QColor(get_surface_color("field"))) + node_color = get_semantic_color("artifact") pen = QPen(node_color, 1.5) if self.isSelected(): pen = QPen(palette.SELECTION, 2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2) - + pen = QPen(QColor(get_surface_color("text_bright")), 2) + painter.setPen(pen) painter.drawPath(path) @@ -586,7 +588,7 @@ def paint(self, painter, option, widget=None): return if self.is_collapsed: - painter.setPen(QColor("#FFFFFF")) + painter.setPen(QColor(get_surface_color("text_bright"))) font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Artifact Drafter") @@ -595,7 +597,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRectF(10, 10, 20, 20).toRect()) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - expand_icon = qta.icon('fa5s.expand-arrows-alt', color='#FFFFFF' if self.hovered else '#888888') + expand_icon = qta.icon('fa5s.expand-arrows-alt', color=get_surface_color("text_bright") if self.hovered else get_surface_color("chrome_inactive")) expand_icon.paint(painter, QRectF(self.width - 30, 10, 20, 20).toRect()) else: if self.hovered: @@ -604,7 +606,7 @@ def paint(self, painter, option, widget=None): painter.setPen(QColor(255, 255, 255, 150)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(6,6,-6,-6), 4, 4) - icon_pen = QPen(QColor("#FFFFFF"), 2) + icon_pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(icon_pen) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py b/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py index 46b3c03..0b6f831 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_code_sandbox.py @@ -24,35 +24,37 @@ from graphlink_agents_code_sandbox import SandboxStage from graphlink_agents_pycoder import PyCoderStatus from graphlink_canvas_items import HoverAnimationMixin -from graphlink_config import canvas_font, get_current_palette, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_semantic_color, get_surface_color +from graphlink_styles import FONT_FAMILY from graphlink_connections import ConnectionItem from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu from graphlink_pycoder import CodeEditor, PythonHighlighter, StatusItemWidget -SANDBOX_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #1E1E1E; +def sandbox_scrollbar_style(): + return f""" + QScrollBar:vertical {{ + background: {get_surface_color("window")}; width: 10px; margin: 0px; border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #606060; + }} + QScrollBar::handle:vertical {{ + background-color: {get_surface_color("handle")}; min-height: 24px; border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #797979; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + }} + QScrollBar::handle:vertical:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: none; - } + }} """ @@ -78,15 +80,15 @@ def __init__(self, parent=None): layout.addWidget(self.stages[SandboxStage.EXECUTE], 1, 0) layout.addWidget(self.stages[SandboxStage.ANALYZE], 1, 1) - self.setStyleSheet(""" - SandboxStatusTracker { - background-color: #212121; - border: 1px solid #3A3A3A; + self.setStyleSheet(f""" + SandboxStatusTracker {{ + background-color: {get_surface_color("inset")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; - } - QLabel { + }} + QLabel {{ background: transparent; - } + }} """) def update_status(self, stage, status): @@ -187,15 +189,15 @@ def __init__(self, parent_node, parent=None): self.widget = QWidget() self.widget.setObjectName("codeSandboxWidget") self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#codeSandboxWidget { + self.widget.setStyleSheet(f""" + QWidget#codeSandboxWidget {{ background: transparent; - color: #E7E7E7; - font-family: 'Segoe UI', sans-serif; - } - QWidget#codeSandboxWidget QLabel { + color: {get_surface_color("text_primary")}; + font-family: {FONT_FAMILY}; + }} + QWidget#codeSandboxWidget QLabel {{ background: transparent; - } + }} """) self._setup_ui() @@ -252,7 +254,7 @@ def _setup_ui(self): subtitle_label = QLabel("Runs Python with per-node requirements in a virtualenv. Isolates installed packages only - not a security boundary; code runs with your full account privileges.") subtitle_label.setWordWrap(True) - subtitle_label.setStyleSheet("font-size: 11px; color: #A4A4A4;") + subtitle_label.setStyleSheet(f"font-size: 11px; color: {get_surface_color('text_label')};") title_column.addWidget(subtitle_label) header_layout.addLayout(title_column, 1) @@ -277,7 +279,7 @@ def _setup_ui(self): divider = QFrame() divider.setFrameShape(QFrame.Shape.HLine) - divider.setStyleSheet("background-color: #3A3A3A; border: none; height: 1px;") + divider.setStyleSheet(f"background-color: {get_surface_color('border')}; border: none; height: 1px;") main_layout.addWidget(divider) briefing_layout = QHBoxLayout() @@ -290,12 +292,12 @@ def _setup_ui(self): prompt_layout.setSpacing(8) prompt_header = QLabel("Task Brief") - prompt_header.setStyleSheet("font-size: 12px; font-weight: 700; color: #FFFFFF;") + prompt_header.setStyleSheet(f"font-size: 12px; font-weight: 700; color: {get_surface_color('text_bright')};") prompt_layout.addWidget(prompt_header) prompt_hint = QLabel("Describe what to build, test, analyze, or transform. The sandbox agent will generate code against the branch context.") prompt_hint.setWordWrap(True) - prompt_hint.setStyleSheet("font-size: 11px; color: #A4A4A4;") + prompt_hint.setStyleSheet(f"font-size: 11px; color: {get_surface_color('text_label')};") prompt_layout.addWidget(prompt_hint) self.prompt_input = QTextEdit() @@ -303,17 +305,17 @@ def _setup_ui(self): self.prompt_input.setFixedHeight(132) self.prompt_input.setStyleSheet(f""" QTextEdit {{ - background-color: #181818; - border: 1px solid #393939; + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; padding: 10px; - color: #F6F6F6; + color: {get_surface_color("text_bright")}; font-size: 12px; }} QTextEdit:focus {{ border: 1px solid {accent.name()}; }} - {SANDBOX_SCROLLBAR_STYLE} + {sandbox_scrollbar_style()} """) self.prompt_input.textChanged.connect(self._on_prompt_changed) prompt_layout.addWidget(self.prompt_input) @@ -329,7 +331,7 @@ def _setup_ui(self): deps_header_layout.setSpacing(8) deps_header = QLabel("requirements.txt") - deps_header.setStyleSheet("font-size: 12px; font-weight: 700; color: #FFFFFF;") + deps_header.setStyleSheet(f"font-size: 12px; font-weight: 700; color: {get_surface_color('text_bright')};") deps_header_layout.addWidget(deps_header) deps_header_layout.addStretch() @@ -339,7 +341,7 @@ def _setup_ui(self): deps_hint = QLabel("One dependency per line. The sandbox automatically rebuilds the environment when this manifest changes.") deps_hint.setWordWrap(True) - deps_hint.setStyleSheet("font-size: 11px; color: #A4A4A4;") + deps_hint.setStyleSheet(f"font-size: 11px; color: {get_surface_color('text_label')};") deps_layout.addWidget(deps_hint) self.requirements_input = QPlainTextEdit() @@ -349,18 +351,18 @@ def _setup_ui(self): self.requirements_input.textChanged.connect(self._on_requirements_changed) self.requirements_input.setStyleSheet(f""" QPlainTextEdit {{ - background-color: #181818; - border: 1px solid #393939; + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; padding: 10px; - color: #E0E0E0; + color: {get_surface_color("text_primary")}; font-family: Consolas, Monaco, monospace; font-size: 12px; }} QPlainTextEdit:focus {{ border: 1px solid {accent.name()}; }} - {SANDBOX_SCROLLBAR_STYLE} + {sandbox_scrollbar_style()} """) deps_layout.addWidget(self.requirements_input) briefing_layout.addWidget(deps_card, 2) @@ -393,15 +395,15 @@ def _setup_ui(self): background-color: {accent.darker(115).name()}; }} QPushButton:disabled {{ - background-color: #474747; - color: #868686; + background-color: {get_surface_color("divider")}; + color: {get_surface_color("chrome_inactive")}; }} """ self._default_secondary_style = f""" QPushButton {{ - background-color: #252525; - color: #E3E3E3; - border: 1px solid #404040; + background-color: {get_surface_color("node_body")}; + color: {get_surface_color("text_primary")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; padding: 10px 14px; font-size: 12px; @@ -409,28 +411,28 @@ def _setup_ui(self): }} QPushButton:hover {{ border: 1px solid {accent.name()}; - color: #FFFFFF; + color: {get_surface_color("text_bright")}; }} QPushButton:pressed {{ - background-color: #1B1B1B; + background-color: {get_surface_color("window")}; }} QPushButton:disabled {{ - background-color: #232323; - color: #767676; - border: 1px solid #373737; + background-color: {get_surface_color("node_body")}; + color: {get_surface_color("text_muted")}; + border: 1px solid {get_surface_color("border")}; }} """ self.generate_button.setStyleSheet(self._default_primary_style) self.run_button.setStyleSheet(self._default_secondary_style) self.generate_button.setIcon(qta.icon("fa5s.magic", color="white")) - self.run_button.setIcon(qta.icon("fa5s.play", color="#E3E3E3")) + self.run_button.setIcon(qta.icon("fa5s.play", color=get_surface_color("text_primary"))) action_layout.addWidget(self.generate_button) action_layout.addWidget(self.run_button) action_hint = QLabel("Use the prompt for agent-driven generation, or run the current script directly with the declared dependencies.") action_hint.setWordWrap(True) - action_hint.setStyleSheet("font-size: 11px; color: #989898;") + action_hint.setStyleSheet(f"font-size: 11px; color: {get_surface_color('text_label')};") action_layout.addWidget(action_hint, 1) main_layout.addLayout(action_layout) @@ -447,12 +449,12 @@ def _setup_ui(self): code_header_layout.setSpacing(8) code_header = QLabel("Sandbox Script") - code_header.setStyleSheet("font-size: 12px; font-weight: 700; color: #FFFFFF;") + code_header.setStyleSheet(f"font-size: 12px; font-weight: 700; color: {get_surface_color('text_bright')};") code_header_layout.addWidget(code_header) code_header_layout.addStretch() code_hint = QLabel("Editable execution file") - code_hint.setStyleSheet("font-size: 11px; color: #989898;") + code_hint.setStyleSheet(f"font-size: 11px; color: {get_surface_color('text_label')};") code_header_layout.addWidget(code_hint) code_layout.addLayout(code_header_layout) @@ -461,18 +463,18 @@ def _setup_ui(self): self.code_input.setFixedHeight(250) self.code_input.setStyleSheet(f""" QPlainTextEdit {{ - background-color: #141414; - border: 1px solid #393939; + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; padding: 8px; - color: #E5E5E5; + color: {get_surface_color("text_primary")}; font-family: Consolas, Monaco, monospace; font-size: 12px; }} QPlainTextEdit:focus {{ border: 1px solid {accent.name()}; }} - {SANDBOX_SCROLLBAR_STYLE} + {sandbox_scrollbar_style()} """) self.code_input.textChanged.connect(self._on_code_changed) code_layout.addWidget(self.code_input) @@ -481,15 +483,15 @@ def _setup_ui(self): self.results_tabs = QTabWidget() self.results_tabs.setStyleSheet(f""" QTabWidget::pane {{ - border: 1px solid #3A3A3A; - background: #1B1B1B; + border: 1px solid {get_surface_color("border")}; + background: {get_surface_color("window")}; border-radius: 8px; }} QTabBar::tab {{ - background: #232323; - color: #A4A4A4; + background: {get_surface_color("node_body")}; + color: {get_surface_color("text_label")}; padding: 8px 14px; - border: 1px solid #3A3A3A; + border: 1px solid {get_surface_color("border")}; border-bottom: none; border-top-left-radius: 8px; border-top-right-radius: 8px; @@ -497,13 +499,13 @@ def _setup_ui(self): font-weight: 700; }} QTabBar::tab:selected {{ - background: #1B1B1B; - color: #FFFFFF; + background: {get_surface_color("window")}; + color: {get_surface_color("text_bright")}; border-top: 2px solid {accent.name()}; }} QTabBar::tab:hover:!selected {{ - background: #2A2A2A; - color: #FFFFFF; + background: {get_surface_color("field")}; + color: {get_surface_color("text_bright")}; }} """) @@ -512,16 +514,16 @@ def _setup_ui(self): self.output_display.setPlaceholderText("Virtualenv setup, dependency install logs, and execution output will appear here...") self.output_display.setStyleSheet(f""" QTextEdit {{ - background-color: #121212; - color: #CACACA; + background-color: {get_surface_color("inset_deep")}; + color: {get_surface_color("text_soft")}; border: none; padding: 12px; font-family: Consolas, Monaco, monospace; font-size: 11px; }} - {SANDBOX_SCROLLBAR_STYLE} + {sandbox_scrollbar_style()} """) - self.results_tabs.addTab(self.output_display, qta.icon("fa5s.terminal", color="#D2D2D2"), "Terminal") + self.results_tabs.addTab(self.output_display, qta.icon("fa5s.terminal", color=get_surface_color("text_soft")), "Terminal") self.ai_analysis_display = QTextEdit() self.ai_analysis_display.setReadOnly(True) @@ -532,18 +534,18 @@ def _setup_ui(self): border: none; padding: 12px; font-size: 12px; - color: #ECECEC; + color: {get_surface_color("text_strong")}; }} - {SANDBOX_SCROLLBAR_STYLE} + {sandbox_scrollbar_style()} """) - self.ai_analysis_display.document().setDefaultStyleSheet(""" - p, ul, ol, li { color: #ECECEC; font-family: 'Segoe UI', sans-serif; font-size: 13px; line-height: 1.5; } - h1, h2, h3, h4 { color: #FFFFFF; font-weight: bold; margin-bottom: 5px; } - pre { background-color: #292929; padding: 10px; border-radius: 6px; font-family: Consolas, monospace; color: #E1E1E1; } - code { background-color: #353535; color: #E1E1E1; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; } - blockquote { border-left: 3px solid #666666; padding-left: 10px; color: #B5B5B5; } + self.ai_analysis_display.document().setDefaultStyleSheet(f""" + p, ul, ol, li {{ color: {get_surface_color("text_strong")}; font-family: {FONT_FAMILY}; font-size: 13px; line-height: 1.5; }} + h1, h2, h3, h4 {{ color: {get_surface_color("text_bright")}; font-weight: bold; margin-bottom: 5px; }} + pre {{ background-color: {get_surface_color("field")}; padding: 10px; border-radius: 6px; font-family: Consolas, monospace; color: {get_surface_color("text_primary")}; }} + code {{ background-color: {get_surface_color("border")}; color: {get_surface_color("text_primary")}; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; }} + blockquote {{ border-left: 3px solid {get_surface_color("handle_hover")}; padding-left: 10px; color: {get_surface_color("text_secondary")}; }} """) - self.results_tabs.addTab(self.ai_analysis_display, qta.icon("fa5s.search", color="#D2D2D2"), "Review") + self.results_tabs.addTab(self.ai_analysis_display, qta.icon("fa5s.search", color=get_surface_color("text_soft")), "Review") main_layout.addWidget(self.results_tabs, 1) self.highlighter_code = PythonHighlighter(self.code_input.document()) @@ -553,12 +555,12 @@ def _setup_ui(self): def _create_surface_card(self): card = QWidget() card.setObjectName("sandboxSurfaceCard") - card.setStyleSheet(""" - QWidget#sandboxSurfaceCard { - background-color: #1E1E1E; - border: 1px solid #3A3A3A; + card.setStyleSheet(f""" + QWidget#sandboxSurfaceCard {{ + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 10px; - } + }} """) return card @@ -629,13 +631,13 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 12, 12) - painter.setBrush(QColor("#2B2B2B")) + painter.setBrush(QColor(get_surface_color("field"))) pen = QPen(accent, 1.6) if self.isSelected(): pen = QPen(palette.SELECTION, 2.2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2.0) + pen = QPen(QColor(get_surface_color("text_bright")), 2.0) painter.setPen(pen) painter.drawPath(path) @@ -681,14 +683,14 @@ def paint(self, painter, option, widget=None): return if self.is_collapsed: - painter.setPen(QColor("#FFFFFF")) + painter.setPen(QColor(get_surface_color("text_bright"))) painter.setFont(canvas_font(self.scene(), weight=QFont.Weight.Bold)) painter.drawText(QRectF(42, 0, self.width - 84, self.height), Qt.AlignmentFlag.AlignVCenter, "Execution Sandbox") qta.icon("fa5s.shield-alt", color=accent.name()).paint(painter, QRect(12, 11, 18, 18)) self.collapse_button_rect = QRectF(self.width - 34, 6, 28, 28) expand_icon = qta.icon( "fa5s.expand-arrows-alt", - color="#FFFFFF" if self.hovered else "#979797", + color=get_surface_color("text_bright") if self.hovered else get_surface_color("text_label"), ) expand_icon.paint(painter, QRect(int(self.width - 30), 10, 18, 18)) return @@ -699,7 +701,7 @@ def paint(self, painter, option, widget=None): painter.setPen(QColor(255, 255, 255, 120)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(5, 5, -5, -5), 4, 4) center = self.collapse_button_rect.center() - painter.setPen(QPen(QColor("#FFFFFF"), 2)) + painter.setPen(QPen(QColor(get_surface_color("text_bright")), 2)) painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) else: self.collapse_button_rect = QRectF() @@ -896,22 +898,22 @@ def set_running_state(self, is_running): self.requirements_input.setReadOnly(is_running) self.code_input.setReadOnly(is_running) - stop_style = """ - QPushButton { - background-color: #646464; + stop_style = f""" + QPushButton {{ + background-color: {get_surface_color("handle_hover")}; color: white; border: none; border-radius: 8px; padding: 10px 14px; font-size: 12px; font-weight: 700; - } - QPushButton:hover { - background-color: #727272; - } - QPushButton:pressed { - background-color: #535353; - } + }} + QPushButton:hover {{ + background-color: {get_surface_color("text_muted")}; + }} + QPushButton:pressed {{ + background-color: {get_surface_color("handle")}; + }} """ if is_running: @@ -931,7 +933,7 @@ def set_running_state(self, is_running): self.generate_button.setStyleSheet(self._default_primary_style) self.run_button.setStyleSheet(self._default_secondary_style) self.generate_button.setIcon(qta.icon("fa5s.magic", color="white")) - self.run_button.setIcon(qta.icon("fa5s.play", color="#E3E3E3")) + self.run_button.setIcon(qta.icon("fa5s.play", color=get_surface_color("text_primary"))) tone = "success" if self.status == "Ready" else "info" self._update_status_pill(tone) diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py b/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py index 875aa6c..d14d9bf 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_gitlink.py @@ -29,7 +29,8 @@ from graphlink_config import canvas_font from graphlink_canvas_items import HoverAnimationMixin -from graphlink_config import get_current_palette, get_semantic_color +from graphlink_config import get_current_palette, get_semantic_color, get_surface_color +from graphlink_styles import FONT_FAMILY from graphlink_connections import ConnectionItem from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu from graphlink_plugins.common.github_client import GitHubRestClient @@ -51,49 +52,50 @@ from graphlink_plugins.common.combo import PopupComboBox -GITLINK_SCROLLBAR_STYLE = """ - QScrollBar:vertical { - background: #1D1D1D; +def gitlink_scrollbar_style(): + return f""" + QScrollBar:vertical {{ + background: {get_surface_color("window")}; width: 10px; margin: 0px; border-radius: 5px; - } - QScrollBar::handle:vertical { - background-color: #5A5A5A; + }} + QScrollBar::handle:vertical {{ + background-color: {get_surface_color("handle")}; min-height: 25px; border-radius: 5px; - } - QScrollBar::handle:vertical:hover { - background-color: #717171; - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + }} + QScrollBar::handle:vertical:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; background: none; - } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: none; - } - QScrollBar:horizontal { - background: #1D1D1D; + }} + QScrollBar:horizontal {{ + background: {get_surface_color("window")}; height: 10px; margin: 0px; border-radius: 5px; - } - QScrollBar::handle:horizontal { - background-color: #5A5A5A; + }} + QScrollBar::handle:horizontal {{ + background-color: {get_surface_color("handle")}; min-width: 25px; border-radius: 5px; - } - QScrollBar::handle:horizontal:hover { - background-color: #717171; - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + }} + QScrollBar::handle:horizontal:hover {{ + background-color: {get_surface_color("handle_hover")}; + }} + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0px; background: none; - } - QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + }} + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; - } + }} """ MAX_REPO_PAGES = 5 @@ -258,34 +260,34 @@ def _build_ui(self): f""" QWidget#gitlinkMainWidget {{ background-color: transparent; - color: #E0E0E0; - font-family: 'Segoe UI', sans-serif; + color: {get_surface_color("text_primary")}; + font-family: {FONT_FAMILY}; }} QWidget#gitlinkMainWidget QLabel {{ background-color: transparent; }} QFrame#gitlinkHeaderCard, QFrame#gitlinkSectionCard {{ - background-color: #232323; - border: 1px solid #3A3A3A; + background-color: {get_surface_color("node_body")}; + border: 1px solid {get_surface_color("border")}; border-radius: 10px; }} QLabel#gitlinkTitle {{ - color: #FFFFFF; + color: {get_surface_color("text_bright")}; font-size: 17px; font-weight: bold; }} QLabel#gitlinkHint {{ - color: #A5A5A5; + color: {get_surface_color("text_label")}; font-size: 11px; }} QLabel#gitlinkFieldLabel {{ - color: #D6D6D6; + color: {get_surface_color("text_primary")}; font-size: 11px; font-weight: bold; }} QLabel#gitlinkBadge {{ - color: #F3F3F3; + color: {get_surface_color("text_strong")}; background-color: rgba({badge_rgba}, 0.14); border: 1px solid rgba({badge_rgba}, 0.28); border-radius: 10px; @@ -298,9 +300,9 @@ def _build_ui(self): QListWidget, QPlainTextEdit, QTextEdit {{ - background-color: #1A1A1A; - color: #F3F3F3; - border: 1px solid #3A3A3A; + background-color: {get_surface_color("window")}; + color: {get_surface_color("text_strong")}; + border: 1px solid {get_surface_color("border")}; border-radius: 8px; padding: 6px 8px; selection-background-color: {node_color.name()}; @@ -313,35 +315,35 @@ def _build_ui(self): border-color: {node_color.name()}; }} QPushButton {{ - background-color: #303030; - color: #FFFFFF; - border: 1px solid #474747; + background-color: {get_surface_color("field")}; + color: {get_surface_color("text_bright")}; + border: 1px solid {get_surface_color("divider")}; border-radius: 8px; padding: 7px 12px; font-weight: bold; }} QPushButton:hover {{ - background-color: #3E3E3E; + background-color: {get_surface_color("border")}; border-color: {node_color.name()}; }} QPushButton:pressed {{ - background-color: #2E2E2E; + background-color: {get_surface_color("field")}; }} QPushButton:disabled {{ - background-color: #262626; - color: #848484; - border-color: #353535; + background-color: {get_surface_color("node_body")}; + color: {get_surface_color("chrome_inactive")}; + border-color: {get_surface_color("border")}; }} QTabWidget::pane {{ - border: 1px solid #3F3F3F; - background: #202020; + border: 1px solid {get_surface_color("border")}; + background: {get_surface_color("inset")}; border-radius: 8px; }} QTabBar::tab {{ - background: #282828; - color: #A0A0A0; + background: {get_surface_color("field")}; + color: {get_surface_color("text_label")}; padding: 7px 12px; - border: 1px solid #3F3F3F; + border: 1px solid {get_surface_color("border")}; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; @@ -349,10 +351,10 @@ def _build_ui(self): font-weight: bold; }} QTabBar::tab:selected {{ - background: #202020; - color: #FFFFFF; + background: {get_surface_color("inset")}; + color: {get_surface_color("text_bright")}; border-top: 2px solid {node_color.name()}; - border-bottom: 1px solid #202020; + border-bottom: 1px solid {get_surface_color("inset")}; }} QListWidget::item {{ padding: 4px 6px; @@ -360,7 +362,7 @@ def _build_ui(self): }} QListWidget::item:selected {{ background: rgba({badge_rgba}, 0.22); - color: #FFFFFF; + color: {get_surface_color("text_bright")}; }} """ ) @@ -424,7 +426,7 @@ def _build_ui(self): setup_scroll = QScrollArea() setup_scroll.setWidgetResizable(True) setup_scroll.setFrameShape(QFrame.Shape.NoFrame) - setup_scroll.setStyleSheet(GITLINK_SCROLLBAR_STYLE) + setup_scroll.setStyleSheet(gitlink_scrollbar_style()) setup_layout.addWidget(setup_scroll) setup_content = QWidget() @@ -440,7 +442,7 @@ def _build_ui(self): repo_layout.setSpacing(8) repo_title = QLabel("GitHub Source") - repo_title.setStyleSheet("color: #FFFFFF; font-weight: bold;") + repo_title.setStyleSheet(f"color: {get_surface_color('text_bright')}; font-weight: bold;") repo_layout.addWidget(repo_title) self.github_state_label = QLabel("") @@ -453,7 +455,7 @@ def _build_ui(self): repo_picker_row.setSpacing(8) self.load_repos_button = QPushButton("Load Repo List") - self.load_repos_button.setIcon(qta.icon("fa5s.sync-alt", color="#FFFFFF")) + self.load_repos_button.setIcon(qta.icon("fa5s.sync-alt", color=get_surface_color("text_bright"))) self.load_repos_button.clicked.connect(self.load_github_repositories) repo_picker_row.addWidget(self.load_repos_button) @@ -485,7 +487,7 @@ def _build_ui(self): branch_row.addWidget(self.branch_input, stretch=1) self.load_tree_button = QPushButton("Load File Tree") - self.load_tree_button.setIcon(qta.icon("fa5s.sitemap", color="#FFFFFF")) + self.load_tree_button.setIcon(qta.icon("fa5s.sitemap", color=get_surface_color("text_bright"))) self.load_tree_button.clicked.connect(self.load_repository_tree) branch_row.addWidget(self.load_tree_button) repo_layout.addLayout(branch_row) @@ -499,7 +501,7 @@ def _build_ui(self): scope_layout.setSpacing(8) scope_title = QLabel("Access Scope") - scope_title.setStyleSheet("color: #FFFFFF; font-weight: bold;") + scope_title.setStyleSheet(f"color: {get_surface_color('text_bright')}; font-weight: bold;") scope_layout.addWidget(scope_title) scope_hint = QLabel( @@ -544,7 +546,7 @@ def _build_ui(self): self.file_list.setAlternatingRowColors(False) self.file_list.itemSelectionChanged.connect(self._on_file_selection_changed) self.file_list.setMinimumHeight(200) - self.file_list.setStyleSheet(GITLINK_SCROLLBAR_STYLE) + self.file_list.setStyleSheet(gitlink_scrollbar_style()) scope_layout.addWidget(self.file_list) setup_content_layout.addWidget(scope_card) @@ -556,7 +558,7 @@ def _build_ui(self): workspace_layout.setSpacing(8) workspace_title = QLabel("Writable Checkout") - workspace_title.setStyleSheet("color: #FFFFFF; font-weight: bold;") + workspace_title.setStyleSheet(f"color: {get_surface_color('text_bright')}; font-weight: bold;") workspace_layout.addWidget(workspace_title) workspace_hint = QLabel( @@ -580,12 +582,12 @@ def _build_ui(self): workspace_button_row.setSpacing(8) self.browse_root_button = QPushButton("Browse") - self.browse_root_button.setIcon(qta.icon("fa5s.folder-open", color="#FFFFFF")) + self.browse_root_button.setIcon(qta.icon("fa5s.folder-open", color=get_surface_color("text_bright"))) self.browse_root_button.clicked.connect(self.browse_local_root) workspace_button_row.addWidget(self.browse_root_button) self.import_repo_button = QPushButton("Import Repo Snapshot") - self.import_repo_button.setIcon(qta.icon("fa5s.download", color="#FFFFFF")) + self.import_repo_button.setIcon(qta.icon("fa5s.download", color=get_surface_color("text_bright"))) self.import_repo_button.clicked.connect(self.import_repository_snapshot) workspace_button_row.addWidget(self.import_repo_button) workspace_button_row.addStretch() @@ -600,7 +602,7 @@ def _build_ui(self): task_layout.setSpacing(8) task_title = QLabel("Task Prompt") - task_title.setStyleSheet("color: #FFFFFF; font-weight: bold;") + task_title.setStyleSheet(f"color: {get_surface_color('text_bright')}; font-weight: bold;") task_layout.addWidget(task_title) task_hint = QLabel( @@ -621,17 +623,17 @@ def _build_ui(self): button_row.setSpacing(8) self.build_context_button = QPushButton("Build XML Context") - self.build_context_button.setIcon(qta.icon("fa5s.file-code", color="#FFFFFF")) + self.build_context_button.setIcon(qta.icon("fa5s.file-code", color=get_surface_color("text_bright"))) self.build_context_button.clicked.connect(self.build_context_preview) button_row.addWidget(self.build_context_button) self.run_button = QPushButton("Generate Change Set") - self.run_button.setIcon(qta.icon("fa5s.magic", color="#FFFFFF")) + self.run_button.setIcon(qta.icon("fa5s.magic", color=get_surface_color("text_bright"))) self.run_button.clicked.connect(self._request_gitlink_run) button_row.addWidget(self.run_button) self.apply_button = QPushButton("Apply Approved Changes") - self.apply_button.setIcon(qta.icon("fa5s.save", color="#FFFFFF")) + self.apply_button.setIcon(qta.icon("fa5s.save", color=get_surface_color("text_bright"))) self.apply_button.clicked.connect(self.apply_approved_changes) self.apply_button.setEnabled(False) button_row.addWidget(self.apply_button) @@ -642,20 +644,20 @@ def _build_ui(self): self.context_editor = QPlainTextEdit() self.context_editor.setReadOnly(True) - self.context_editor.setStyleSheet(GITLINK_SCROLLBAR_STYLE) + self.context_editor.setStyleSheet(gitlink_scrollbar_style()) self.proposal_display = QTextEdit() self.proposal_display.setReadOnly(True) - self.proposal_display.setStyleSheet(GITLINK_SCROLLBAR_STYLE) + self.proposal_display.setStyleSheet(gitlink_scrollbar_style()) self.preview_editor = QPlainTextEdit() self.preview_editor.setReadOnly(True) - self.preview_editor.setStyleSheet(GITLINK_SCROLLBAR_STYLE) + self.preview_editor.setStyleSheet(gitlink_scrollbar_style()) - self.workspace_tabs.addTab(setup_tab, qta.icon("fa5s.link", color="#CCCCCC"), "Setup") - self.workspace_tabs.addTab(self.context_editor, qta.icon("fa5s.file-code", color="#CCCCCC"), "Context XML") - self.workspace_tabs.addTab(self.proposal_display, qta.icon("fa5s.tasks", color="#CCCCCC"), "Proposal") - self.workspace_tabs.addTab(self.preview_editor, qta.icon("fa5s.columns", color="#CCCCCC"), "Preview") + self.workspace_tabs.addTab(setup_tab, qta.icon("fa5s.link", color=get_surface_color("text_soft")), "Setup") + self.workspace_tabs.addTab(self.context_editor, qta.icon("fa5s.file-code", color=get_surface_color("text_soft")), "Context XML") + self.workspace_tabs.addTab(self.proposal_display, qta.icon("fa5s.tasks", color=get_surface_color("text_soft")), "Proposal") + self.workspace_tabs.addTab(self.preview_editor, qta.icon("fa5s.columns", color=get_surface_color("text_soft")), "Preview") def _get_github_token(self): # Delegates to the GitHubRestClient shared with Code Review - kept as a thin @@ -1337,7 +1339,7 @@ def set_status(self, status_text): elif "ready" in status_text.lower() or "applied" in status_text.lower(): color = get_semantic_color("status_success") else: - color = QColor("#A2A2A2") + color = QColor(get_surface_color("text_label")) self.status_label.setStyleSheet( f"color: {color.name()}; background-color: rgba({color.red()}, {color.green()}, {color.blue()}, 0.1); " @@ -1424,7 +1426,7 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) + painter.setBrush(QColor(get_surface_color("field"))) pen = QPen(node_color, 1.5) if self.isSelected(): @@ -1432,7 +1434,7 @@ def paint(self, painter, option, widget=None): elif self.is_search_match: pen = QPen(get_semantic_color("search_highlight"), 2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2) + pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(pen) painter.drawPath(path) @@ -1458,14 +1460,14 @@ def paint(self, painter, option, widget=None): painter.drawPie(dot_rect_right, 90 * 16, 180 * 16) if self.is_collapsed: - painter.setPen(QColor("#FFFFFF")) + painter.setPen(QColor(get_surface_color("text_bright"))) painter.setFont(canvas_font(self.scene(), weight=QFont.Weight.Bold)) painter.drawText(QRectF(42, 0, self.width - 84, self.height), Qt.AlignmentFlag.AlignVCenter, "Gitlink") qta.icon("fa5s.link", color=node_color.name()).paint(painter, QRect(12, 10, 20, 20)) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) qta.icon( "fa5s.expand-arrows-alt", - color="#FFFFFF" if self.hovered else "#888888", + color=get_surface_color("text_bright") if self.hovered else get_surface_color("chrome_inactive"), ).paint(painter, QRect(int(self.width - 30), 10, 20, 20)) else: if self.hovered: @@ -1473,7 +1475,7 @@ def paint(self, painter, option, widget=None): painter.setBrush(QColor(255, 255, 255, 30)) painter.setPen(QColor(255, 255, 255, 150)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(6, 6, -6, -6), 4, 4) - painter.setPen(QPen(QColor("#FFFFFF"), 2)) + painter.setPen(QPen(QColor(get_surface_color("text_bright")), 2)) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) else: diff --git a/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py b/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py index c1b6501..1227b28 100644 --- a/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py +++ b/graphlink_app/graphlink_plugins/graphlink_plugin_portal.py @@ -7,7 +7,7 @@ SystemPromptConnectionItem, PyCoderConnectionItem, ConversationConnectionItem, HtmlConnectionItem ) -from graphlink_config import get_current_palette +from graphlink_config import get_current_palette, get_surface_color from graphlink_pycoder import PyCoderNode from graphlink_plugins.graphlink_plugin_code_sandbox import CodeSandboxNode, CodeSandboxConnectionItem from graphlink_node import ChatNode, CodeNode @@ -415,7 +415,7 @@ def _create_system_prompt_node(self): prompt_note.is_system_prompt = True prompt_note.content = "Enter custom system prompt here..." prompt_note.header_color = palette.FRAME_COLORS["Purple Header"]["color"] - prompt_note.color = "#252525" + prompt_note.color = get_surface_color("node_body") prompt_note.width = 300 prompt_note.height = 150 diff --git a/graphlink_app/graphlink_pycoder.py b/graphlink_app/graphlink_pycoder.py index 54b259d..909504e 100644 --- a/graphlink_app/graphlink_pycoder.py +++ b/graphlink_app/graphlink_pycoder.py @@ -15,8 +15,9 @@ from PySide6.QtCore import QRectF, Qt, Signal, Property, QPropertyAnimation, QEasingCurve, QPointF, QRegularExpression, QSize, QRect from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QIcon, QSyntaxHighlighter, QTextCharFormat, QFont import qtawesome as qta -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color, get_surface_color, get_syntax_color from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state +from graphlink_styles import FONT_FAMILY from graphlink_agents_pycoder import PyCoderStage, PyCoderStatus from graphlink_canvas_items import HoverAnimationMixin @@ -31,7 +32,7 @@ def __init__(self, document): # Keywords keyword_format = QTextCharFormat() - keyword_format.setForeground(QColor("#909090")) # Purple + keyword_format.setForeground(QColor(get_syntax_color("keyword"))) keyword_format.setFontWeight(QFont.Weight.Bold) keywords = [ "and", "as", "assert", "break", "class", "continue", "def", @@ -46,7 +47,7 @@ def __init__(self, document): # Builtins builtin_format = QTextCharFormat() - builtin_format.setForeground(QColor("#A2A2A2")) # Cyan + builtin_format.setForeground(QColor(get_syntax_color("builtin"))) builtins = ["print", "len", "range", "int", "float", "str", "list", "dict", "set", "tuple", "open", "type", "dir"] for word in builtins: pattern = QRegularExpression(rf"\b{word}\b") @@ -54,24 +55,24 @@ def __init__(self, document): # Numbers number_format = QTextCharFormat() - number_format.setForeground(QColor("#A2A2A2")) # Orange + number_format.setForeground(QColor(get_syntax_color("number"))) self.highlighting_rules.append((QRegularExpression(r"\b[0-9]+(?:\.[0-9]+)?\b"), number_format)) # Strings string_format = QTextCharFormat() - string_format.setForeground(QColor("#B5B5B5")) # Green + string_format.setForeground(QColor(get_syntax_color("string"))) self.highlighting_rules.append((QRegularExpression(r'".*?"'), string_format)) self.highlighting_rules.append((QRegularExpression(r"'.*?'"), string_format)) # Comments comment_format = QTextCharFormat() - comment_format.setForeground(QColor("#626262")) # Grey + comment_format.setForeground(QColor(get_syntax_color("comment"))) comment_format.setFontItalic(True) self.highlighting_rules.append((QRegularExpression(r"#[^\n]*"), comment_format)) # Functions function_format = QTextCharFormat() - function_format.setForeground(QColor("#A3A3A3")) # Blue + function_format.setForeground(QColor(get_syntax_color("function"))) self.highlighting_rules.append((QRegularExpression(r"\b[A-Za-z0-9_]+(?=\()"), function_format)) def highlightBlock(self, text): @@ -134,7 +135,7 @@ def resizeEvent(self, event): def lineNumberAreaPaintEvent(self, event): painter = QPainter(self.lineNumberArea) - painter.fillRect(event.rect(), QColor("#1E1E1E").lighter(120)) + painter.fillRect(event.rect(), QColor(get_surface_color("window")).lighter(120)) block = self.firstVisibleBlock() blockNumber = block.blockNumber() @@ -144,7 +145,7 @@ def lineNumberAreaPaintEvent(self, event): while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): number = str(blockNumber + 1) - painter.setPen(QColor("#6B6B6B")) + painter.setPen(QColor(get_surface_color("handle_hover"))) font = painter.font() font.setFamily("Consolas") painter.setFont(font) @@ -193,7 +194,7 @@ def paintEvent(self, event): palette = get_current_palette() if self._status == PyCoderStatus.PENDING: - painter.setPen(QPen(QColor("#555555"), 2)) + painter.setPen(QPen(QColor(get_surface_color("handle")), 2)) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawEllipse(rect) elif self._status == PyCoderStatus.RUNNING: @@ -250,7 +251,7 @@ def __init__(self, text, parent=None): self.icon_widget = StatusIconWidget() self.text_label = QLabel(text) - self.text_label.setStyleSheet("color: #888888; font-size: 11px;") + self.text_label.setStyleSheet(f"color: {get_surface_color('chrome_inactive')}; font-size: 11px;") layout.addWidget(self.icon_widget) layout.addWidget(self.text_label) @@ -262,7 +263,7 @@ def set_status(self, status): self.icon_widget.set_status(status) palette = get_current_palette() if status == PyCoderStatus.PENDING: - self.text_label.setStyleSheet("color: #888888; font-style: italic; font-size: 11px;") + self.text_label.setStyleSheet(f"color: {get_surface_color('chrome_inactive')}; font-style: italic; font-size: 11px;") elif status == PyCoderStatus.RUNNING: self.text_label.setStyleSheet(f"color: {palette.AI_NODE.name()}; font-style: normal; font-weight: bold; font-size: 11px;") elif status == PyCoderStatus.SUCCESS: @@ -291,13 +292,13 @@ def __init__(self, parent=None): layout.addWidget(self.stages[PyCoderStage.EXECUTE], 1, 0) layout.addWidget(self.stages[PyCoderStage.ANALYZE_RESULT], 1, 1) - self.setStyleSheet(""" - StatusTrackerWidget { - background-color: #1E1E1E; - border: 1px solid #3F3F3F; + self.setStyleSheet(f""" + StatusTrackerWidget {{ + background-color: {get_surface_color("window")}; + border: 1px solid {get_surface_color("border")}; border-radius: 6px; - } - QLabel { background: transparent; } + }} + QLabel {{ background: transparent; }} """) def update_status(self, stage, status): @@ -372,15 +373,15 @@ def __init__(self, parent_node, mode=PyCoderMode.AI_DRIVEN, parent=None): self.widget = QWidget() self.widget.setObjectName("pyCoderMainWidget") self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#pyCoderMainWidget { + self.widget.setStyleSheet(f""" + QWidget#pyCoderMainWidget {{ background-color: transparent; - color: #E0E0E0; - font-family: 'Segoe UI', sans-serif; - } - QWidget#pyCoderMainWidget QLabel { + color: {get_surface_color("text_primary")}; + font-family: {FONT_FAMILY}; + }} + QWidget#pyCoderMainWidget QLabel {{ background-color: transparent; - } + }} """) self._setup_ui() @@ -506,16 +507,16 @@ def _setup_ui(self): self.mode_toggle_button = QPushButton() self.mode_toggle_button.setFixedSize(30, 30) self.mode_toggle_button.clicked.connect(self._toggle_mode) - self.mode_toggle_button.setStyleSheet(""" - QPushButton { border: 1px solid #555; border-radius: 15px; background-color: #2A2A2A; } - QPushButton:hover { background-color: #4F4F4F; border: 1px solid #777; } + self.mode_toggle_button.setStyleSheet(f""" + QPushButton {{ border: 1px solid {get_surface_color("handle")}; border-radius: 15px; background-color: {get_surface_color("field")}; }} + QPushButton:hover {{ background-color: {get_surface_color("border_strong")}; border: 1px solid {get_surface_color("text_muted")}; }} """) header_layout.addWidget(self.mode_toggle_button) main_layout.addLayout(header_layout) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) - line.setStyleSheet("background-color: #3F3F3F; border: none; height: 1px;") + line.setStyleSheet(f"background-color: {get_surface_color('border')}; border: none; height: 1px;") main_layout.addWidget(line) # --- AI-Driven Input Container --- @@ -525,7 +526,7 @@ def _setup_ui(self): prompt_layout.setSpacing(5) self.ai_prompt_label = QLabel("Instruction Prompt:") - self.ai_prompt_label.setStyleSheet("color: #AAAAAA; font-weight: bold; font-size: 12px; background: transparent;") + self.ai_prompt_label.setStyleSheet(f"color: {get_surface_color('text_label')}; font-weight: bold; font-size: 12px; background: transparent;") prompt_layout.addWidget(self.ai_prompt_label) self.prompt_input = QTextEdit() @@ -533,14 +534,14 @@ def _setup_ui(self): self.prompt_input.setFixedHeight(70) prompt_focus_color = get_semantic_color("status_info").name() prompt_focus_color = get_semantic_color("status_info").name() - self.prompt_input.setStyleSheet(""" - QTextEdit { - background-color: #1E1E1E; border: 1px solid #3F3F3F; - border-radius: 6px; padding: 8px; color: #FFFFFF; - font-family: 'Segoe UI', sans-serif; - } - QTextEdit:focus { border: 1px solid %s; } - """ % prompt_focus_color) + self.prompt_input.setStyleSheet(f""" + QTextEdit {{ + background-color: {get_surface_color("window")}; border: 1px solid {get_surface_color("border")}; + border-radius: 6px; padding: 8px; color: {get_surface_color("text_bright")}; + font-family: {FONT_FAMILY}; + }} + QTextEdit:focus {{ border: 1px solid {prompt_focus_color}; }} + """) self.prompt_input.textChanged.connect(self._on_prompt_changed) prompt_layout.addWidget(self.prompt_input) @@ -556,20 +557,20 @@ def _setup_ui(self): manual_layout.setSpacing(5) self.manual_code_label = QLabel("Python Code:") - self.manual_code_label.setStyleSheet("color: #AAAAAA; font-weight: bold; font-size: 12px; background: transparent;") + self.manual_code_label.setStyleSheet(f"color: {get_surface_color('text_label')}; font-weight: bold; font-size: 12px; background: transparent;") manual_layout.addWidget(self.manual_code_label) self.code_input = CodeEditor() self.code_input.setPlaceholderText("Enter Python code to execute...") self.code_input.setFixedHeight(140) - self.code_input.setStyleSheet(""" - QPlainTextEdit { - background-color: #1E1E1E; border: 1px solid #3F3F3F; - border-radius: 6px; padding: 8px; color: #D8D8D8; + self.code_input.setStyleSheet(f""" + QPlainTextEdit {{ + background-color: {get_surface_color("window")}; border: 1px solid {get_surface_color("border")}; + border-radius: 6px; padding: 8px; color: {get_surface_color("text_primary")}; font-family: Consolas, Monaco, monospace; font-size: 13px; - } - QPlainTextEdit:focus { border: 1px solid %s; } - """ % prompt_focus_color) + }} + QPlainTextEdit:focus {{ border: 1px solid {prompt_focus_color}; }} + """) self.code_input.textChanged.connect(self._on_code_changed) manual_layout.addWidget(self.code_input) @@ -597,9 +598,9 @@ def _setup_ui(self): border-color: {button_colors["border"].darker(105).name()}; }} QPushButton:disabled {{ - background-color: #2B2B2B; - border-color: #353535; - color: #7B7B7B; + background-color: {get_surface_color("field")}; + border-color: {get_surface_color("border")}; + color: {get_surface_color("text_muted")}; }} """ for btn in [self.generate_button, self.run_button]: @@ -616,21 +617,21 @@ def _setup_ui(self): self.tabs = QTabWidget() self.tabs.setStyleSheet(f""" QTabWidget::pane {{ - border: 1px solid #3F3F3F; background: #1E1E1E; border-radius: 6px; + border: 1px solid {get_surface_color("border")}; background: {get_surface_color("window")}; border-radius: 6px; }} QTabBar::tab {{ - background: #252525; color: #AAAAAA; padding: 8px 16px; - border: 1px solid #3F3F3F; border-bottom: none; + background: {get_surface_color("node_body")}; color: {get_surface_color("text_label")}; padding: 8px 16px; + border: 1px solid {get_surface_color("border")}; border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px; margin-right: 2px; font-weight: bold; }} QTabBar::tab:selected {{ - background: #1E1E1E; color: #FFFFFF; + background: {get_surface_color("window")}; color: {get_surface_color("text_bright")}; border-top: 2px solid {pycoder_color.name()}; - border-bottom: 1px solid #1E1E1E; + border-bottom: 1px solid {get_surface_color("window")}; }} QTabBar::tab:hover:!selected {{ - background: #2D2D2D; color: #FFFFFF; + background: {get_surface_color("field")}; color: {get_surface_color("text_bright")}; }} """) @@ -638,20 +639,20 @@ def _setup_ui(self): self.generated_code_display = CodeEditor() self.generated_code_display.setReadOnly(True) self.generated_code_display.setPlaceholderText("Generated code will appear here...") - self.generated_code_display.setStyleSheet(""" - QPlainTextEdit { background-color: transparent; color: #D8D8D8; font-family: Consolas, monospace; border: none; padding: 10px; font-size: 13px; } + self.generated_code_display.setStyleSheet(f""" + QPlainTextEdit {{ background-color: transparent; color: {get_surface_color("text_primary")}; font-family: Consolas, monospace; border: none; padding: 10px; font-size: 13px; }} """) - self.tabs.addTab(self.generated_code_display, qta.icon('fa5s.code', color='#ccc'), "Code") + self.tabs.addTab(self.generated_code_display, qta.icon('fa5s.code', color=get_surface_color("text_soft")), "Code") # 2. Terminal / Output Tab self.output_display = QTextEdit() self.output_display.setReadOnly(True) self.output_display.setPlaceholderText("Execution output will appear here...") output_text_color = get_semantic_color("status_success").name() - self.output_display.setStyleSheet(""" - QTextEdit { background-color: #0D0D0D; color: %s; font-family: Consolas, monospace; border: none; padding: 10px; font-size: 12px; } - """ % output_text_color) - self.tabs.addTab(self.output_display, qta.icon('fa5s.terminal', color='#ccc'), "Terminal") + self.output_display.setStyleSheet(f""" + QTextEdit {{ background-color: {get_surface_color("inset_deep")}; color: {output_text_color}; font-family: Consolas, monospace; border: none; padding: 10px; font-size: 12px; }} + """) + self.tabs.addTab(self.output_display, qta.icon('fa5s.terminal', color=get_surface_color("text_soft")), "Terminal") # 3. AI Analysis Tab self.ai_analysis_display = QTextEdit() @@ -661,14 +662,14 @@ def _setup_ui(self): QTextEdit { background-color: transparent; border: none; padding: 10px; } """) # Base styling for markdown content inside the text edit - self.ai_analysis_display.document().setDefaultStyleSheet(""" - p, ul, ol, li { color: #E0E0E0; font-family: 'Segoe UI', sans-serif; font-size: 13px; line-height: 1.5; } - h1, h2, h3, h4 { color: #FFFFFF; font-weight: bold; margin-bottom: 5px; } - pre { background-color: #2D2D2D; padding: 10px; border-radius: 6px; font-family: Consolas, monospace; color: #D8D8D8; } - code { background-color: #3F3F3F; color: #D8D8D8; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; } - blockquote { border-left: 3px solid #555555; padding-left: 10px; color: #AAAAAA; } + self.ai_analysis_display.document().setDefaultStyleSheet(f""" + p, ul, ol, li {{ color: {get_surface_color("text_primary")}; font-family: {FONT_FAMILY}; font-size: 13px; line-height: 1.5; }} + h1, h2, h3, h4 {{ color: {get_surface_color("text_bright")}; font-weight: bold; margin-bottom: 5px; }} + pre {{ background-color: {get_surface_color("field")}; padding: 10px; border-radius: 6px; font-family: Consolas, monospace; color: {get_surface_color("text_primary")}; }} + code {{ background-color: {get_surface_color("border")}; color: {get_surface_color("text_primary")}; padding: 2px 4px; border-radius: 4px; font-family: Consolas, monospace; }} + blockquote {{ border-left: 3px solid {get_surface_color("handle")}; padding-left: 10px; color: {get_surface_color("text_label")}; }} """) - self.tabs.addTab(self.ai_analysis_display, qta.icon('fa5s.robot', color='#ccc'), "Analysis") + self.tabs.addTab(self.ai_analysis_display, qta.icon('fa5s.robot', color=get_surface_color("text_soft")), "Analysis") main_layout.addWidget(self.tabs) @@ -692,13 +693,13 @@ def _update_ui_for_mode(self): if is_ai_mode: self.title_label.setText("Py-Coder (AI-Driven)") - self.mode_toggle_button.setIcon(qta.icon('fa5s.user-edit', color='#ccc')) + self.mode_toggle_button.setIcon(qta.icon('fa5s.user-edit', color=get_surface_color("text_soft"))) self.mode_toggle_button.setToolTip("Switch to Manual Mode") self.tabs.setTabVisible(0, True) # Show Code tab self.tabs.setCurrentIndex(0) else: self.title_label.setText("Py-Coder (Manual)") - self.mode_toggle_button.setIcon(qta.icon('fa5s.magic', color='#ccc')) + self.mode_toggle_button.setIcon(qta.icon('fa5s.magic', color=get_surface_color("text_soft"))) self.mode_toggle_button.setToolTip("Switch to AI-Driven Mode") self.tabs.setTabVisible(0, False) # Hide Code tab (it's in the top input box now) self.tabs.setCurrentIndex(1) # Default to Terminal tab @@ -720,19 +721,19 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) - + painter.setBrush(QColor(get_surface_color("field"))) + pycoder_color = node_colors["border"] pen = QPen(pycoder_color, 1.5) if self.isSelected(): pen = QPen(palette.SELECTION, 2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2) - + pen = QPen(QColor(get_surface_color("text_bright")), 2) + painter.setPen(pen) painter.drawPath(path) - + dot_color = node_colors["dot"] if self.isSelected() or self.hovered: dot_color = pen.color().lighter(110) if self.isSelected() else node_colors["hover_dot"] @@ -765,7 +766,7 @@ def paint(self, painter, option, widget=None): return if self.is_collapsed: - painter.setPen(QColor("#FFFFFF")) + painter.setPen(QColor(get_surface_color("text_bright"))) font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) title = f"Py-Coder ({'AI' if self.mode == PyCoderMode.AI_DRIVEN else 'Manual'})" @@ -775,7 +776,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRect(10, 10, 20, 20)) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - expand_icon = qta.icon('fa5s.expand-arrows-alt', color='#FFFFFF' if self.hovered else '#888888') + expand_icon = qta.icon('fa5s.expand-arrows-alt', color=get_surface_color("text_bright") if self.hovered else get_surface_color("chrome_inactive")) expand_icon.paint(painter, QRect(int(self.width - 30), 10, 20, 20)) else: if self.hovered: @@ -784,7 +785,7 @@ def paint(self, painter, option, widget=None): painter.setPen(QColor(255, 255, 255, 150)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(6,6,-6,-6), 4, 4) - icon_pen = QPen(QColor("#FFFFFF"), 2) + icon_pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(icon_pen) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) diff --git a/graphlink_app/graphlink_scene.py b/graphlink_app/graphlink_scene.py index ec9d4ba..965c061 100644 --- a/graphlink_app/graphlink_scene.py +++ b/graphlink_app/graphlink_scene.py @@ -21,6 +21,8 @@ from graphlink_plugins.graphlink_plugin_artifact import ArtifactNode, ArtifactConnectionItem from graphlink_plugins.graphlink_plugin_gitlink import GitlinkNode, GitlinkConnectionItem from graphlink_memory import clone_history, resolve_branch_parent +from graphlink_config import get_surface_color +from graphlink_styles import FONT_FAMILY_NAME class ChatScene(QGraphicsScene): BRANCH_DIM_OPACITY = 0.18 @@ -85,7 +87,7 @@ def __init__(self, window): self.artifact_connections = [] self.gitlink_connections = [] - self.setBackgroundBrush(QColor("#252525")) + self.setBackgroundBrush(QColor(get_surface_color("node_body"))) # Parameters for the auto-layout algorithm. self.horizontal_spacing = 150 @@ -102,9 +104,9 @@ def __init__(self, window): self.is_rubber_band_dragging = False # Global font properties for nodes that support them. - self.font_family = "Segoe UI" + self.font_family = FONT_FAMILY_NAME self.font_size = 10 - self.font_color = QColor("#DDDDDD") + self.font_color = QColor(get_surface_color("text_primary")) def setFontFamily(self, family): """ diff --git a/graphlink_app/graphlink_session/deserializers.py b/graphlink_app/graphlink_session/deserializers.py index dc131db..ee0bc2e 100644 --- a/graphlink_app/graphlink_session/deserializers.py +++ b/graphlink_app/graphlink_session/deserializers.py @@ -3,6 +3,7 @@ from PySide6.QtCore import QPointF, QRectF from PySide6.QtGui import QTransform +from graphlink_canvas.graphlink_canvas_container import DEFAULT_CONTAINER_COLOR from graphlink_canvas_items import Container, Frame from graphlink_connections import ( ConnectionItem, @@ -466,7 +467,7 @@ def deserialize_container(self, data, scene, all_items_map): container.persistent_id = data["id"] container.setPos(data["position"]["x"], data["position"]["y"]) container.title = data.get("title", "Container") - container.color = data.get("color", "#3a3a3a") + container.color = data.get("color", DEFAULT_CONTAINER_COLOR) container.header_color = data.get("header_color") rect_data = data.get("expanded_rect") diff --git a/graphlink_app/graphlink_styles.py b/graphlink_app/graphlink_styles.py index 0c0e5de..258ca01 100644 --- a/graphlink_app/graphlink_styles.py +++ b/graphlink_app/graphlink_styles.py @@ -797,6 +797,44 @@ class StyleSheet: "badge_fill": "#484848", "panel_fill": "#202020", }, + # UI-refactor P0 (doc/UI_QA_AUDIT.md section 7): the clean neutral + # role set for native painting/stylesheet code. Values are the exact + # hexes that dominated the swept files (node cards, canvas items, + # widget chrome) so the migration is byte-neutral in this theme; + # mono/muted values are derived from each theme's existing qss + # neutrals so themed rendering stays coherent. + "surface": { + "window": "#1E1E1E", + "node_body": "#252525", + "inset": "#202020", + "inset_deep": "#121212", + "field": "#272727", + "border": "#3F3F3F", + "border_strong": "#505050", + "divider": "#424242", + "handle": "#555555", + "handle_hover": "#6A6A6A", + "chrome_inactive": "#888888", + "text_primary": "#E0E0E0", + "text_strong": "#F1F1F1", + "text_soft": "#CCCCCC", + "text_label": "#A4A4A4", + "text_secondary": "#BDBDBD", + "text_muted": "#767676", + "text_bright": "#FFFFFF", + }, + # Sweep-adjudicated (2026-07-22): PythonHighlighter's palette moved + # here from graphlink_pycoder.py so code-node syntax colors theme with + # everything else. Identical across themes today - a deliberate + # starting point, not a constraint. + "syntax": { + "keyword": "#909090", + "builtin": "#A2A2A2", + "number": "#A2A2A2", + "string": "#B5B5B5", + "comment": "#626262", + "function": "#A3A3A3", + }, "qss": { "qmainwindow_qwidget__background_color": "#1E1E1E", "qmainwindow_qwidget__color": "#DCDCDC", @@ -895,6 +933,34 @@ class StyleSheet: "badge_fill": "#484848", "panel_fill": "#202020", }, + "surface": { + "window": "#222222", + "node_body": "#292929", + "inset": "#202020", + "inset_deep": "#161616", + "field": "#2A2A2A", + "border": "#444444", + "border_strong": "#555555", + "divider": "#444444", + "handle": "#555555", + "handle_hover": "#6A6A6A", + "chrome_inactive": "#999999", + "text_primary": "#DDDDDD", + "text_strong": "#F1F1F1", + "text_soft": "#CFCFCF", + "text_label": "#ABABAB", + "text_secondary": "#D5D5D5", + "text_muted": "#8A8A8A", + "text_bright": "#FFFFFF", + }, + "syntax": { + "keyword": "#909090", + "builtin": "#A2A2A2", + "number": "#A2A2A2", + "string": "#B5B5B5", + "comment": "#626262", + "function": "#A3A3A3", + }, "qss": { "qmainwindow_qwidget__background_color": "#222222", "qmainwindow_qwidget__color": "#DDDDDD", @@ -985,6 +1051,34 @@ class StyleSheet: "badge_fill": "#4a4a4a", "panel_fill": "#1c1c1c", }, + "surface": { + "window": "#1A1A1A", + "node_body": "#222222", + "inset": "#1C1C1C", + "inset_deep": "#101010", + "field": "#232323", + "border": "#383838", + "border_strong": "#494949", + "divider": "#383838", + "handle": "#494949", + "handle_hover": "#5E5E5E", + "chrome_inactive": "#7E7E7E", + "text_primary": "#D1D1D1", + "text_strong": "#E8E8E8", + "text_soft": "#C4C4C4", + "text_label": "#9C9C9C", + "text_secondary": "#BABABA", + "text_muted": "#6E6E6E", + "text_bright": "#F5F5F5", + }, + "syntax": { + "keyword": "#909090", + "builtin": "#A2A2A2", + "number": "#A2A2A2", + "string": "#B5B5B5", + "comment": "#626262", + "function": "#A3A3A3", + }, "qss": { "qmainwindow_qwidget__background_color": "#1A1A1A", "qmainwindow_qwidget__color": "#D1D1D1", @@ -1096,6 +1190,87 @@ def _generate_qss(theme_name: str) -> str: # so this is "the app's one explicit font choice," not "every font in use." FONT_FAMILY = "'Segoe UI', sans-serif" +# Bare family name for QFont construction (QFont wants "Segoe UI", not the +# CSS-quoted stack above). UI-refactor P0: every widget/painting file that +# used to string-paste "Segoe UI" imports this instead - the acceptance gate +# (tests/test_ui_token_acceptance.py) enforces that this module is the only +# place the literal appears. +FONT_FAMILY_NAME = "Segoe UI" + +# --------------------------------------------------------------------------- +# UI-refactor P0: structure tokens (doc/UI_QA_AUDIT.md section 7, P0). +# +# Theme-INDEPENDENT scales - spacing on a 4px grid, three radii, a type ramp +# with nothing under 12px (audit finding D2: 9-10px microtext), three +# elevation levels (audit finding B9: no elevation model), and motion +# durations/easing (audit finding F4: zero transitions). One source, two +# consumers: css_custom_properties() flattens these into --gl-* custom +# properties for every island (and tailwind_theme_css() registers them under +# Tailwind's proper non-color namespaces), while the *_PX/ELEVATION_PARAMS +# accessors below give Qt-side code integer values for the same scales so +# native painting/QSS derives from the identical source of truth. +# +# RECORDED DECISION (2026-07-22): the audit's P0 text sketched "one token +# source (JSON)". This codebase already HAS the single token source - this +# module: THEME_TOKENS feeds runtime island injection (css_root_block -> +# _inline_bundle) and the generated Tailwind theme. A parallel JSON file +# would fork truth into two places, so P0 extends THIS module instead; the +# audit doc's P0 entry is updated to match. "Token modules" for the +# acceptance grep = this file alone. +STRUCTURE_TOKENS = { + "space": { + "1": "4px", + "2": "8px", + "3": "12px", + "4": "16px", + "5": "20px", + "6": "24px", + "8": "32px", + }, + "radius": { + "sm": "4px", + "md": "8px", + "lg": "12px", + }, + "text": { + "xs": "12px", + "sm": "13px", + "base": "14px", + "lg": "16px", + "xl": "20px", + }, + "weight": { + "regular": "400", + "semibold": "600", + }, + "shadow": { + "1": "0 1px 3px rgba(0, 0, 0, 0.40)", + "2": "0 4px 12px rgba(0, 0, 0, 0.45)", + "3": "0 8px 28px rgba(0, 0, 0, 0.55)", + }, + "motion": { + "fast": "150ms", + "base": "200ms", + "ease": "cubic-bezier(0.2, 0, 0, 1)", + }, +} + +# Qt-side integer views of the same scales (QFont/QMargins/QRect math wants +# ints, not "13px" strings). Derived, not duplicated: parsed from +# STRUCTURE_TOKENS so the two representations cannot drift. +SPACE_PX = {int(key): int(value[:-2]) for key, value in STRUCTURE_TOKENS["space"].items()} +RADIUS_PX = {key: int(value[:-2]) for key, value in STRUCTURE_TOKENS["radius"].items()} +TEXT_PX = {key: int(value[:-2]) for key, value in STRUCTURE_TOKENS["text"].items()} + +# QGraphicsDropShadowEffect parameters (blur_radius, y_offset, alpha) matching +# the three CSS shadow levels closely enough that a native popover and a web +# popover read as the same elevation tier on screen. +ELEVATION_PARAMS = { + 1: (12, 2, 110), + 2: (24, 4, 120), + 3: (40, 8, 140), +} + # THEME_TOKENS groups that belong to ONE island's own chrome rather than to the # app-wide color vocabulary, mapped to the CSS custom-property prefix they # flatten under. These are exported by css_custom_properties() (island CSS has @@ -1196,7 +1371,7 @@ def css_custom_properties(theme_name: str) -> dict[str, str]: function already exports. """ tokens = THEME_TOKENS[theme_name] - included_groups = ("palette", "semantic", "neutral_button", "graph_node") + included_groups = ("palette", "semantic", "neutral_button", "graph_node", "surface", "syntax") excluded_groups = ("qss", "qss_alpha") # Self-verifying rather than relying solely on a separate test file to # catch drift: if a future edit adds a new top-level THEME_TOKENS group @@ -1260,6 +1435,14 @@ def css_custom_properties(theme_name: str) -> dict[str, str]: properties["--gl-font-family"] = FONT_FAMILY + # UI-refactor P0: theme-independent structure scales, emitted identically + # for every theme (so the cross-theme key-set guard holds by + # construction). Names: --gl-space-N, --gl-radius-K, --gl-text-K, + # --gl-weight-K, --gl-shadow-N, --gl-motion-K. + for group, entries in STRUCTURE_TOKENS.items(): + for key, value in entries.items(): + properties[f"--gl-{group}-{key}"] = value + for name, value in properties.items(): _assert_safe_css_declaration_value(name, value) return properties @@ -1332,12 +1515,43 @@ def tailwind_theme_css() -> str: """ excluded = island_property_names("dark") names = [name for name in sorted(css_custom_properties("dark")) if name not in excluded] + # UI-refactor P0: structure tokens register under Tailwind v4's proper + # non-color namespaces so the compiler mints the RIGHT kind of utility + # (p-gl-2 / rounded-gl-md / text-gl-sm / shadow-gl-1 / duration-gl-fast), + # not a bogus bg-gl-space-2 color utility. Routed by emitted-name prefix; + # prefixes are derived from STRUCTURE_TOKENS's real group names so a new + # group cannot silently fall through to the color namespace. + structure_namespace = { + "space": "--spacing-gl-", + "radius": "--radius-gl-", + "text": "--text-gl-", + "weight": "--font-weight-gl-", + "shadow": "--shadow-gl-", + } + assert set(structure_namespace) | {"motion"} == set(STRUCTURE_TOKENS), ( + "STRUCTURE_TOKENS gained a group tailwind_theme_css() doesn't route - " + "add it to structure_namespace (or the motion special-case) before " + "extending the scales." + ) lines = [] for name in names: if name == "--gl-font-family": lines.append(f" --font-gl: var({name});") + continue + for group, tw_prefix in structure_namespace.items(): + gl_prefix = f"--gl-{group}-" + if name.startswith(gl_prefix): + lines.append(f" {tw_prefix}{name[len(gl_prefix):]}: var({name});") + break else: - lines.append(f" --color-{name[len('--'):]}: var({name});") + if name.startswith("--gl-motion-"): + key = name[len("--gl-motion-"):] + if key == "ease": + lines.append(f" --ease-gl: var({name});") + else: + lines.append(f" --duration-gl-{key}: var({name});") + else: + lines.append(f" --color-{name[len('--'):]}: var({name});") return "@theme {\n" + "\n".join(lines) + "\n}\n" diff --git a/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py b/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py index 6e29297..f4cbd3a 100644 --- a/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py +++ b/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py @@ -12,7 +12,7 @@ ) from graphlink_chat_library_web import ChatLibraryWebHost -from graphlink_config import get_current_palette +from graphlink_config import get_current_palette, get_surface_color class ChatLibraryDialog(QDialog): @@ -149,7 +149,7 @@ def on_theme_changed(self): accent = palette.SELECTION.name() panel_gray = "rgba(42, 42, 42, 248)" line_gray = "rgba(255, 255, 255, 0.08)" - muted_text = "#8D8D8D" + muted_text = get_surface_color("chrome_inactive") badge_gray = "rgba(255, 255, 255, 0.025)" self.icon_badge.setPixmap(qta.icon("fa5s.book", color=accent).pixmap(16, 16)) @@ -181,7 +181,7 @@ def on_theme_changed(self): letter-spacing: 0.14em; }} QLabel#libraryWindowTitle {{ - color: #F5F5F5; + color: {get_surface_color("text_strong")}; font-size: 16px; font-weight: 700; }} @@ -191,7 +191,7 @@ def on_theme_changed(self): }} QPushButton#libraryCloseButton {{ background-color: rgba(255, 255, 255, 0.04); - color: #F5F5F5; + color: {get_surface_color("text_strong")}; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; padding: 8px 14px; diff --git a/graphlink_app/graphlink_view.py b/graphlink_app/graphlink_view.py index 3c80b03..5e8007d 100644 --- a/graphlink_app/graphlink_view.py +++ b/graphlink_app/graphlink_view.py @@ -15,7 +15,7 @@ from graphlink_font_control_web import FontControlHost from graphlink_drag_speed_web import DragSpeedHost from graphlink_minimap_web import MinimapHost -from graphlink_config import get_semantic_color +from graphlink_config import get_semantic_color, get_surface_color from graphlink_context_menu import create_context_menu from graphlink_web_island_host import any_host_has_text_focus @@ -677,7 +677,7 @@ def drawBackground(self, painter, rect): """ super().drawBackground(painter, rect) - painter.fillRect(rect, QColor("#252525")) + painter.fillRect(rect, QColor(get_surface_color("node_body"))) grid_size = self.grid_settings.grid_size opacity = self.grid_settings.grid_opacity diff --git a/graphlink_app/graphlink_web.py b/graphlink_app/graphlink_web.py index a578738..4ebabc2 100644 --- a/graphlink_app/graphlink_web.py +++ b/graphlink_app/graphlink_web.py @@ -5,9 +5,10 @@ from PySide6.QtCore import QRectF, Qt, QPointF, Signal, QTimer, QRect, QUrl from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QPainterPath, QFont, QDesktopServices import qtawesome as qta -from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color +from graphlink_config import canvas_font, get_current_palette, get_graph_node_colors, get_neutral_button_colors, get_semantic_color, get_surface_color from graphlink_connections import ConnectionItem from graphlink_canvas_items import HoverAnimationMixin +from graphlink_styles import FONT_FAMILY from graphlink_lod import draw_lod_card, preview_text, sync_proxy_render_state from graphlink_memory import append_history, get_node_history from graphlink_plugins.graphlink_plugin_context_menu import PluginNodeContextMenu @@ -141,14 +142,14 @@ def __init__(self, parent_node, parent=None): self.widget = QWidget() self.widget.setObjectName("webNodeMainWidget") self.widget.setFixedSize(self.NODE_WIDTH, self.NODE_HEIGHT) - self.widget.setStyleSheet(""" - QWidget#webNodeMainWidget { + self.widget.setStyleSheet(f""" + QWidget#webNodeMainWidget {{ background-color: transparent; - color: #E0E0E0; - } - QWidget#webNodeMainWidget QLabel { + color: {get_surface_color("text_primary")}; + }} + QWidget#webNodeMainWidget QLabel {{ background-color: transparent; - } + }} """) self._setup_ui() @@ -222,14 +223,14 @@ def _setup_ui(self): # --- Status Label --- self.status_label = QLabel("Status: Idle") - self.status_label.setStyleSheet("color: #888; font-style: italic; background: transparent;") + self.status_label.setStyleSheet(f"color: {get_surface_color('chrome_inactive')}; font-style: italic; background: transparent;") main_layout.addWidget(self.status_label) # --- Result Display Section --- result_header = QHBoxLayout() result_header.addWidget(QLabel("Answer:")) self.source_count_label = QLabel("No sources") - self.source_count_label.setStyleSheet("color: #A4A4A4; background: transparent;") + self.source_count_label.setStyleSheet(f"color: {get_surface_color('text_label')}; background: transparent;") result_header.addWidget(self.source_count_label) result_header.addStretch() main_layout.addLayout(result_header) @@ -244,18 +245,18 @@ def _setup_ui(self): self.warning_label = QLabel("") self.warning_label.setWordWrap(True) - self.warning_label.setStyleSheet("color: #AAAAAA; background: transparent;") + self.warning_label.setStyleSheet(f"color: {get_surface_color('text_label')}; background: transparent;") main_layout.addWidget(self.warning_label) # Apply common styles to text edit widgets. for widget in [self.query_input, self.summary_display]: - widget.setStyleSheet(""" - QTextEdit, QTextBrowser { - background-color: #252525; border: 1px solid #3F3F3F; - color: #CCCCCC; border-radius: 4px; padding: 5px; - font-family: 'Segoe UI', sans-serif; - } - QTextBrowser a { color: #B3B3B3; text-decoration: none; } + widget.setStyleSheet(f""" + QTextEdit, QTextBrowser {{ + background-color: {get_surface_color("node_body")}; border: 1px solid {get_surface_color("border")}; + color: {get_surface_color("text_soft")}; border-radius: 4px; padding: 5px; + font-family: {FONT_FAMILY}; + }} + QTextBrowser a {{ color: {get_surface_color("text_secondary")}; text-decoration: none; }} """) # Style the run button with a contrasting text color based on background brightness. @@ -280,9 +281,9 @@ def _setup_ui(self): border-color: {button_colors["border"].darker(105).name()}; }} QPushButton:disabled {{ - background-color: #2B2B2B; - border-color: #353535; - color: #7B7B7B; + background-color: {get_surface_color("field")}; + border-color: {get_surface_color("border")}; + color: {get_surface_color("text_muted")}; }} """) @@ -480,16 +481,16 @@ def paint(self, painter, option, widget=None): path = QPainterPath() path.addRoundedRect(0, 0, self.width, self.height, 10, 10) - painter.setBrush(QColor("#2D2D2D")) - + painter.setBrush(QColor(get_surface_color("field"))) + web_color = node_colors["border"] pen = QPen(web_color, 1.5) if self.isSelected(): pen = QPen(palette.SELECTION, 2) elif self.hovered: - pen = QPen(QColor("#FFFFFF"), 2) - + pen = QPen(QColor(get_surface_color("text_bright")), 2) + painter.setPen(pen) painter.drawPath(path) @@ -526,7 +527,7 @@ def paint(self, painter, option, widget=None): return if self.is_collapsed: - painter.setPen(QColor("#FFFFFF")) + painter.setPen(QColor(get_surface_color("text_bright"))) font = canvas_font(self.scene(), weight=QFont.Weight.Bold) painter.setFont(font) painter.drawText(QRectF(40, 0, self.width - 80, self.height), Qt.AlignmentFlag.AlignVCenter, "Web Search") @@ -535,7 +536,7 @@ def paint(self, painter, option, widget=None): icon.paint(painter, QRect(10, 10, 20, 20)) self.collapse_button_rect = QRectF(self.width - 35, 5, 30, 30) - expand_icon = qta.icon('fa5s.expand-arrows-alt', color='#FFFFFF' if self.hovered else '#888888') + expand_icon = qta.icon('fa5s.expand-arrows-alt', color=get_surface_color("text_bright") if self.hovered else get_surface_color("chrome_inactive")) expand_icon.paint(painter, QRect(int(self.width - 30), 10, 20, 20)) else: if self.hovered: @@ -544,7 +545,7 @@ def paint(self, painter, option, widget=None): painter.setPen(QColor(255, 255, 255, 150)) painter.drawRoundedRect(self.collapse_button_rect.adjusted(6,6,-6,-6), 4, 4) - icon_pen = QPen(QColor("#FFFFFF"), 2) + icon_pen = QPen(QColor(get_surface_color("text_bright")), 2) painter.setPen(icon_pen) center = self.collapse_button_rect.center() painter.drawLine(int(center.x() - 4), int(center.y()), int(center.x() + 4), int(center.y())) diff --git a/graphlink_app/graphlink_widgets/overlays.py b/graphlink_app/graphlink_widgets/overlays.py index 6dda58e..4d38d56 100644 --- a/graphlink_app/graphlink_widgets/overlays.py +++ b/graphlink_app/graphlink_widgets/overlays.py @@ -4,7 +4,8 @@ from PySide6.QtCore import QPointF, Property, QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, QRectF, Qt, Signal from PySide6.QtGui import QColor, QBrush, QFont, QFontMetrics, QKeySequence, QLinearGradient, QPainter, QPainterPath, QPen, QShortcut from PySide6.QtWidgets import QGraphicsObject, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget -from graphlink_config import get_current_palette, get_semantic_color, is_monochrome_theme +from graphlink_config import get_current_palette, get_graph_node_colors, get_semantic_color, get_surface_color, is_monochrome_theme +from graphlink_styles import FONT_FAMILY_NAME from .loading_visuals import paint_orbital_loading_spinner class LoadingAnimation(QGraphicsObject): @@ -136,19 +137,19 @@ def _surface_colors(self): accent = QColor(palette.AI_NODE) monochrome = is_monochrome_theme() - body_start = self._mix_color(QColor("#282828"), accent, 0.04 if not monochrome else 0.02) + body_start = self._mix_color(QColor(get_surface_color("field")), accent, 0.04 if not monochrome else 0.02) body_start.setAlpha(210) - body_end = self._mix_color(QColor("#1A1A1A"), accent, 0.02 if not monochrome else 0.01) + body_end = self._mix_color(QColor(get_surface_color("window")), accent, 0.02 if not monochrome else 0.01) body_end.setAlpha(192) - header_start = self._mix_color(QColor("#323232"), accent, 0.30 if not monochrome else 0.08) + header_start = self._mix_color(get_graph_node_colors()["header_end"], accent, 0.30 if not monochrome else 0.08) header_start.setAlpha(188) - header_end = self._mix_color(QColor("#1C1C1C"), accent, 0.18 if not monochrome else 0.04) + header_end = self._mix_color(QColor(get_surface_color("window")), accent, 0.18 if not monochrome else 0.04) header_end.setAlpha(170) - badge_fill = self._mix_color(QColor("#2B2B2B"), accent, 0.58 if not monochrome else 0.12) + badge_fill = self._mix_color(QColor(get_surface_color("field")), accent, 0.58 if not monochrome else 0.12) badge_fill.setAlpha(138) - descriptor_text = self._mix_color(QColor("#A5A5A5"), accent, 0.14 if not monochrome else 0.04) + descriptor_text = self._mix_color(QColor(get_surface_color("text_label")), accent, 0.14 if not monochrome else 0.04) descriptor_text.setAlpha(175) - content_panel_border = self._mix_color(QColor("#393939"), accent, 0.08 if not monochrome else 0.03) + content_panel_border = self._mix_color(QColor(get_surface_color("border")), accent, 0.08 if not monochrome else 0.03) content_panel_border.setAlpha(135) return { @@ -158,7 +159,7 @@ def _surface_colors(self): "header_start": header_start, "header_end": header_end, "badge_fill": badge_fill, - "badge_text": QColor("#F6F6F6"), + "badge_text": QColor(get_surface_color("text_bright")), "descriptor_text": descriptor_text, "content_panel_fill": QColor(17, 20, 23, 182), "content_panel_border": content_panel_border, @@ -237,7 +238,7 @@ def paint(self, painter, option, widget): QPointF(self.width - 10, self.HEADER_HEIGHT), ) - badge_font = QFont("Segoe UI", 8, QFont.Weight.DemiBold) + badge_font = QFont(FONT_FAMILY_NAME, 8, QFont.Weight.DemiBold) painter.setFont(badge_font) badge_text = "Assistant" badge_metrics = QFontMetrics(badge_font) @@ -254,7 +255,7 @@ def paint(self, painter, option, widget): painter.setPen(badge_text_color) painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_text) - descriptor_font = QFont("Segoe UI", 8) + descriptor_font = QFont(FONT_FAMILY_NAME, 8) painter.setFont(descriptor_font) painter.setPen(colors["descriptor_text"]) painter.drawText( @@ -273,9 +274,9 @@ def paint(self, painter, option, widget): text_left = content_rect.left() + 68 text_width = max(80.0, content_rect.width() - 84.0) - title_font = QFont("Segoe UI", 10, QFont.Weight.DemiBold) + title_font = QFont(FONT_FAMILY_NAME, 10, QFont.Weight.DemiBold) painter.setFont(title_font) - title_color = QColor("#F5F5F5") + title_color = QColor(get_surface_color("text_bright")) title_color.setAlpha(220) painter.setPen(title_color) painter.drawText( @@ -285,9 +286,9 @@ def paint(self, painter, option, widget): ) if self.subtitle: - subtitle_font = QFont("Segoe UI", 8) + subtitle_font = QFont(FONT_FAMILY_NAME, 8) painter.setFont(subtitle_font) - subtitle_color = QColor("#B6B6B6") + subtitle_color = QColor(get_surface_color("text_secondary")) subtitle_color.setAlpha(165) painter.setPen(subtitle_color) painter.drawText( diff --git a/graphlink_app/graphlink_widgets/scrolling.py b/graphlink_app/graphlink_widgets/scrolling.py index 3edfa36..70b0df6 100644 --- a/graphlink_app/graphlink_widgets/scrolling.py +++ b/graphlink_app/graphlink_widgets/scrolling.py @@ -3,6 +3,7 @@ from PySide6.QtCore import QRectF, Qt, Signal from PySide6.QtGui import QBrush, QColor, QPainter from PySide6.QtWidgets import QGraphicsObject, QWidget +from graphlink_config import get_surface_color class CustomScrollBar(QWidget): valueChanged = Signal(float) @@ -43,7 +44,7 @@ def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - track_color = QColor("#2A2A2A") + track_color = QColor(get_surface_color("field")) track_color.setAlpha(100) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(track_color) @@ -76,7 +77,7 @@ def paintEvent(self, event): else: handle_position = 0 - handle_color = QColor("#6a6a6a") if self.hover else QColor("#555555") + handle_color = QColor(get_surface_color("handle_hover")) if self.hover else QColor(get_surface_color("handle")) painter.setBrush(handle_color) if self.orientation == Qt.Orientation.Vertical: @@ -137,7 +138,7 @@ def boundingRect(self): def paint(self, painter, option, widget=None): painter.setRenderHint(QPainter.RenderHint.Antialiasing) - color = QColor("#6a6a6a") if self.hover else QColor("#555555") + color = QColor(get_surface_color("handle_hover")) if self.hover else QColor(get_surface_color("handle")) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(color)) @@ -171,7 +172,7 @@ def boundingRect(self): def paint(self, painter, option, widget=None): painter.setRenderHint(QPainter.RenderHint.Antialiasing) - track_color = QColor("#2A2A2A") + track_color = QColor(get_surface_color("field")) track_color.setAlpha(100) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(track_color)) diff --git a/graphlink_app/graphlink_widgets/splash.py b/graphlink_app/graphlink_widgets/splash.py index 2fce05d..952ba92 100644 --- a/graphlink_app/graphlink_widgets/splash.py +++ b/graphlink_app/graphlink_widgets/splash.py @@ -3,7 +3,8 @@ from PySide6.QtCore import QPointF, Property, QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, QTimer from PySide6.QtGui import QColor, QFont, QFontMetrics, QGuiApplication, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QGraphicsDropShadowEffect, QLabel, QVBoxLayout, QWidget -from graphlink_config import get_current_palette +from graphlink_config import get_current_palette, get_surface_color +from graphlink_styles import FONT_FAMILY_NAME from graphlink_version import APP_VERSION from .loading_visuals import paint_orbital_loading_spinner @@ -99,7 +100,7 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) - font = QFont("Segoe UI", 34, QFont.Weight.Bold) + font = QFont(FONT_FAMILY_NAME, 34, QFont.Weight.Bold) font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 2) painter.setFont(font) metrics = QFontMetrics(font) @@ -131,8 +132,8 @@ def paintEvent(self, event): y_offset = (1.0 - ease_p) * 25.0 - if i < 5: - color = QColor("#FFFFFF") + if i < 5: + color = QColor(get_surface_color("text_bright")) else: color = QColor(palette.SELECTION) @@ -177,14 +178,14 @@ def __init__(self, main_window): self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) content_layout.addWidget(self.status_label) - self.setStyleSheet(""" - QWidget#splashContainer { - background-color: #1E1E1E; + self.setStyleSheet(f""" + QWidget#splashContainer {{ + background-color: {get_surface_color("window")}; border-radius: 8px; - } - QLabel { - color: #747474; - } + }} + QLabel {{ + color: {get_surface_color("text_muted")}; + }} """) screen = QGuiApplication.primaryScreen().geometry() diff --git a/graphlink_app/tests/test_css_custom_properties.py b/graphlink_app/tests/test_css_custom_properties.py index a57296c..c63724f 100644 --- a/graphlink_app/tests/test_css_custom_properties.py +++ b/graphlink_app/tests/test_css_custom_properties.py @@ -53,9 +53,20 @@ def test_every_value_is_a_flat_hex_color_except_font_family_and_alpha_groups( f"--gl-composer-{key.replace('_', '-')}" for key in gs.THEME_TOKENS[theme_name]["composer_alpha"] } + # UI-refactor P0: structure tokens are px/ms/shadow/easing values, + # not colors - carved out by their real group names, mirroring the + # alpha carve-out's enumerate-don't-pattern-match approach. + structure_keys = { + f"--gl-{group}-{key}" + for group, entries in gs.STRUCTURE_TOKENS.items() + for key in entries + } for key, value in props.items(): if key == "--gl-font-family": continue + if key in structure_keys: + assert value, f"{key} is empty" + continue if key in alpha_keys: assert RGBA_RE.match(value), f"{key} = {value!r} is not an rgba() literal" continue @@ -114,11 +125,17 @@ def test_font_family_matches_the_shared_constant(self): @pytest.mark.parametrize("theme_name", THEMES) def test_total_property_count_has_no_missing_or_extra_keys(self, theme_name): tokens = gs.THEME_TOKENS[theme_name] - expected_group_count = sum(len(tokens[g]) for g in ("palette", "semantic", "neutral_button", "graph_node")) + # "surface"/"syntax" joined the included groups in UI-refactor P0. + expected_group_count = sum( + len(tokens[g]) + for g in ("palette", "semantic", "neutral_button", "graph_node", "surface", "syntax") + ) expected_island_count = sum(len(tokens[g]) for g in gs._ISLAND_GROUPS) expected_frame_count = len({name.removesuffix(" Header") for name in gs._FRAME_COLORS_BY_THEME[theme_name]}) + expected_structure_count = sum(len(entries) for entries in gs.STRUCTURE_TOKENS.values()) expected_total = ( - expected_group_count + expected_island_count + expected_frame_count + 1 + expected_group_count + expected_island_count + expected_frame_count + + expected_structure_count + 1 ) # +1 for --gl-font-family assert len(gs.css_custom_properties(theme_name)) == expected_total diff --git a/graphlink_app/tests/test_tailwind_theme_css.py b/graphlink_app/tests/test_tailwind_theme_css.py index 36e5188..e75e90c 100644 --- a/graphlink_app/tests/test_tailwind_theme_css.py +++ b/graphlink_app/tests/test_tailwind_theme_css.py @@ -50,14 +50,36 @@ def test_declares_exactly_one_theme_block(self): def test_every_declaration_references_the_matching_gl_custom_property(self): content = _read_generated_file() - declarations = re.findall(r"--([a-z-]+):\s*var\((--gl-[a-z-]+)\);", content) + declarations = re.findall(r"--([a-z0-9-]+):\s*var\((--gl-[a-z0-9-]+)\);", content) assert len(declarations) > 0 + # UI-refactor P0: structure tokens route to Tailwind v4's proper + # non-color namespaces (spacing/radius/text/font-weight/shadow/ + # duration/ease); everything else stays "color-gl-..." except the + # original "font-gl" special case. Mirrors tailwind_theme_css()'s + # own routing table. + structure_routes = { + "--gl-space-": "spacing-gl-", + "--gl-radius-": "radius-gl-", + "--gl-text-": "text-gl-", + "--gl-weight-": "font-weight-gl-", + "--gl-shadow-": "shadow-gl-", + } for tailwind_name, gl_var in declarations: - # Every Tailwind theme key is either "color-gl-..." (mirroring - # the referenced --gl-... name exactly) or the one special-cased - # "font-gl" for --gl-font-family. if tailwind_name == "font-gl": assert gl_var == "--gl-font-family" + continue + if gl_var == "--gl-motion-ease": + assert tailwind_name == "ease-gl" + continue + if gl_var.startswith("--gl-motion-"): + assert tailwind_name == f"duration-gl-{gl_var[len('--gl-motion-'):]}" + continue + for gl_prefix, tw_prefix in structure_routes.items(): + if gl_var.startswith(gl_prefix): + assert tailwind_name == f"{tw_prefix}{gl_var[len(gl_prefix):]}", ( + f"--{tailwind_name} does not mirror {gl_var} as expected" + ) + break else: assert tailwind_name == f"color-{gl_var[len('--'):]}", ( f"--{tailwind_name} does not mirror {gl_var} as expected" diff --git a/graphlink_app/tests/test_theme_tokens.py b/graphlink_app/tests/test_theme_tokens.py index d9287fe..f649f72 100644 --- a/graphlink_app/tests/test_theme_tokens.py +++ b/graphlink_app/tests/test_theme_tokens.py @@ -86,9 +86,15 @@ def test_every_theme_has_all_expected_token_groups(self): # "all four token groups" before that change landed. # "composer"/"composer_alpha" were added by the composer token # retrofit (see tests/test_composer_token_retrofit.py). + # "surface" was added by UI-refactor P0 (doc/UI_QA_AUDIT.md section + # 7): the clean neutral role set the hex-literal sweep migrated + # native painting/stylesheet code onto, exported to islands like + # palette/semantic (see test_ui_token_acceptance.py for the gate). + # "syntax" (same increment, sweep adjudication): PythonHighlighter's + # palette relocated from graphlink_pycoder.py. expected = { - "palette", "semantic", "neutral_button", "graph_node", "qss", "qss_alpha", - "composer", "composer_alpha", + "palette", "semantic", "neutral_button", "graph_node", "surface", + "syntax", "qss", "qss_alpha", "composer", "composer_alpha", } for name in ("dark", "mono", "muted"): tokens = gs.THEME_TOKENS[name] diff --git a/graphlink_app/tests/test_ui_token_acceptance.py b/graphlink_app/tests/test_ui_token_acceptance.py new file mode 100644 index 0000000..eb30bd1 --- /dev/null +++ b/graphlink_app/tests/test_ui_token_acceptance.py @@ -0,0 +1,184 @@ +"""UI-refactor P0 acceptance gate (doc/UI_QA_AUDIT.md section 7, P0). + +The audit's P0 acceptance criterion, codified so it can never silently +regress: outside the token module (graphlink_styles.py), no UI source file +may contain a hardcoded 6-digit hex color literal or a string-pasted +"Segoe UI". Colors come from get_surface_color()/get_semantic_color()/ +get_current_palette()/THEME_TOKENS lookups; the font family comes from +FONT_FAMILY (QSS stacks) / FONT_FAMILY_NAME (QFont). + +ALLOWLIST POLICY: entries in _HEX_ALLOWLIST are (relative path, reason) +pairs for files whose remaining literals were adjudicated as domain DATA +rather than UI chrome (e.g. syntax-highlight palettes pending their own +token group). Every entry must carry a reason; an empty allowlist is the +goal state. Adding an entry to dodge the gate defeats P0 - route new +colors through THEME_TOKENS instead. + +Structure-token coverage lives here too: the P0 scales exist, are complete, +and respect their own rules (4px spacing grid, no type size under 12px, +three elevation levels, 150/200ms motion). +""" + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import graphlink_styles as gs + +APP_DIR = Path(__file__).resolve().parents[1] + +# The one module allowed to define color literals and the font family. +_TOKEN_MODULES = {"graphlink_styles.py"} + +# Adjudicated exceptions: (path relative to graphlink_app/, reason). Each +# entry was individually adjudicated during the P0 sweep (2026-07-22) as +# domain DATA rather than UI chrome. Shrink this list, never grow it +# casually - route new colors through THEME_TOKENS instead. +_HEX_ALLOWLIST: dict[str, str] = { + "graphlink_context_menu.py": ( + "per-theme QMenu state table carrying literals for NON-current themes " + "(get_surface_color resolves only the current theme); the menu system " + "is rebuilt wholesale in refactor phase P8, which retires this file's " + "styling entirely" + ), + "graphlink_exporter.py": ( + "light-styled CSS baked into exported standalone HTML/PDF documents - " + "deliberately NOT themed to the running app; theming exports to the " + "app's dark theme would corrupt the user's output artifacts" + ), + "graphlink_font_control_bridge.py": ( + "FONT_COLOR_PRESETS: user-pickable canvas font color swatches - " + "stable data choices offered to the user, not app chrome" + ), + "graphlink_grid_control_bridge.py": ( + "grid-color preset swatches - user-pickable data choices, stable " + "across themes by design" + ), + "graphlink_grid_view_settings.py": ( + "DEFAULT_GRID_COLOR in the deliberately Qt-free plain-data settings " + "model - a persisted user-preference default, not chrome" + ), + "graphlink_plugins/common/combo.py": ( + "single #656565 default-parameter accent in a reusable combo widget's " + "public API signature - callers pass real accents; changing the " + "default's identity is an API change deferred past P0" + ), + "graphlink_canvas/graphlink_canvas_chart_item.py": ( + "single #868686 'slate' entry in the chart data-series color cycle - " + "series colors are data, not chrome (rule 2 of the sweep)" + ), + "graphlink_canvas/graphlink_canvas_container.py": ( + "DEFAULT_CONTAINER_COLOR: the container's persisted user-facing body " + "color default (saved into scene JSON) - scene data must keep its " + "color across theme switches, like DEFAULT_GRID_COLOR" + ), +} + +# Files allowed to carry the literal string "Segoe UI" outside the token +# module, same adjudication bar. +_FONT_ALLOWLIST: dict[str, str] = { + "graphlink_font_control_bridge.py": ( + "FONT_FAMILIES: the user-pickable font list offers 'Segoe UI' and " + "'Segoe UI Variable' as CHOICES - data presented to the user, not a " + "hardcoded app default (defaults go through FONT_FAMILY_NAME)" + ), +} + +_HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b") + + +def _ui_source_files(): + for path in sorted(APP_DIR.rglob("*.py")): + rel = path.relative_to(APP_DIR).as_posix() + if rel.startswith("tests/"): + continue + if path.name in _TOKEN_MODULES: + continue + yield rel, path + + +class TestNoHardcodedHexOutsideTokenModules: + def test_no_ui_file_contains_a_hex_literal(self): + offenders = {} + for rel, path in _ui_source_files(): + if rel in _HEX_ALLOWLIST: + continue + text = path.read_text(encoding="utf-8") + hits = _HEX_RE.findall(text) + if hits: + offenders[rel] = sorted(set(hits)) + assert not offenders, ( + "Hardcoded hex color literal(s) outside the token module - route " + f"them through THEME_TOKENS/get_surface_color instead:\n{offenders}" + ) + + def test_allowlist_entries_all_carry_reasons_and_still_exist(self): + for rel, reason in _HEX_ALLOWLIST.items(): + assert reason.strip(), f"allowlist entry {rel} has no reason" + assert (APP_DIR / rel).is_file(), f"allowlist entry {rel} no longer exists - prune it" + + +class TestNoHardcodedFontFamilyOutsideTokenModules: + def test_no_ui_file_string_pastes_segoe_ui(self): + offenders = [] + for rel, path in _ui_source_files(): + if rel in _FONT_ALLOWLIST: + continue + text = path.read_text(encoding="utf-8") + if "Segoe UI" in text: + offenders.append(rel) + assert not offenders, ( + "String-pasted 'Segoe UI' outside the token module - use " + f"FONT_FAMILY / FONT_FAMILY_NAME from graphlink_styles:\n{offenders}" + ) + + def test_font_allowlist_entries_all_carry_reasons_and_still_exist(self): + for rel, reason in _FONT_ALLOWLIST.items(): + assert reason.strip(), f"font allowlist entry {rel} has no reason" + assert (APP_DIR / rel).is_file(), f"font allowlist entry {rel} no longer exists - prune it" + + +class TestStructureTokenScales: + def test_all_expected_groups_exist(self): + assert set(gs.STRUCTURE_TOKENS) == { + "space", "radius", "text", "weight", "shadow", "motion", + } + + def test_spacing_is_a_4px_grid(self): + for key, px in gs.SPACE_PX.items(): + assert px % 4 == 0, f"space-{key}={px}px breaks the 4px grid" + assert gs.SPACE_PX[1] == 4 + + def test_no_type_size_under_12px(self): + # Audit finding D2: 9-10px microtext. The ramp's floor is 12px. + for key, px in gs.TEXT_PX.items(): + assert px >= 12, f"text-{key}={px}px is below the 12px floor" + + def test_three_radius_steps(self): + assert set(gs.RADIUS_PX) == {"sm", "md", "lg"} + assert gs.RADIUS_PX["sm"] < gs.RADIUS_PX["md"] < gs.RADIUS_PX["lg"] + + def test_three_elevation_levels_in_both_representations(self): + assert set(gs.STRUCTURE_TOKENS["shadow"]) == {"1", "2", "3"} + assert set(gs.ELEVATION_PARAMS) == {1, 2, 3} + + def test_motion_scale(self): + motion = gs.STRUCTURE_TOKENS["motion"] + assert motion["fast"] == "150ms" + assert motion["base"] == "200ms" + assert motion["ease"].startswith("cubic-bezier(") + + def test_structure_tokens_are_exported_to_every_theme_identically(self): + for theme in ("dark", "mono", "muted"): + props = gs.css_custom_properties(theme) + for group, entries in gs.STRUCTURE_TOKENS.items(): + for key, value in entries.items(): + assert props[f"--gl-{group}-{key}"] == value + + def test_surface_group_exports_for_every_theme(self): + for theme in ("dark", "mono", "muted"): + props = gs.css_custom_properties(theme) + for key in gs.THEME_TOKENS[theme]["surface"]: + assert f"--gl-surface-{key.replace('_', '-')}" in props diff --git a/web_ui/src/lib/tokens/gl-theme.css b/web_ui/src/lib/tokens/gl-theme.css index ef5640c..1552601 100644 --- a/web_ui/src/lib/tokens/gl-theme.css +++ b/web_ui/src/lib/tokens/gl-theme.css @@ -19,6 +19,9 @@ --color-gl-graph-node-header-end: var(--gl-graph-node-header-end); --color-gl-graph-node-header-start: var(--gl-graph-node-header-start); --color-gl-graph-node-panel-fill: var(--gl-graph-node-panel-fill); + --duration-gl-base: var(--gl-motion-base); + --ease-gl: var(--gl-motion-ease); + --duration-gl-fast: var(--gl-motion-fast); --color-gl-neutral-button-background: var(--gl-neutral-button-background); --color-gl-neutral-button-border: var(--gl-neutral-button-border); --color-gl-neutral-button-hover: var(--gl-neutral-button-hover); @@ -29,6 +32,9 @@ --color-gl-palette-nav-highlight: var(--gl-palette-nav-highlight); --color-gl-palette-selection: var(--gl-palette-selection); --color-gl-palette-user-node: var(--gl-palette-user-node); + --radius-gl-lg: var(--gl-radius-lg); + --radius-gl-md: var(--gl-radius-md); + --radius-gl-sm: var(--gl-radius-sm); --color-gl-semantic-artifact: var(--gl-semantic-artifact); --color-gl-semantic-conversation-ai-bubble: var(--gl-semantic-conversation-ai-bubble); --color-gl-semantic-conversation-user-bubble: var(--gl-semantic-conversation-user-bubble); @@ -38,4 +44,45 @@ --color-gl-semantic-status-info: var(--gl-semantic-status-info); --color-gl-semantic-status-success: var(--gl-semantic-status-success); --color-gl-semantic-status-warning: var(--gl-semantic-status-warning); + --shadow-gl-1: var(--gl-shadow-1); + --shadow-gl-2: var(--gl-shadow-2); + --shadow-gl-3: var(--gl-shadow-3); + --spacing-gl-1: var(--gl-space-1); + --spacing-gl-2: var(--gl-space-2); + --spacing-gl-3: var(--gl-space-3); + --spacing-gl-4: var(--gl-space-4); + --spacing-gl-5: var(--gl-space-5); + --spacing-gl-6: var(--gl-space-6); + --spacing-gl-8: var(--gl-space-8); + --color-gl-surface-border: var(--gl-surface-border); + --color-gl-surface-border-strong: var(--gl-surface-border-strong); + --color-gl-surface-chrome-inactive: var(--gl-surface-chrome-inactive); + --color-gl-surface-divider: var(--gl-surface-divider); + --color-gl-surface-field: var(--gl-surface-field); + --color-gl-surface-handle: var(--gl-surface-handle); + --color-gl-surface-handle-hover: var(--gl-surface-handle-hover); + --color-gl-surface-inset: var(--gl-surface-inset); + --color-gl-surface-inset-deep: var(--gl-surface-inset-deep); + --color-gl-surface-node-body: var(--gl-surface-node-body); + --color-gl-surface-text-bright: var(--gl-surface-text-bright); + --color-gl-surface-text-label: var(--gl-surface-text-label); + --color-gl-surface-text-muted: var(--gl-surface-text-muted); + --color-gl-surface-text-primary: var(--gl-surface-text-primary); + --color-gl-surface-text-secondary: var(--gl-surface-text-secondary); + --color-gl-surface-text-soft: var(--gl-surface-text-soft); + --color-gl-surface-text-strong: var(--gl-surface-text-strong); + --color-gl-surface-window: var(--gl-surface-window); + --color-gl-syntax-builtin: var(--gl-syntax-builtin); + --color-gl-syntax-comment: var(--gl-syntax-comment); + --color-gl-syntax-function: var(--gl-syntax-function); + --color-gl-syntax-keyword: var(--gl-syntax-keyword); + --color-gl-syntax-number: var(--gl-syntax-number); + --color-gl-syntax-string: var(--gl-syntax-string); + --text-gl-base: var(--gl-text-base); + --text-gl-lg: var(--gl-text-lg); + --text-gl-sm: var(--gl-text-sm); + --text-gl-xl: var(--gl-text-xl); + --text-gl-xs: var(--gl-text-xs); + --font-weight-gl-regular: var(--gl-weight-regular); + --font-weight-gl-semibold: var(--gl-weight-semibold); } diff --git a/web_ui/src/lib/tokens/gl-vars-dev.css b/web_ui/src/lib/tokens/gl-vars-dev.css index a55183a..f2328d8 100644 --- a/web_ui/src/lib/tokens/gl-vars-dev.css +++ b/web_ui/src/lib/tokens/gl-vars-dev.css @@ -75,6 +75,9 @@ --gl-graph-node-header-end: #333333; --gl-graph-node-header-start: #3c3c3c; --gl-graph-node-panel-fill: #202020; + --gl-motion-base: 200ms; + --gl-motion-ease: cubic-bezier(0.2, 0, 0, 1); + --gl-motion-fast: 150ms; --gl-neutral-button-background: #393939; --gl-neutral-button-border: #585858; --gl-neutral-button-hover: #484848; @@ -85,6 +88,9 @@ --gl-palette-nav-highlight: #949494; --gl-palette-selection: #858585; --gl-palette-user-node: #838383; + --gl-radius-lg: 12px; + --gl-radius-md: 8px; + --gl-radius-sm: 4px; --gl-semantic-artifact: #828282; --gl-semantic-conversation-ai-bubble: #323232; --gl-semantic-conversation-user-bubble: #696969; @@ -94,4 +100,45 @@ --gl-semantic-status-info: #828282; --gl-semantic-status-success: #838383; --gl-semantic-status-warning: #919191; + --gl-shadow-1: 0 1px 3px rgba(0, 0, 0, 0.40); + --gl-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.45); + --gl-shadow-3: 0 8px 28px rgba(0, 0, 0, 0.55); + --gl-space-1: 4px; + --gl-space-2: 8px; + --gl-space-3: 12px; + --gl-space-4: 16px; + --gl-space-5: 20px; + --gl-space-6: 24px; + --gl-space-8: 32px; + --gl-surface-border: #3F3F3F; + --gl-surface-border-strong: #505050; + --gl-surface-chrome-inactive: #888888; + --gl-surface-divider: #424242; + --gl-surface-field: #272727; + --gl-surface-handle: #555555; + --gl-surface-handle-hover: #6A6A6A; + --gl-surface-inset: #202020; + --gl-surface-inset-deep: #121212; + --gl-surface-node-body: #252525; + --gl-surface-text-bright: #FFFFFF; + --gl-surface-text-label: #A4A4A4; + --gl-surface-text-muted: #767676; + --gl-surface-text-primary: #E0E0E0; + --gl-surface-text-secondary: #BDBDBD; + --gl-surface-text-soft: #CCCCCC; + --gl-surface-text-strong: #F1F1F1; + --gl-surface-window: #1E1E1E; + --gl-syntax-builtin: #A2A2A2; + --gl-syntax-comment: #626262; + --gl-syntax-function: #A3A3A3; + --gl-syntax-keyword: #909090; + --gl-syntax-number: #A2A2A2; + --gl-syntax-string: #B5B5B5; + --gl-text-base: 14px; + --gl-text-lg: 16px; + --gl-text-sm: 13px; + --gl-text-xl: 20px; + --gl-text-xs: 12px; + --gl-weight-regular: 400; + --gl-weight-semibold: 600; } From e46d573dec4b7976ad80f07dc460d321316d75de Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 09:22:45 -0400 Subject: [PATCH 2/2] UI refactor P1: OverlayManager - one owner for every popup/dialog lifecycle Second phase of the UI refactor plan (doc/UI_QA_AUDIT.md section 7). The audit's section-2 findings were a class, not incidents: this phase removes the class. New graphlink_overlay_manager.py (OverlayManager + DialogScrim): - Named-surface registry with POPOVER/DIALOG tiers; single-open policy across both tiers - opening anything closes whatever else is open (audit B1: Plugins+Controls+Settings+Library used to stack freely). - One QApplication event filter: Escape closes the open surface; a mouse press outside an open popover light-dismisses it without eating the click (audit B2). Dialogs dismiss via close button/scrim/Escape only, preserving the legacy stay-open-during-scans rationale. - DialogScrim: translucent full-window layer under dialogs - visible modality, guaranteed z-order, click-to-dismiss. - visibility_changed signal -> toolbar payload's new activeSurface field: chips render active from REAL visibility, never local latched click state (audit B6; web side: App.tsx reads payload, local latch removed, schema regenerated, island rebuilt). - Two manager bugs found by this phase's own acceptance tests and fixed: close() captures state BEFORE close_fn() because hide delivers Show/HideEvents synchronously through the manager's own app filter; and the filter never mutates manager state for the same reason. Settings + Library converted from escaping top-level windows to clamped embedded children (audit B4): - SettingsWebHost: Tool flags + screen-coordinate show_for_anchor DELETED; now wrapped in the new token-styled DialogFrame (title + close button - audit B5) and centered/clamped by the manager. - ChatLibraryDialog: frameless stay-on-top per-open window -> cached embedded child (explicit Qt.Widget flags: QDialog defaults to a Dialog WINDOW flag even with a parent, which briefly made it an invisible translucent top-level - found live). The fresh-window-per-open construction race WAS audit B3's first-click no-op; first click now opens reliably (live-verified). THE BIG ONE - audit B7 root-caused and fixed: the library's "black void" body was the QGraphicsDropShadowEffect on its shell. A QGraphicsEffect on any ancestor forces software rendering that the GPU-composited QWebEngineView cannot join, so the entire web region painted as a solid black rectangle. Same effect removed from DialogFrame (where it initially reproduced identically on Settings). Live-verified: library now renders its full chat list (19 chats, search, CRUD row) on first open; settings renders all sections. No ancestor of a webview may carry a QGraphicsEffect. Escape delivery over web content: QtWebEngine feeds keys straight into the render process - app filter, QShortcuts, and ShortcutOverride interception never fire (each verified live). Added a host-injected page-side relay (capture-phase keydown -> titleChanged -> escape_pressed signal, zero bridge/schema/rebuild cost) wired to close_all for every island host. End-to-end verified in-process (synthetic page keydown closes the surface); real-OS-key confirmation pending one human keystroke - the automation tool's synthetic Escape also failed against native Qt menus pre-refactor, implicating the tool's key delivery rather than app code. All toolbar handlers (library/settings/about/help/plugins/pins/controls) route through manager.toggle(); resize re-clamps the open dialog + scrim. Tests: 22 new acceptance tests including a REAL ChatWindow drive (single-open across real surfaces, Escape per surface, no dialog pixel outside the window at 3 window sizes, library-first-open). Two stale settings-host tests rewritten to pin the embedded contract. Full pytest 1766 passed; vitest 535 passed; live drive on Windows verified all section-2 behaviors visually. Co-Authored-By: Claude Fable 5 --- assets/toolbar/assets/index-Ds23m8jz.js | 40 +++ assets/toolbar/assets/index-DwVg4wwn.js | 40 --- assets/toolbar/index.html | 2 +- graphlink_app/graphlink_dialog_frame.py | 106 ++++++++ graphlink_app/graphlink_overlay_manager.py | 247 +++++++++++++++++ graphlink_app/graphlink_settings_web.py | 40 +-- graphlink_app/graphlink_toolbar_bridge.py | 7 +- graphlink_app/graphlink_toolbar_payload.py | 4 + .../graphlink_library_dialog.py | 53 ++-- graphlink_app/graphlink_web_island_host.py | 37 ++- graphlink_app/graphlink_window.py | 195 ++++++++++---- graphlink_app/tests/test_overlay_manager.py | 250 ++++++++++++++++++ graphlink_app/tests/test_settings_web_host.py | 24 +- graphlink_app/tests/test_toolbar_bridge.py | 48 +++- web_ui/src/islands/toolbar/App.test.tsx | 54 +++- web_ui/src/islands/toolbar/App.tsx | 43 ++- web_ui/src/islands/toolbar/bridge.test.ts | 4 +- web_ui/src/islands/toolbar/bridgeTypes.ts | 9 +- .../generated/toolbar-state.schema.json | 4 + .../bridge-core/generated/toolbar-state.ts | 6 + 20 files changed, 1025 insertions(+), 188 deletions(-) create mode 100644 assets/toolbar/assets/index-Ds23m8jz.js delete mode 100644 assets/toolbar/assets/index-DwVg4wwn.js create mode 100644 graphlink_app/graphlink_dialog_frame.py create mode 100644 graphlink_app/graphlink_overlay_manager.py create mode 100644 graphlink_app/tests/test_overlay_manager.py diff --git a/assets/toolbar/assets/index-Ds23m8jz.js b/assets/toolbar/assets/index-Ds23m8jz.js new file mode 100644 index 0000000..88d48fd --- /dev/null +++ b/assets/toolbar/assets/index-Ds23m8jz.js @@ -0,0 +1,40 @@ +var jf=Object.defineProperty;var If=(w,S,d)=>S in w?jf(w,S,{enumerable:!0,configurable:!0,writable:!0,value:d}):w[S]=d;var xo=(w,S,d)=>If(w,typeof S!="symbol"?S+"":S,d);(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const L of document.querySelectorAll('link[rel="modulepreload"]'))x(L);new MutationObserver(L=>{for(const V of L)if(V.type==="childList")for(const A of V.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&x(A)}).observe(document,{childList:!0,subtree:!0});function d(L){const V={};return L.integrity&&(V.integrity=L.integrity),L.referrerPolicy&&(V.referrerPolicy=L.referrerPolicy),L.crossOrigin==="use-credentials"?V.credentials="include":L.crossOrigin==="anonymous"?V.credentials="omit":V.credentials="same-origin",V}function x(L){if(L.ep)return;L.ep=!0;const V=d(L);fetch(L.href,V)}})();var _o={exports:{}},gr={},No={exports:{}},U={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var _a;function Df(){if(_a)return U;_a=1;var w=Symbol.for("react.element"),S=Symbol.for("react.portal"),d=Symbol.for("react.fragment"),x=Symbol.for("react.strict_mode"),L=Symbol.for("react.profiler"),V=Symbol.for("react.provider"),A=Symbol.for("react.context"),$=Symbol.for("react.forward_ref"),D=Symbol.for("react.suspense"),de=Symbol.for("react.memo"),pe=Symbol.for("react.lazy"),b=Symbol.iterator;function H(c){return c===null||typeof c!="object"?null:(c=b&&c[b]||c["@@iterator"],typeof c=="function"?c:null)}var he={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C=Object.assign,W={};function G(c,v,F){this.props=c,this.context=v,this.refs=W,this.updater=F||he}G.prototype.isReactComponent={},G.prototype.setState=function(c,v){if(typeof c!="object"&&typeof c!="function"&&c!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,c,v,"setState")},G.prototype.forceUpdate=function(c){this.updater.enqueueForceUpdate(this,c,"forceUpdate")};function Le(){}Le.prototype=G.prototype;function ct(c,v,F){this.props=c,this.context=v,this.refs=W,this.updater=F||he}var et=ct.prototype=new Le;et.constructor=ct,C(et,G.prototype),et.isPureReactComponent=!0;var Ce=Array.isArray,tt=Object.prototype.hasOwnProperty,ze={current:null},Oe={key:!0,ref:!0,__self:!0,__source:!0};function Ke(c,v,F){var B,K={},Y=null,ee=null;if(v!=null)for(B in v.ref!==void 0&&(ee=v.ref),v.key!==void 0&&(Y=""+v.key),v)tt.call(v,B)&&!Oe.hasOwnProperty(B)&&(K[B]=v[B]);var Z=arguments.length-2;if(Z===1)K.children=F;else if(1>>1,v=E[c];if(0>>1;cL(K,P))YL(ee,K)?(E[c]=ee,E[Y]=P,c=Y):(E[c]=K,E[B]=P,c=B);else if(YL(ee,P))E[c]=ee,E[Y]=P,c=Y;else break e}}return j}function L(E,j){var P=E.sortIndex-j.sortIndex;return P!==0?P:E.id-j.id}if(typeof performance=="object"&&typeof performance.now=="function"){var V=performance;w.unstable_now=function(){return V.now()}}else{var A=Date,$=A.now();w.unstable_now=function(){return A.now()-$}}var D=[],de=[],pe=1,b=null,H=3,he=!1,C=!1,W=!1,G=typeof setTimeout=="function"?setTimeout:null,Le=typeof clearTimeout=="function"?clearTimeout:null,ct=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function et(E){for(var j=d(de);j!==null;){if(j.callback===null)x(de);else if(j.startTime<=E)x(de),j.sortIndex=j.expirationTime,S(D,j);else break;j=d(de)}}function Ce(E){if(W=!1,et(E),!C)if(d(D)!==null)C=!0,Me(tt);else{var j=d(de);j!==null&&se(Ce,j.startTime-E)}}function tt(E,j){C=!1,W&&(W=!1,Le(Ke),Ke=-1),he=!0;var P=H;try{for(et(j),b=d(D);b!==null&&(!(b.expirationTime>j)||E&&!Yt());){var c=b.callback;if(typeof c=="function"){b.callback=null,H=b.priorityLevel;var v=c(b.expirationTime<=j);j=w.unstable_now(),typeof v=="function"?b.callback=v:b===d(D)&&x(D),et(j)}else x(D);b=d(D)}if(b!==null)var F=!0;else{var B=d(de);B!==null&&se(Ce,B.startTime-j),F=!1}return F}finally{b=null,H=P,he=!1}}var ze=!1,Oe=null,Ke=-1,Pt=5,gt=-1;function Yt(){return!(w.unstable_now()-gtE||125c?(E.sortIndex=P,S(de,E),d(D)===null&&E===d(de)&&(W?(Le(Ke),Ke=-1):W=!0,se(Ce,P-c))):(E.sortIndex=v,S(D,E),C||he||(C=!0,Me(tt))),E},w.unstable_shouldYield=Yt,w.unstable_wrapCallback=function(E){var j=H;return function(){var P=H;H=j;try{return E.apply(this,arguments)}finally{H=P}}}})(To)),To}var Ra;function Vf(){return Ra||(Ra=1,zo.exports=Uf()),zo.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var La;function $f(){if(La)return Ue;La=1;var w=Ro(),S=Vf();function d(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),D=Object.prototype.hasOwnProperty,de=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,pe={},b={};function H(e){return D.call(b,e)?!0:D.call(pe,e)?!1:de.test(e)?b[e]=!0:(pe[e]=!0,!1)}function he(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function C(e,t,n,r){if(t===null||typeof t>"u"||he(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function W(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var G={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){G[e]=new W(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];G[t]=new W(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){G[e]=new W(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){G[e]=new W(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){G[e]=new W(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){G[e]=new W(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){G[e]=new W(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){G[e]=new W(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){G[e]=new W(e,5,!1,e.toLowerCase(),null,!1,!1)});var Le=/[\-:]([a-z])/g;function ct(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Le,ct);G[t]=new W(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Le,ct);G[t]=new W(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Le,ct);G[t]=new W(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){G[e]=new W(e,1,!1,e.toLowerCase(),null,!1,!1)}),G.xlinkHref=new W("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){G[e]=new W(e,1,!1,e.toLowerCase(),null,!0,!0)});function et(e,t,n,r){var l=G.hasOwnProperty(t)?G[t]:null;(l!==null?l.type!==0:r||!(2i||l[o]!==u[i]){var s=` +`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{F=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?v(e):""}function K(e){switch(e.tag){case 5:return v(e.type);case 16:return v("Lazy");case 13:return v("Suspense");case 19:return v("SuspenseList");case 0:case 2:case 15:return e=B(e.type,!1),e;case 11:return e=B(e.type.render,!1),e;case 1:return e=B(e.type,!0),e;default:return""}}function Y(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Oe:return"Fragment";case ze:return"Portal";case Pt:return"Profiler";case Ke:return"StrictMode";case Ve:return"Suspense";case nt:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Yt:return(e.displayName||"Context")+".Consumer";case gt:return(e._context.displayName||"Context")+".Provider";case ft:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case dt:return t=e.displayName||null,t!==null?t:Y(e.type)||"Memo";case Me:t=e._payload,e=e._init;try{return Y(e(t))}catch{}}return null}function ee(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Y(t);case 8:return t===Ke?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Z(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function $e(e){var t=le(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function wr(e){e._valueTracker||(e._valueTracker=$e(e))}function Lo(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=le(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function kr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Rl(e,t){var n=t.checked;return P({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Oo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Z(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Mo(e,t){t=t.checked,t!=null&&et(e,"checked",t,!1)}function Ll(e,t){Mo(e,t);var n=Z(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ol(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ol(e,t.type,Z(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function jo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ol(e,t,n){(t!=="number"||kr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Mn=Array.isArray;function sn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Sr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function jn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var In={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Fa=["Webkit","ms","Moz","O"];Object.keys(In).forEach(function(e){Fa.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),In[t]=In[e]})});function Vo(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||In.hasOwnProperty(e)&&In[e]?(""+t).trim():t+"px"}function $o(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Vo(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Aa=P({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Il(e,t){if(t){if(Aa[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(d(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(d(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(d(61))}if(t.style!=null&&typeof t.style!="object")throw Error(d(62))}}function Dl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Fl=null;function Al(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ul=null,an=null,cn=null;function Bo(e){if(e=rr(e)){if(typeof Ul!="function")throw Error(d(280));var t=e.stateNode;t&&(t=Wr(t),Ul(e.stateNode,e.type,t))}}function Ho(e){an?cn?cn.push(e):cn=[e]:an=e}function Wo(){if(an){var e=an,t=cn;if(cn=an=null,Bo(e),t)for(e=0;e>>=0,e===0?32:31-(Ga(e)/Za|0)|0}var Nr=64,Pr=4194304;function Un(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function zr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=Un(i):(u&=o,u!==0&&(r=Un(u)))}else o=n&~l,o!==0?r=Un(o):u!==0&&(r=Un(u));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Vn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-rt(t),e[t]=n}function ec(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Xn),gi=" ",wi=!1;function ki(e,t){switch(e){case"keyup":return zc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Si(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var pn=!1;function Rc(e,t){switch(e){case"compositionend":return Si(t);case"keypress":return t.which!==32?null:(wi=!0,gi);case"textInput":return e=t.data,e===gi&&wi?null:e;default:return null}}function Lc(e,t){if(pn)return e==="compositionend"||!ru&&ki(e,t)?(e=di(),Mr=Jl=Ot=null,pn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=zi(n)}}function Ri(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ri(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Li(){for(var e=window,t=kr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=kr(e.document)}return t}function ou(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Vc(e){var t=Li(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ri(n.ownerDocument.documentElement,n)){if(r!==null&&ou(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=Ti(n,u);var o=Ti(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,mn=null,iu=null,qn=null,su=!1;function Oi(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;su||mn==null||mn!==kr(r)||(r=mn,"selectionStart"in r&&ou(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),qn&&Jn(qn,r)||(qn=r,r=$r(iu,"onSelect"),0wn||(e.current=ku[wn],ku[wn]=null,wn--)}function te(e,t){wn++,ku[wn]=e.current,e.current=t}var Dt={},xe=It(Dt),je=It(!1),Zt=Dt;function kn(e,t){var n=e.type.contextTypes;if(!n)return Dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ie(e){return e=e.childContextTypes,e!=null}function Qr(){re(je),re(xe)}function Yi(e,t,n){if(xe.current!==Dt)throw Error(d(168));te(xe,t),te(je,n)}function Xi(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(d(108,ee(e)||"Unknown",l));return P({},n,r)}function Kr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Dt,Zt=xe.current,te(xe,e),te(je,je.current),!0}function Gi(e,t,n){var r=e.stateNode;if(!r)throw Error(d(169));n?(e=Xi(e,t,Zt),r.__reactInternalMemoizedMergedChildContext=e,re(je),re(xe),te(xe,e)):re(je),te(je,n)}var kt=null,Yr=!1,Su=!1;function Zi(e){kt===null?kt=[e]:kt.push(e)}function qc(e){Yr=!0,Zi(e)}function Ft(){if(!Su&&kt!==null){Su=!0;var e=0,t=J;try{var n=kt;for(J=1;e>=o,l-=o,St=1<<32-rt(t)+l|n<I?(ke=M,M=null):ke=M.sibling;var X=h(f,M,p[I],k);if(X===null){M===null&&(M=ke);break}e&&M&&X.alternate===null&&t(f,M),a=u(X,a,I),O===null?R=X:O.sibling=X,O=X,M=ke}if(I===p.length)return n(f,M),ue&&qt(f,I),R;if(M===null){for(;II?(ke=M,M=null):ke=M.sibling;var Kt=h(f,M,X.value,k);if(Kt===null){M===null&&(M=ke);break}e&&M&&Kt.alternate===null&&t(f,M),a=u(Kt,a,I),O===null?R=Kt:O.sibling=Kt,O=Kt,M=ke}if(X.done)return n(f,M),ue&&qt(f,I),R;if(M===null){for(;!X.done;I++,X=p.next())X=g(f,X.value,k),X!==null&&(a=u(X,a,I),O===null?R=X:O.sibling=X,O=X);return ue&&qt(f,I),R}for(M=r(f,M);!X.done;I++,X=p.next())X=_(M,f,I,X.value,k),X!==null&&(e&&X.alternate!==null&&M.delete(X.key===null?I:X.key),a=u(X,a,I),O===null?R=X:O.sibling=X,O=X);return e&&M.forEach(function(Mf){return t(f,Mf)}),ue&&qt(f,I),R}function fe(f,a,p,k){if(typeof p=="object"&&p!==null&&p.type===Oe&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case tt:e:{for(var R=p.key,O=a;O!==null;){if(O.key===R){if(R=p.type,R===Oe){if(O.tag===7){n(f,O.sibling),a=l(O,p.props.children),a.return=f,f=a;break e}}else if(O.elementType===R||typeof R=="object"&&R!==null&&R.$$typeof===Me&&ns(R)===O.type){n(f,O.sibling),a=l(O,p.props),a.ref=lr(f,O,p),a.return=f,f=a;break e}n(f,O);break}else t(f,O);O=O.sibling}p.type===Oe?(a=on(p.props.children,f.mode,k,p.key),a.return=f,f=a):(k=Sl(p.type,p.key,p.props,null,f.mode,k),k.ref=lr(f,a,p),k.return=f,f=k)}return o(f);case ze:e:{for(O=p.key;a!==null;){if(a.key===O)if(a.tag===4&&a.stateNode.containerInfo===p.containerInfo&&a.stateNode.implementation===p.implementation){n(f,a.sibling),a=l(a,p.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=wo(p,f.mode,k),a.return=f,f=a}return o(f);case Me:return O=p._init,fe(f,a,O(p._payload),k)}if(Mn(p))return z(f,a,p,k);if(j(p))return T(f,a,p,k);Jr(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,p),a.return=f,f=a):(n(f,a),a=go(p,f.mode,k),a.return=f,f=a),o(f)):n(f,a)}return fe}var xn=rs(!0),ls=rs(!1),qr=It(null),br=null,_n=null,Pu=null;function zu(){Pu=_n=br=null}function Tu(e){var t=qr.current;re(qr),e._currentValue=t}function Ru(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Nn(e,t){br=e,Pu=_n=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(De=!0),e.firstContext=null)}function Ge(e){var t=e._currentValue;if(Pu!==e)if(e={context:e,memoizedValue:t,next:null},_n===null){if(br===null)throw Error(d(308));_n=e,br.dependencies={lanes:0,firstContext:e}}else _n=_n.next=e;return t}var bt=null;function Lu(e){bt===null?bt=[e]:bt.push(e)}function us(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Lu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ct(e,r)}function Ct(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var At=!1;function Ou(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function os(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function xt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Ut(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(Q&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ct(e,n)}return l=r.interleaved,l===null?(t.next=t,Lu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ct(e,n)}function el(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Kl(e,n)}}function is(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function tl(e,t,n,r){var l=e.updateQueue;At=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,m=s.next;s.next=null,o===null?u=m:o.next=m,o=s;var y=e.alternate;y!==null&&(y=y.updateQueue,i=y.lastBaseUpdate,i!==o&&(i===null?y.firstBaseUpdate=m:i.next=m,y.lastBaseUpdate=s))}if(u!==null){var g=l.baseState;o=0,y=m=s=null,i=u;do{var h=i.lane,_=i.eventTime;if((r&h)===h){y!==null&&(y=y.next={eventTime:_,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var z=e,T=i;switch(h=t,_=n,T.tag){case 1:if(z=T.payload,typeof z=="function"){g=z.call(_,g,h);break e}g=z;break e;case 3:z.flags=z.flags&-65537|128;case 0:if(z=T.payload,h=typeof z=="function"?z.call(_,g,h):z,h==null)break e;g=P({},g,h);break e;case 2:At=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[i]:h.push(i))}else _={eventTime:_,lane:h,tag:i.tag,payload:i.payload,callback:i.callback,next:null},y===null?(m=y=_,s=g):y=y.next=_,o|=h;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;h=i,i=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(y===null&&(s=g),l.baseState=s,l.firstBaseUpdate=m,l.lastBaseUpdate=y,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);nn|=o,e.lanes=o,e.memoizedState=g}}function ss(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Fu.transition;Fu.transition={};try{e(!1),t()}finally{J=n,Fu.transition=r}}function Ps(){return Ze().memoizedState}function nf(e,t,n){var r=Ht(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},zs(e))Ts(t,n);else if(n=us(e,t,n,r),n!==null){var l=Re();at(n,e,r,l),Rs(n,t,r)}}function rf(e,t,n){var r=Ht(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(zs(e))Ts(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,lt(i,o)){var s=t.interleaved;s===null?(l.next=l,Lu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=us(e,t,l,r),n!==null&&(l=Re(),at(n,e,r,l),Rs(n,t,r))}}function zs(e){var t=e.alternate;return e===ie||t!==null&&t===ie}function Ts(e,t){sr=ll=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Rs(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Kl(e,n)}}var il={readContext:Ge,useCallback:_e,useContext:_e,useEffect:_e,useImperativeHandle:_e,useInsertionEffect:_e,useLayoutEffect:_e,useMemo:_e,useReducer:_e,useRef:_e,useState:_e,useDebugValue:_e,useDeferredValue:_e,useTransition:_e,useMutableSource:_e,useSyncExternalStore:_e,useId:_e,unstable_isNewReconciler:!1},lf={readContext:Ge,useCallback:function(e,t){return vt().memoizedState=[e,t===void 0?null:t],e},useContext:Ge,useEffect:ws,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,ul(4194308,4,Es.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ul(4194308,4,e,t)},useInsertionEffect:function(e,t){return ul(4,2,e,t)},useMemo:function(e,t){var n=vt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=vt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=nf.bind(null,ie,e),[r.memoizedState,e]},useRef:function(e){var t=vt();return e={current:e},t.memoizedState=e},useState:ys,useDebugValue:Wu,useDeferredValue:function(e){return vt().memoizedState=e},useTransition:function(){var e=ys(!1),t=e[0];return e=tf.bind(null,e[1]),vt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ie,l=vt();if(ue){if(n===void 0)throw Error(d(407));n=n()}else{if(n=t(),we===null)throw Error(d(349));(tn&30)!==0||ds(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,ws(ms.bind(null,r,u,e),[e]),r.flags|=2048,fr(9,ps.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=vt(),t=we.identifierPrefix;if(ue){var n=Et,r=St;n=(r&~(1<<32-rt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=ar++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[mt]=t,e[nr]=r,Zs(e,t,!1,!1),t.stateNode=e;e:{switch(o=Dl(n,r),n){case"dialog":ne("cancel",e),ne("close",e),l=r;break;case"iframe":case"object":case"embed":ne("load",e),l=r;break;case"video":case"audio":for(l=0;lLn&&(t.flags|=128,r=!0,dr(u,!1),t.lanes=4194304)}else{if(!r)if(e=nl(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),dr(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!ue)return Ne(t),null}else 2*ce()-u.renderingStartTime>Ln&&n!==1073741824&&(t.flags|=128,r=!0,dr(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=ce(),t.sibling=null,n=oe.current,te(oe,r?n&1|2:n&1),t):(Ne(t),null);case 22:case 23:return ho(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(Qe&1073741824)!==0&&(Ne(t),t.subtreeFlags&6&&(t.flags|=8192)):Ne(t),null;case 24:return null;case 25:return null}throw Error(d(156,t.tag))}function pf(e,t){switch(Cu(t),t.tag){case 1:return Ie(t.type)&&Qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Pn(),re(je),re(xe),Du(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return ju(t),null;case 13:if(re(oe),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(d(340));Cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return re(oe),null;case 4:return Pn(),null;case 10:return Tu(t.type._context),null;case 22:case 23:return ho(),null;case 24:return null;default:return null}}var fl=!1,Pe=!1,mf=typeof WeakSet=="function"?WeakSet:Set,N=null;function Tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ae(e,t,r)}else n.current=null}function no(e,t,n){try{n()}catch(r){ae(e,t,r)}}var bs=!1;function hf(e,t){if(mu=Lr,e=Li(),ou(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,m=0,y=0,g=e,h=null;t:for(;;){for(var _;g!==n||l!==0&&g.nodeType!==3||(i=o+l),g!==u||r!==0&&g.nodeType!==3||(s=o+r),g.nodeType===3&&(o+=g.nodeValue.length),(_=g.firstChild)!==null;)h=g,g=_;for(;;){if(g===e)break t;if(h===n&&++m===l&&(i=o),h===u&&++y===r&&(s=o),(_=g.nextSibling)!==null)break;g=h,h=g.parentNode}g=_}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(hu={focusedElem:e,selectionRange:n},Lr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var z=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(z!==null){var T=z.memoizedProps,fe=z.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?T:ot(t.type,T),fe);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(d(163))}}catch(k){ae(t,t.return,k)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return z=bs,bs=!1,z}function pr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&no(t,n,u)}l=l.next}while(l!==r)}}function dl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ro(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function ea(e){var t=e.alternate;t!==null&&(e.alternate=null,ea(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[mt],delete t[nr],delete t[wu],delete t[Zc],delete t[Jc])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ta(e){return e.tag===5||e.tag===3||e.tag===4}function na(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ta(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function lo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Hr));else if(r!==4&&(e=e.child,e!==null))for(lo(e,t,n),e=e.sibling;e!==null;)lo(e,t,n),e=e.sibling}function uo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(uo(e,t,n),e=e.sibling;e!==null;)uo(e,t,n),e=e.sibling}var Se=null,it=!1;function Vt(e,t,n){for(n=n.child;n!==null;)ra(e,t,n),n=n.sibling}function ra(e,t,n){if(pt&&typeof pt.onCommitFiberUnmount=="function")try{pt.onCommitFiberUnmount(_r,n)}catch{}switch(n.tag){case 5:Pe||Tn(n,t);case 6:var r=Se,l=it;Se=null,Vt(e,t,n),Se=r,it=l,Se!==null&&(it?(e=Se,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Se.removeChild(n.stateNode));break;case 18:Se!==null&&(it?(e=Se,n=n.stateNode,e.nodeType===8?gu(e.parentNode,n):e.nodeType===1&&gu(e,n),Qn(e)):gu(Se,n.stateNode));break;case 4:r=Se,l=it,Se=n.stateNode.containerInfo,it=!0,Vt(e,t,n),Se=r,it=l;break;case 0:case 11:case 14:case 15:if(!Pe&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&((u&2)!==0||(u&4)!==0)&&no(n,t,o),l=l.next}while(l!==r)}Vt(e,t,n);break;case 1:if(!Pe&&(Tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){ae(n,t,i)}Vt(e,t,n);break;case 21:Vt(e,t,n);break;case 22:n.mode&1?(Pe=(r=Pe)||n.memoizedState!==null,Vt(e,t,n),Pe=r):Vt(e,t,n);break;default:Vt(e,t,n)}}function la(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new mf),t.forEach(function(r){var l=xf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function st(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=ce()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*yf(r/1960))-r,10e?16:e,Bt===null)var r=!1;else{if(e=Bt,Bt=null,yl=0,(Q&6)!==0)throw Error(d(331));var l=Q;for(Q|=4,N=e.current;N!==null;){var u=N,o=u.child;if((N.flags&16)!==0){var i=u.deletions;if(i!==null){for(var s=0;sce()-so?ln(e,0):io|=n),Ae(e,t)}function ya(e,t){t===0&&((e.mode&1)===0?t=1:(t=Pr,Pr<<=1,(Pr&130023424)===0&&(Pr=4194304)));var n=Re();e=Ct(e,t),e!==null&&(Vn(e,t,n),Ae(e,n))}function Cf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ya(e,n)}function xf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(d(314))}r!==null&&r.delete(t),ya(e,n)}var ga;ga=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||je.current)De=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return De=!1,ff(e,t,n);De=(e.flags&131072)!==0}else De=!1,ue&&(t.flags&1048576)!==0&&Ji(t,Gr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;cl(e,t),e=t.pendingProps;var l=kn(t,xe.current);Nn(t,n),l=Uu(null,t,r,e,l,n);var u=Vu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ie(r)?(u=!0,Kr(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Ou(t),l.updater=sl,t.stateNode=l,l._reactInternals=t,Ku(t,r,e,n),t=Zu(null,t,r,!0,u,n)):(t.tag=0,ue&&u&&Eu(t),Te(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(cl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Nf(r),e=ot(r,e),l){case 0:t=Gu(null,t,r,e,n);break e;case 1:t=Ws(null,t,r,e,n);break e;case 11:t=Us(null,t,r,e,n);break e;case 14:t=Vs(null,t,r,ot(r.type,e),n);break e}throw Error(d(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ot(r,l),Gu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ot(r,l),Ws(e,t,r,l,n);case 3:e:{if(Qs(t),e===null)throw Error(d(387));r=t.pendingProps,u=t.memoizedState,l=u.element,os(e,t),tl(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=zn(Error(d(423)),t),t=Ks(e,t,r,n,l);break e}else if(r!==l){l=zn(Error(d(424)),t),t=Ks(e,t,r,n,l);break e}else for(We=jt(t.stateNode.containerInfo.firstChild),He=t,ue=!0,ut=null,n=ls(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Cn(),r===l){t=_t(e,t,n);break e}Te(e,t,r,n)}t=t.child}return t;case 5:return as(t),e===null&&_u(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,vu(r,l)?o=null:u!==null&&vu(r,u)&&(t.flags|=32),Hs(e,t),Te(e,t,o,n),t.child;case 6:return e===null&&_u(t),null;case 13:return Ys(e,t,n);case 4:return Mu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=xn(t,null,r,n):Te(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ot(r,l),Us(e,t,r,l,n);case 7:return Te(e,t,t.pendingProps,n),t.child;case 8:return Te(e,t,t.pendingProps.children,n),t.child;case 12:return Te(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,te(qr,r._currentValue),r._currentValue=o,u!==null)if(lt(u.value,o)){if(u.children===l.children&&!je.current){t=_t(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=xt(-1,n&-n),s.tag=2;var m=u.updateQueue;if(m!==null){m=m.shared;var y=m.pending;y===null?s.next=s:(s.next=y.next,y.next=s),m.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),Ru(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(d(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),Ru(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}Te(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Nn(t,n),l=Ge(l),r=r(l),t.flags|=1,Te(e,t,r,n),t.child;case 14:return r=t.type,l=ot(r,t.pendingProps),l=ot(r.type,l),Vs(e,t,r,l,n);case 15:return $s(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ot(r,l),cl(e,t),t.tag=1,Ie(r)?(e=!0,Kr(t)):e=!1,Nn(t,n),Os(t,r,l),Ku(t,r,l,n),Zu(null,t,r,!0,e,n);case 19:return Gs(e,t,n);case 22:return Bs(e,t,n)}throw Error(d(156,t.tag))};function wa(e,t){return qo(e,t)}function _f(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function qe(e,t,n,r){return new _f(e,t,n,r)}function yo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Nf(e){if(typeof e=="function")return yo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ft)return 11;if(e===dt)return 14}return 2}function Qt(e,t){var n=e.alternate;return n===null?(n=qe(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Sl(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")yo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Oe:return on(n.children,l,u,t);case Ke:o=8,l|=8;break;case Pt:return e=qe(12,n,t,l|2),e.elementType=Pt,e.lanes=u,e;case Ve:return e=qe(13,n,t,l),e.elementType=Ve,e.lanes=u,e;case nt:return e=qe(19,n,t,l),e.elementType=nt,e.lanes=u,e;case se:return El(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case gt:o=10;break e;case Yt:o=9;break e;case ft:o=11;break e;case dt:o=14;break e;case Me:o=16,r=null;break e}throw Error(d(130,e==null?e:typeof e,""))}return t=qe(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function on(e,t,n,r){return e=qe(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=qe(22,e,r,t),e.elementType=se,e.lanes=n,e.stateNode={isHidden:!1},e}function go(e,t,n){return e=qe(6,e,null,t),e.lanes=n,e}function wo(e,t,n){return t=qe(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Pf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ql(0),this.expirationTimes=Ql(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ql(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ko(e,t,n,r,l,u,o,i,s){return e=new Pf(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=qe(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ou(u),e}function zf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(w)}catch(S){console.error(S)}}return w(),Po.exports=$f(),Po.exports}var Ma;function Hf(){if(Ma)return Tl;Ma=1;var w=Bf();return Tl.createRoot=w.createRoot,Tl.hydrateRoot=w.hydrateRoot,Tl}var Wf=Hf();const Da={schemaVersion:1,minCompatibleSchemaVersion:1,revision:0,pinsChecked:!1,activeSurface:"",modeOptions:[],currentMode:""};function Qf(w=window){var d;const S=w;return!!(S.QWebChannel&&((d=S.qt)!=null&&d.webChannelTransport))}function Kf(w,S=window){var x;const d=S;!d.QWebChannel||!((x=d.qt)!=null&&x.webChannelTransport)||new d.QWebChannel(d.qt.webChannelTransport,L=>{w(L.objects)})}function Yf(w){return typeof w=="object"&&w!==null&&!Array.isArray(w)}function Xf(w,S,d){if(!Yf(w)){d.push(`${S}: expected object`);return}{const x=w.schemaVersion;x==null?d.push(`${S}.schemaVersion: missing required field`):typeof x!="number"&&d.push(`${S}.schemaVersion: expected number`)}{const x=w.revision;x==null?d.push(`${S}.revision: missing required field`):typeof x!="number"&&d.push(`${S}.revision: expected number`)}{const x=w.pinsChecked;x==null?d.push(`${S}.pinsChecked: missing required field`):typeof x!="boolean"&&d.push(`${S}.pinsChecked: expected boolean`)}{const x=w.activeSurface;x==null?d.push(`${S}.activeSurface: missing required field`):typeof x!="string"&&d.push(`${S}.activeSurface: expected string`)}{const x=w.modeOptions;x==null?d.push(`${S}.modeOptions: missing required field`):Array.isArray(x)?x.forEach((L,V)=>{typeof L!="string"&&d.push(`${S}.modeOptions[${V}]: expected string`)}):d.push(`${S}.modeOptions: expected array`)}{const x=w.currentMode;x==null?d.push(`${S}.currentMode: missing required field`):typeof x!="string"&&d.push(`${S}.currentMode: expected string`)}{const x=w.minCompatibleSchemaVersion;x!=null&&typeof x!="number"&&d.push(`${S}.minCompatibleSchemaVersion: expected number`)}}function Gf(w){const S=[];return Xf(w,"$",S),S.length===0?{ok:!0,value:w}:{ok:!1,errors:S}}const ja=1,Ia=1;function Zf(w){if(typeof w!="object"||w===null)return{compatible:!1,reason:"The update from the desktop app was not an object."};const S=w,d=S.schemaVersion;if(typeof d!="number"||!Number.isFinite(d))return{compatible:!1,reason:"The update from the desktop app did not declare a schema version."};if(d{const D=qf($);if(D.ok){w(D.state),S==null||S(null);return}console.error(`[toolbar bridge] rejected payload (${D.rejection.kind}): ${D.rejection.reason}`,D.rejection.details),S==null||S(D.rejection)};Kf($=>{x=$.toolbarBridge,x.stateChanged.connect(V),L=!0,x.ready()});const A=($,...D)=>{L&&x&&typeof x[$]=="function"&&x[$](...D)};return{ready:()=>A("ready"),reportAnchorRect:($,D,de,pe,b)=>A("reportAnchorRect",$,D,de,pe,b),openLibrary:()=>A("openLibrary"),saveChat:()=>A("saveChat"),togglePins:()=>A("togglePins"),organizeNodes:()=>A("organizeNodes"),zoomIn:()=>A("zoomIn"),zoomOut:()=>A("zoomOut"),resetZoom:()=>A("resetZoom"),fitAll:()=>A("fitAll"),toggleControls:$=>A("toggleControls",$),togglePlugins:()=>A("togglePlugins"),selectMode:$=>A("selectMode",$),openSettings:()=>A("openSettings"),openAbout:()=>A("openAbout"),openHelp:()=>A("openHelp"),dispose:()=>{var $,D;(D=x==null?void 0:($=x.stateChanged).disconnect)==null||D.call($,V),x=null}}}function td({title:w,rejection:S,className:d}){return q.jsxs("div",{className:d,role:"alert","aria-live":"assertive",children:[q.jsx("div",{className:"bridge-error-title",children:w}),q.jsx("p",{className:"bridge-error-reason",children:S.reason}),S.details.length>0&&q.jsx("ul",{className:"bridge-error-details",children:S.details.map(x=>q.jsx("li",{children:x},x))}),q.jsx("p",{className:"bridge-error-hint",children:S.kind==="version"?"Rebuilding the app's interface assets usually resolves this.":"This is a bug - the desktop app and this interface disagree about the message format."})]})}const nd=["pins","plugins","settings","help"];function rd(){const[w,S]=be.useState(Da),[d,x]=be.useState(null),L=be.useRef(null),V=be.useRef(null),A=be.useRef(null),$=be.useRef(null),D=be.useRef(null),de=be.useRef(null),pe={pins:A,plugins:$,settings:D,help:de};be.useEffect(()=>{const C=ed(S,x);return L.current=C,C.ready(),()=>{C.dispose(),L.current=null}},[]);function b(){var C;for(const W of nd){const G=pe[W].current;if(!G)continue;const Le=G.getBoundingClientRect();(C=L.current)==null||C.reportAnchorRect(W,Math.round(Le.left),Math.round(Le.top),Math.round(Le.width),Math.round(Le.height))}}if(be.useEffect(()=>{const C=V.current;if(!C)return;const W=new ResizeObserver(b);return W.observe(C),()=>W.disconnect()},[]),be.useEffect(b,[w.revision]),d)return q.jsx(td,{title:"The toolbar is unavailable",rejection:d,className:"toolbar-shell bridge-error"});const H=C=>w.activeSurface===C,he=C=>"toolbar-btn toolbar-btn-checkable"+(H(C)?" checked":"");return q.jsxs("div",{className:"toolbar-shell",ref:V,children:[q.jsx("button",{type:"button",className:he("library"),"aria-pressed":H("library"),onClick:()=>{var C;return(C=L.current)==null?void 0:C.openLibrary()},children:"Library"}),q.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var C;return(C=L.current)==null?void 0:C.saveChat()},children:"Save"}),q.jsx("button",{type:"button",ref:A,className:"toolbar-btn toolbar-btn-checkable"+(w.pinsChecked?" checked":""),"aria-pressed":w.pinsChecked,title:"Show navigation pins",onClick:()=>{var C;return(C=L.current)==null?void 0:C.togglePins()},children:"Pins"}),q.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var C;return(C=L.current)==null?void 0:C.organizeNodes()},children:"Organize"}),q.jsx("span",{className:"toolbar-separator"}),q.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var C;return(C=L.current)==null?void 0:C.zoomIn()},children:"Zoom In"}),q.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var C;return(C=L.current)==null?void 0:C.zoomOut()},children:"Zoom Out"}),q.jsx("span",{className:"toolbar-separator"}),q.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var C;return(C=L.current)==null?void 0:C.resetZoom()},children:"Reset"}),q.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var C;return(C=L.current)==null?void 0:C.fitAll()},children:"Fit All"}),q.jsx("button",{type:"button",className:he("controls"),"aria-pressed":H("controls"),onClick:()=>{var C;return(C=L.current)==null?void 0:C.toggleControls(!H("controls"))},children:"Controls"}),q.jsxs("button",{type:"button",ref:$,className:he("plugins")+" toolbar-btn-plugins","aria-pressed":H("plugins"),onClick:()=>{var C;return(C=L.current)==null?void 0:C.togglePlugins()},children:["Plugins ",q.jsx("span",{className:"toolbar-chevron",children:"▾"})]}),q.jsx("span",{className:"toolbar-spacer"}),q.jsx("select",{className:"toolbar-mode-select",value:w.currentMode,"aria-label":"Provider mode",onChange:C=>{var W;return(W=L.current)==null?void 0:W.selectMode(C.target.value)},children:w.modeOptions.map(C=>q.jsx("option",{value:C,children:C},C))}),q.jsx("button",{type:"button",ref:D,className:he("settings"),"aria-pressed":H("settings"),onClick:()=>{var C;return(C=L.current)==null?void 0:C.openSettings()},children:"Settings"}),q.jsx("button",{type:"button",className:he("about"),"aria-pressed":H("about"),onClick:()=>{var C;return(C=L.current)==null?void 0:C.openAbout()},children:"About"}),q.jsx("button",{type:"button",ref:de,className:he("help"),"aria-pressed":H("help"),onClick:()=>{var C;return(C=L.current)==null?void 0:C.openHelp()},children:"Help"})]})}Wf.createRoot(document.getElementById("root")).render(q.jsx(be.StrictMode,{children:q.jsx(rd,{})})); diff --git a/assets/toolbar/assets/index-DwVg4wwn.js b/assets/toolbar/assets/index-DwVg4wwn.js deleted file mode 100644 index 36539d4..0000000 --- a/assets/toolbar/assets/index-DwVg4wwn.js +++ /dev/null @@ -1,40 +0,0 @@ -var jf=Object.defineProperty;var If=(k,S,p)=>S in k?jf(k,S,{enumerable:!0,configurable:!0,writable:!0,value:p}):k[S]=p;var xo=(k,S,p)=>If(k,typeof S!="symbol"?S+"":S,p);(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const D of document.querySelectorAll('link[rel="modulepreload"]'))_(D);new MutationObserver(D=>{for(const H of D)if(H.type==="childList")for(const L of H.addedNodes)L.tagName==="LINK"&&L.rel==="modulepreload"&&_(L)}).observe(document,{childList:!0,subtree:!0});function p(D){const H={};return D.integrity&&(H.integrity=D.integrity),D.referrerPolicy&&(H.referrerPolicy=D.referrerPolicy),D.crossOrigin==="use-credentials"?H.credentials="include":D.crossOrigin==="anonymous"?H.credentials="omit":H.credentials="same-origin",H}function _(D){if(D.ep)return;D.ep=!0;const H=p(D);fetch(D.href,H)}})();var _o={exports:{}},gr={},No={exports:{}},V={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var _a;function Df(){if(_a)return V;_a=1;var k=Symbol.for("react.element"),S=Symbol.for("react.portal"),p=Symbol.for("react.fragment"),_=Symbol.for("react.strict_mode"),D=Symbol.for("react.profiler"),H=Symbol.for("react.provider"),L=Symbol.for("react.context"),$=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),de=Symbol.for("react.memo"),ae=Symbol.for("react.lazy"),J=Symbol.iterator;function q(c){return c===null||typeof c!="object"?null:(c=J&&c[J]||c["@@iterator"],typeof c=="function"?c:null)}var Ne={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Le=Object.assign,E={};function A(c,v,U){this.props=c,this.context=v,this.refs=E,this.updater=U||Ne}A.prototype.isReactComponent={},A.prototype.setState=function(c,v){if(typeof c!="object"&&typeof c!="function"&&c!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,c,v,"setState")},A.prototype.forceUpdate=function(c){this.updater.enqueueForceUpdate(this,c,"forceUpdate")};function Ye(){}Ye.prototype=A.prototype;function Pe(c,v,U){this.props=c,this.context=v,this.refs=E,this.updater=U||Ne}var tt=Pe.prototype=new Ye;tt.constructor=Pe,Le(tt,A.prototype),tt.isPureReactComponent=!0;var Se=Array.isArray,nt=Object.prototype.hasOwnProperty,ze={current:null},Oe={key:!0,ref:!0,__self:!0,__source:!0};function Xe(c,v,U){var B,Q={},K=null,b=null;if(v!=null)for(B in v.ref!==void 0&&(b=v.ref),v.key!==void 0&&(K=""+v.key),v)nt.call(v,B)&&!Oe.hasOwnProperty(B)&&(Q[B]=v[B]);var X=arguments.length-2;if(X===1)Q.children=U;else if(1>>1,v=C[c];if(0>>1;cD(Q,P))KD(b,Q)?(C[c]=b,C[K]=P,c=K):(C[c]=Q,C[B]=P,c=B);else if(KD(b,P))C[c]=b,C[K]=P,c=K;else break e}}return j}function D(C,j){var P=C.sortIndex-j.sortIndex;return P!==0?P:C.id-j.id}if(typeof performance=="object"&&typeof performance.now=="function"){var H=performance;k.unstable_now=function(){return H.now()}}else{var L=Date,$=L.now();k.unstable_now=function(){return L.now()-$}}var F=[],de=[],ae=1,J=null,q=3,Ne=!1,Le=!1,E=!1,A=typeof setTimeout=="function"?setTimeout:null,Ye=typeof clearTimeout=="function"?clearTimeout:null,Pe=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function tt(C){for(var j=p(de);j!==null;){if(j.callback===null)_(de);else if(j.startTime<=C)_(de),j.sortIndex=j.expirationTime,S(F,j);else break;j=p(de)}}function Se(C){if(E=!1,tt(C),!Le)if(p(F)!==null)Le=!0,Me(nt);else{var j=p(de);j!==null&&ie(Se,j.startTime-C)}}function nt(C,j){Le=!1,E&&(E=!1,Ye(Xe),Xe=-1),Ne=!0;var P=q;try{for(tt(j),J=p(F);J!==null&&(!(J.expirationTime>j)||C&&!Yt());){var c=J.callback;if(typeof c=="function"){J.callback=null,q=J.priorityLevel;var v=c(J.expirationTime<=j);j=k.unstable_now(),typeof v=="function"?J.callback=v:J===p(F)&&_(F),tt(j)}else _(F);J=p(F)}if(J!==null)var U=!0;else{var B=p(de);B!==null&&ie(Se,B.startTime-j),U=!1}return U}finally{J=null,q=P,Ne=!1}}var ze=!1,Oe=null,Xe=-1,Pt=5,gt=-1;function Yt(){return!(k.unstable_now()-gtC||125c?(C.sortIndex=P,S(de,C),p(F)===null&&C===p(de)&&(E?(Ye(Xe),Xe=-1):E=!0,ie(Se,P-c))):(C.sortIndex=v,S(F,C),Le||Ne||(Le=!0,Me(nt))),C},k.unstable_shouldYield=Yt,k.unstable_wrapCallback=function(C){var j=q;return function(){var P=q;q=j;try{return C.apply(this,arguments)}finally{q=P}}}})(To)),To}var Ra;function Vf(){return Ra||(Ra=1,zo.exports=Uf()),zo.exports}/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var La;function $f(){if(La)return Ue;La=1;var k=Ro(),S=Vf();function p(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),F=Object.prototype.hasOwnProperty,de=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ae={},J={};function q(e){return F.call(J,e)?!0:F.call(ae,e)?!1:de.test(e)?J[e]=!0:(ae[e]=!0,!1)}function Ne(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Le(e,t,n,r){if(t===null||typeof t>"u"||Ne(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function E(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var A={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){A[e]=new E(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];A[t]=new E(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){A[e]=new E(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){A[e]=new E(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){A[e]=new E(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){A[e]=new E(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){A[e]=new E(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){A[e]=new E(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){A[e]=new E(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ye=/[\-:]([a-z])/g;function Pe(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ye,Pe);A[t]=new E(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ye,Pe);A[t]=new E(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ye,Pe);A[t]=new E(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){A[e]=new E(e,1,!1,e.toLowerCase(),null,!1,!1)}),A.xlinkHref=new E("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){A[e]=new E(e,1,!1,e.toLowerCase(),null,!0,!0)});function tt(e,t,n,r){var l=A.hasOwnProperty(t)?A[t]:null;(l!==null?l.type!==0:r||!(2i||l[o]!==u[i]){var s=` -`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{U=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?v(e):""}function Q(e){switch(e.tag){case 5:return v(e.type);case 16:return v("Lazy");case 13:return v("Suspense");case 19:return v("SuspenseList");case 0:case 2:case 15:return e=B(e.type,!1),e;case 11:return e=B(e.type.render,!1),e;case 1:return e=B(e.type,!0),e;default:return""}}function K(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Oe:return"Fragment";case ze:return"Portal";case Pt:return"Profiler";case Xe:return"StrictMode";case Ve:return"Suspense";case rt:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Yt:return(e.displayName||"Context")+".Consumer";case gt:return(e._context.displayName||"Context")+".Provider";case ft:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case dt:return t=e.displayName||null,t!==null?t:K(e.type)||"Memo";case Me:t=e._payload,e=e._init;try{return K(e(t))}catch{}}return null}function b(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return K(t);case 8:return t===Xe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function X(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function re(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function $e(e){var t=re(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function wr(e){e._valueTracker||(e._valueTracker=$e(e))}function Lo(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=re(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function kr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Rl(e,t){var n=t.checked;return P({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Oo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=X(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Mo(e,t){t=t.checked,t!=null&&tt(e,"checked",t,!1)}function Ll(e,t){Mo(e,t);var n=X(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ol(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ol(e,t.type,X(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function jo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ol(e,t,n){(t!=="number"||kr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Mn=Array.isArray;function sn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Sr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function jn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var In={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Fa=["Webkit","ms","Moz","O"];Object.keys(In).forEach(function(e){Fa.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),In[t]=In[e]})});function Vo(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||In.hasOwnProperty(e)&&In[e]?(""+t).trim():t+"px"}function $o(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Vo(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Aa=P({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Il(e,t){if(t){if(Aa[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(p(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(p(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(p(61))}if(t.style!=null&&typeof t.style!="object")throw Error(p(62))}}function Dl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Fl=null;function Al(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ul=null,an=null,cn=null;function Bo(e){if(e=rr(e)){if(typeof Ul!="function")throw Error(p(280));var t=e.stateNode;t&&(t=Wr(t),Ul(e.stateNode,e.type,t))}}function Ho(e){an?cn?cn.push(e):cn=[e]:an=e}function Wo(){if(an){var e=an,t=cn;if(cn=an=null,Bo(e),t)for(e=0;e>>=0,e===0?32:31-(Ga(e)/Za|0)|0}var Nr=64,Pr=4194304;function Un(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function zr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=Un(i):(u&=o,u!==0&&(r=Un(u)))}else o=n&~l,o!==0?r=Un(o):u!==0&&(r=Un(u));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Vn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-lt(t),e[t]=n}function ec(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Xn),gi=" ",wi=!1;function ki(e,t){switch(e){case"keyup":return zc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Si(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var pn=!1;function Rc(e,t){switch(e){case"compositionend":return Si(t);case"keypress":return t.which!==32?null:(wi=!0,gi);case"textInput":return e=t.data,e===gi&&wi?null:e;default:return null}}function Lc(e,t){if(pn)return e==="compositionend"||!ru&&ki(e,t)?(e=di(),Mr=Jl=Ot=null,pn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=zi(n)}}function Ri(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ri(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Li(){for(var e=window,t=kr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=kr(e.document)}return t}function ou(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Vc(e){var t=Li(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ri(n.ownerDocument.documentElement,n)){if(r!==null&&ou(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=Ti(n,u);var o=Ti(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,mn=null,iu=null,qn=null,su=!1;function Oi(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;su||mn==null||mn!==kr(r)||(r=mn,"selectionStart"in r&&ou(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),qn&&Jn(qn,r)||(qn=r,r=$r(iu,"onSelect"),0wn||(e.current=ku[wn],ku[wn]=null,wn--)}function ee(e,t){wn++,ku[wn]=e.current,e.current=t}var Dt={},Ee=It(Dt),je=It(!1),Zt=Dt;function kn(e,t){var n=e.type.contextTypes;if(!n)return Dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ie(e){return e=e.childContextTypes,e!=null}function Qr(){ne(je),ne(Ee)}function Yi(e,t,n){if(Ee.current!==Dt)throw Error(p(168));ee(Ee,t),ee(je,n)}function Xi(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(p(108,b(e)||"Unknown",l));return P({},n,r)}function Kr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Dt,Zt=Ee.current,ee(Ee,e),ee(je,je.current),!0}function Gi(e,t,n){var r=e.stateNode;if(!r)throw Error(p(169));n?(e=Xi(e,t,Zt),r.__reactInternalMemoizedMergedChildContext=e,ne(je),ne(Ee),ee(Ee,e)):ne(je),ee(je,n)}var kt=null,Yr=!1,Su=!1;function Zi(e){kt===null?kt=[e]:kt.push(e)}function qc(e){Yr=!0,Zi(e)}function Ft(){if(!Su&&kt!==null){Su=!0;var e=0,t=G;try{var n=kt;for(G=1;e>=o,l-=o,St=1<<32-lt(t)+l|n<I?(ge=M,M=null):ge=M.sibling;var Y=h(f,M,d[I],w);if(Y===null){M===null&&(M=ge);break}e&&M&&Y.alternate===null&&t(f,M),a=u(Y,a,I),O===null?R=Y:O.sibling=Y,O=Y,M=ge}if(I===d.length)return n(f,M),le&&qt(f,I),R;if(M===null){for(;II?(ge=M,M=null):ge=M.sibling;var Kt=h(f,M,Y.value,w);if(Kt===null){M===null&&(M=ge);break}e&&M&&Kt.alternate===null&&t(f,M),a=u(Kt,a,I),O===null?R=Kt:O.sibling=Kt,O=Kt,M=ge}if(Y.done)return n(f,M),le&&qt(f,I),R;if(M===null){for(;!Y.done;I++,Y=d.next())Y=g(f,Y.value,w),Y!==null&&(a=u(Y,a,I),O===null?R=Y:O.sibling=Y,O=Y);return le&&qt(f,I),R}for(M=r(f,M);!Y.done;I++,Y=d.next())Y=x(M,f,I,Y.value,w),Y!==null&&(e&&Y.alternate!==null&&M.delete(Y.key===null?I:Y.key),a=u(Y,a,I),O===null?R=Y:O.sibling=Y,O=Y);return e&&M.forEach(function(Mf){return t(f,Mf)}),le&&qt(f,I),R}function fe(f,a,d,w){if(typeof d=="object"&&d!==null&&d.type===Oe&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case nt:e:{for(var R=d.key,O=a;O!==null;){if(O.key===R){if(R=d.type,R===Oe){if(O.tag===7){n(f,O.sibling),a=l(O,d.props.children),a.return=f,f=a;break e}}else if(O.elementType===R||typeof R=="object"&&R!==null&&R.$$typeof===Me&&ns(R)===O.type){n(f,O.sibling),a=l(O,d.props),a.ref=lr(f,O,d),a.return=f,f=a;break e}n(f,O);break}else t(f,O);O=O.sibling}d.type===Oe?(a=on(d.props.children,f.mode,w,d.key),a.return=f,f=a):(w=Sl(d.type,d.key,d.props,null,f.mode,w),w.ref=lr(f,a,d),w.return=f,f=w)}return o(f);case ze:e:{for(O=d.key;a!==null;){if(a.key===O)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=wo(d,f.mode,w),a.return=f,f=a}return o(f);case Me:return O=d._init,fe(f,a,O(d._payload),w)}if(Mn(d))return z(f,a,d,w);if(j(d))return T(f,a,d,w);Jr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=go(d,f.mode,w),a.return=f,f=a),o(f)):n(f,a)}return fe}var xn=rs(!0),ls=rs(!1),qr=It(null),br=null,_n=null,Pu=null;function zu(){Pu=_n=br=null}function Tu(e){var t=qr.current;ne(qr),e._currentValue=t}function Ru(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Nn(e,t){br=e,Pu=_n=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(De=!0),e.firstContext=null)}function Je(e){var t=e._currentValue;if(Pu!==e)if(e={context:e,memoizedValue:t,next:null},_n===null){if(br===null)throw Error(p(308));_n=e,br.dependencies={lanes:0,firstContext:e}}else _n=_n.next=e;return t}var bt=null;function Lu(e){bt===null?bt=[e]:bt.push(e)}function us(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Lu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ct(e,r)}function Ct(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var At=!1;function Ou(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function os(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function xt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Ut(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(W&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ct(e,n)}return l=r.interleaved,l===null?(t.next=t,Lu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ct(e,n)}function el(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Kl(e,n)}}function is(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function tl(e,t,n,r){var l=e.updateQueue;At=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,m=s.next;s.next=null,o===null?u=m:o.next=m,o=s;var y=e.alternate;y!==null&&(y=y.updateQueue,i=y.lastBaseUpdate,i!==o&&(i===null?y.firstBaseUpdate=m:i.next=m,y.lastBaseUpdate=s))}if(u!==null){var g=l.baseState;o=0,y=m=s=null,i=u;do{var h=i.lane,x=i.eventTime;if((r&h)===h){y!==null&&(y=y.next={eventTime:x,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var z=e,T=i;switch(h=t,x=n,T.tag){case 1:if(z=T.payload,typeof z=="function"){g=z.call(x,g,h);break e}g=z;break e;case 3:z.flags=z.flags&-65537|128;case 0:if(z=T.payload,h=typeof z=="function"?z.call(x,g,h):z,h==null)break e;g=P({},g,h);break e;case 2:At=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[i]:h.push(i))}else x={eventTime:x,lane:h,tag:i.tag,payload:i.payload,callback:i.callback,next:null},y===null?(m=y=x,s=g):y=y.next=x,o|=h;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;h=i,i=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(y===null&&(s=g),l.baseState=s,l.firstBaseUpdate=m,l.lastBaseUpdate=y,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);nn|=o,e.lanes=o,e.memoizedState=g}}function ss(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Fu.transition;Fu.transition={};try{e(!1),t()}finally{G=n,Fu.transition=r}}function Ps(){return qe().memoizedState}function nf(e,t,n){var r=Ht(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},zs(e))Ts(t,n);else if(n=us(e,t,n,r),n!==null){var l=Re();ct(n,e,r,l),Rs(n,t,r)}}function rf(e,t,n){var r=Ht(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(zs(e))Ts(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,ut(i,o)){var s=t.interleaved;s===null?(l.next=l,Lu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=us(e,t,l,r),n!==null&&(l=Re(),ct(n,e,r,l),Rs(n,t,r))}}function zs(e){var t=e.alternate;return e===oe||t!==null&&t===oe}function Ts(e,t){sr=ll=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Rs(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Kl(e,n)}}var il={readContext:Je,useCallback:Ce,useContext:Ce,useEffect:Ce,useImperativeHandle:Ce,useInsertionEffect:Ce,useLayoutEffect:Ce,useMemo:Ce,useReducer:Ce,useRef:Ce,useState:Ce,useDebugValue:Ce,useDeferredValue:Ce,useTransition:Ce,useMutableSource:Ce,useSyncExternalStore:Ce,useId:Ce,unstable_isNewReconciler:!1},lf={readContext:Je,useCallback:function(e,t){return vt().memoizedState=[e,t===void 0?null:t],e},useContext:Je,useEffect:ws,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,ul(4194308,4,Es.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ul(4194308,4,e,t)},useInsertionEffect:function(e,t){return ul(4,2,e,t)},useMemo:function(e,t){var n=vt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=vt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=nf.bind(null,oe,e),[r.memoizedState,e]},useRef:function(e){var t=vt();return e={current:e},t.memoizedState=e},useState:ys,useDebugValue:Wu,useDeferredValue:function(e){return vt().memoizedState=e},useTransition:function(){var e=ys(!1),t=e[0];return e=tf.bind(null,e[1]),vt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=oe,l=vt();if(le){if(n===void 0)throw Error(p(407));n=n()}else{if(n=t(),ye===null)throw Error(p(349));(tn&30)!==0||ds(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,ws(ms.bind(null,r,u,e),[e]),r.flags|=2048,fr(9,ps.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=vt(),t=ye.identifierPrefix;if(le){var n=Et,r=St;n=(r&~(1<<32-lt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=ar++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[mt]=t,e[nr]=r,Zs(e,t,!1,!1),t.stateNode=e;e:{switch(o=Dl(n,r),n){case"dialog":te("cancel",e),te("close",e),l=r;break;case"iframe":case"object":case"embed":te("load",e),l=r;break;case"video":case"audio":for(l=0;lLn&&(t.flags|=128,r=!0,dr(u,!1),t.lanes=4194304)}else{if(!r)if(e=nl(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),dr(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!le)return xe(t),null}else 2*ce()-u.renderingStartTime>Ln&&n!==1073741824&&(t.flags|=128,r=!0,dr(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=ce(),t.sibling=null,n=ue.current,ee(ue,r?n&1|2:n&1),t):(xe(t),null);case 22:case 23:return ho(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(Qe&1073741824)!==0&&(xe(t),t.subtreeFlags&6&&(t.flags|=8192)):xe(t),null;case 24:return null;case 25:return null}throw Error(p(156,t.tag))}function pf(e,t){switch(Cu(t),t.tag){case 1:return Ie(t.type)&&Qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Pn(),ne(je),ne(Ee),Du(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return ju(t),null;case 13:if(ne(ue),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(p(340));Cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ne(ue),null;case 4:return Pn(),null;case 10:return Tu(t.type._context),null;case 22:case 23:return ho(),null;case 24:return null;default:return null}}var fl=!1,_e=!1,mf=typeof WeakSet=="function"?WeakSet:Set,N=null;function Tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){se(e,t,r)}else n.current=null}function no(e,t,n){try{n()}catch(r){se(e,t,r)}}var bs=!1;function hf(e,t){if(mu=Lr,e=Li(),ou(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,m=0,y=0,g=e,h=null;t:for(;;){for(var x;g!==n||l!==0&&g.nodeType!==3||(i=o+l),g!==u||r!==0&&g.nodeType!==3||(s=o+r),g.nodeType===3&&(o+=g.nodeValue.length),(x=g.firstChild)!==null;)h=g,g=x;for(;;){if(g===e)break t;if(h===n&&++m===l&&(i=o),h===u&&++y===r&&(s=o),(x=g.nextSibling)!==null)break;g=h,h=g.parentNode}g=x}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(hu={focusedElem:e,selectionRange:n},Lr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var z=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(z!==null){var T=z.memoizedProps,fe=z.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?T:it(t.type,T),fe);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(p(163))}}catch(w){se(t,t.return,w)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return z=bs,bs=!1,z}function pr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&no(t,n,u)}l=l.next}while(l!==r)}}function dl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ro(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function ea(e){var t=e.alternate;t!==null&&(e.alternate=null,ea(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[mt],delete t[nr],delete t[wu],delete t[Zc],delete t[Jc])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ta(e){return e.tag===5||e.tag===3||e.tag===4}function na(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ta(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function lo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Hr));else if(r!==4&&(e=e.child,e!==null))for(lo(e,t,n),e=e.sibling;e!==null;)lo(e,t,n),e=e.sibling}function uo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(uo(e,t,n),e=e.sibling;e!==null;)uo(e,t,n),e=e.sibling}var we=null,st=!1;function Vt(e,t,n){for(n=n.child;n!==null;)ra(e,t,n),n=n.sibling}function ra(e,t,n){if(pt&&typeof pt.onCommitFiberUnmount=="function")try{pt.onCommitFiberUnmount(_r,n)}catch{}switch(n.tag){case 5:_e||Tn(n,t);case 6:var r=we,l=st;we=null,Vt(e,t,n),we=r,st=l,we!==null&&(st?(e=we,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):we.removeChild(n.stateNode));break;case 18:we!==null&&(st?(e=we,n=n.stateNode,e.nodeType===8?gu(e.parentNode,n):e.nodeType===1&&gu(e,n),Qn(e)):gu(we,n.stateNode));break;case 4:r=we,l=st,we=n.stateNode.containerInfo,st=!0,Vt(e,t,n),we=r,st=l;break;case 0:case 11:case 14:case 15:if(!_e&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&((u&2)!==0||(u&4)!==0)&&no(n,t,o),l=l.next}while(l!==r)}Vt(e,t,n);break;case 1:if(!_e&&(Tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){se(n,t,i)}Vt(e,t,n);break;case 21:Vt(e,t,n);break;case 22:n.mode&1?(_e=(r=_e)||n.memoizedState!==null,Vt(e,t,n),_e=r):Vt(e,t,n);break;default:Vt(e,t,n)}}function la(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new mf),t.forEach(function(r){var l=xf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function at(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=ce()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*yf(r/1960))-r,10e?16:e,Bt===null)var r=!1;else{if(e=Bt,Bt=null,yl=0,(W&6)!==0)throw Error(p(331));var l=W;for(W|=4,N=e.current;N!==null;){var u=N,o=u.child;if((N.flags&16)!==0){var i=u.deletions;if(i!==null){for(var s=0;sce()-so?ln(e,0):io|=n),Ae(e,t)}function ya(e,t){t===0&&((e.mode&1)===0?t=1:(t=Pr,Pr<<=1,(Pr&130023424)===0&&(Pr=4194304)));var n=Re();e=Ct(e,t),e!==null&&(Vn(e,t,n),Ae(e,n))}function Cf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ya(e,n)}function xf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(p(314))}r!==null&&r.delete(t),ya(e,n)}var ga;ga=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||je.current)De=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return De=!1,ff(e,t,n);De=(e.flags&131072)!==0}else De=!1,le&&(t.flags&1048576)!==0&&Ji(t,Gr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;cl(e,t),e=t.pendingProps;var l=kn(t,Ee.current);Nn(t,n),l=Uu(null,t,r,e,l,n);var u=Vu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ie(r)?(u=!0,Kr(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Ou(t),l.updater=sl,t.stateNode=l,l._reactInternals=t,Ku(t,r,e,n),t=Zu(null,t,r,!0,u,n)):(t.tag=0,le&&u&&Eu(t),Te(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(cl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Nf(r),e=it(r,e),l){case 0:t=Gu(null,t,r,e,n);break e;case 1:t=Ws(null,t,r,e,n);break e;case 11:t=Us(null,t,r,e,n);break e;case 14:t=Vs(null,t,r,it(r.type,e),n);break e}throw Error(p(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:it(r,l),Gu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:it(r,l),Ws(e,t,r,l,n);case 3:e:{if(Qs(t),e===null)throw Error(p(387));r=t.pendingProps,u=t.memoizedState,l=u.element,os(e,t),tl(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=zn(Error(p(423)),t),t=Ks(e,t,r,n,l);break e}else if(r!==l){l=zn(Error(p(424)),t),t=Ks(e,t,r,n,l);break e}else for(We=jt(t.stateNode.containerInfo.firstChild),He=t,le=!0,ot=null,n=ls(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Cn(),r===l){t=_t(e,t,n);break e}Te(e,t,r,n)}t=t.child}return t;case 5:return as(t),e===null&&_u(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,vu(r,l)?o=null:u!==null&&vu(r,u)&&(t.flags|=32),Hs(e,t),Te(e,t,o,n),t.child;case 6:return e===null&&_u(t),null;case 13:return Ys(e,t,n);case 4:return Mu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=xn(t,null,r,n):Te(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:it(r,l),Us(e,t,r,l,n);case 7:return Te(e,t,t.pendingProps,n),t.child;case 8:return Te(e,t,t.pendingProps.children,n),t.child;case 12:return Te(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,ee(qr,r._currentValue),r._currentValue=o,u!==null)if(ut(u.value,o)){if(u.children===l.children&&!je.current){t=_t(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=xt(-1,n&-n),s.tag=2;var m=u.updateQueue;if(m!==null){m=m.shared;var y=m.pending;y===null?s.next=s:(s.next=y.next,y.next=s),m.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),Ru(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(p(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),Ru(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}Te(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Nn(t,n),l=Je(l),r=r(l),t.flags|=1,Te(e,t,r,n),t.child;case 14:return r=t.type,l=it(r,t.pendingProps),l=it(r.type,l),Vs(e,t,r,l,n);case 15:return $s(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:it(r,l),cl(e,t),t.tag=1,Ie(r)?(e=!0,Kr(t)):e=!1,Nn(t,n),Os(t,r,l),Ku(t,r,l,n),Zu(null,t,r,!0,e,n);case 19:return Gs(e,t,n);case 22:return Bs(e,t,n)}throw Error(p(156,t.tag))};function wa(e,t){return qo(e,t)}function _f(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function et(e,t,n,r){return new _f(e,t,n,r)}function yo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Nf(e){if(typeof e=="function")return yo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ft)return 11;if(e===dt)return 14}return 2}function Qt(e,t){var n=e.alternate;return n===null?(n=et(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Sl(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")yo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Oe:return on(n.children,l,u,t);case Xe:o=8,l|=8;break;case Pt:return e=et(12,n,t,l|2),e.elementType=Pt,e.lanes=u,e;case Ve:return e=et(13,n,t,l),e.elementType=Ve,e.lanes=u,e;case rt:return e=et(19,n,t,l),e.elementType=rt,e.lanes=u,e;case ie:return El(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case gt:o=10;break e;case Yt:o=9;break e;case ft:o=11;break e;case dt:o=14;break e;case Me:o=16,r=null;break e}throw Error(p(130,e==null?e:typeof e,""))}return t=et(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function on(e,t,n,r){return e=et(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=et(22,e,r,t),e.elementType=ie,e.lanes=n,e.stateNode={isHidden:!1},e}function go(e,t,n){return e=et(6,e,null,t),e.lanes=n,e}function wo(e,t,n){return t=et(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Pf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ql(0),this.expirationTimes=Ql(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ql(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ko(e,t,n,r,l,u,o,i,s){return e=new Pf(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=et(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ou(u),e}function zf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(k)}catch(S){console.error(S)}}return k(),Po.exports=$f(),Po.exports}var Ma;function Hf(){if(Ma)return Tl;Ma=1;var k=Bf();return Tl.createRoot=k.createRoot,Tl.hydrateRoot=k.hydrateRoot,Tl}var Wf=Hf();const Da={schemaVersion:1,minCompatibleSchemaVersion:1,revision:0,pinsChecked:!1,modeOptions:[],currentMode:""};function Qf(k=window){var p;const S=k;return!!(S.QWebChannel&&((p=S.qt)!=null&&p.webChannelTransport))}function Kf(k,S=window){var _;const p=S;!p.QWebChannel||!((_=p.qt)!=null&&_.webChannelTransport)||new p.QWebChannel(p.qt.webChannelTransport,D=>{k(D.objects)})}function Yf(k){return typeof k=="object"&&k!==null&&!Array.isArray(k)}function Xf(k,S,p){if(!Yf(k)){p.push(`${S}: expected object`);return}{const _=k.schemaVersion;_==null?p.push(`${S}.schemaVersion: missing required field`):typeof _!="number"&&p.push(`${S}.schemaVersion: expected number`)}{const _=k.revision;_==null?p.push(`${S}.revision: missing required field`):typeof _!="number"&&p.push(`${S}.revision: expected number`)}{const _=k.pinsChecked;_==null?p.push(`${S}.pinsChecked: missing required field`):typeof _!="boolean"&&p.push(`${S}.pinsChecked: expected boolean`)}{const _=k.modeOptions;_==null?p.push(`${S}.modeOptions: missing required field`):Array.isArray(_)?_.forEach((D,H)=>{typeof D!="string"&&p.push(`${S}.modeOptions[${H}]: expected string`)}):p.push(`${S}.modeOptions: expected array`)}{const _=k.currentMode;_==null?p.push(`${S}.currentMode: missing required field`):typeof _!="string"&&p.push(`${S}.currentMode: expected string`)}{const _=k.minCompatibleSchemaVersion;_!=null&&typeof _!="number"&&p.push(`${S}.minCompatibleSchemaVersion: expected number`)}}function Gf(k){const S=[];return Xf(k,"$",S),S.length===0?{ok:!0,value:k}:{ok:!1,errors:S}}const ja=1,Ia=1;function Zf(k){if(typeof k!="object"||k===null)return{compatible:!1,reason:"The update from the desktop app was not an object."};const S=k,p=S.schemaVersion;if(typeof p!="number"||!Number.isFinite(p))return{compatible:!1,reason:"The update from the desktop app did not declare a schema version."};if(p{const F=qf($);if(F.ok){k(F.state),S==null||S(null);return}console.error(`[toolbar bridge] rejected payload (${F.rejection.kind}): ${F.rejection.reason}`,F.rejection.details),S==null||S(F.rejection)};Kf($=>{_=$.toolbarBridge,_.stateChanged.connect(H),D=!0,_.ready()});const L=($,...F)=>{D&&_&&typeof _[$]=="function"&&_[$](...F)};return{ready:()=>L("ready"),reportAnchorRect:($,F,de,ae,J)=>L("reportAnchorRect",$,F,de,ae,J),openLibrary:()=>L("openLibrary"),saveChat:()=>L("saveChat"),togglePins:()=>L("togglePins"),organizeNodes:()=>L("organizeNodes"),zoomIn:()=>L("zoomIn"),zoomOut:()=>L("zoomOut"),resetZoom:()=>L("resetZoom"),fitAll:()=>L("fitAll"),toggleControls:$=>L("toggleControls",$),togglePlugins:()=>L("togglePlugins"),selectMode:$=>L("selectMode",$),openSettings:()=>L("openSettings"),openAbout:()=>L("openAbout"),openHelp:()=>L("openHelp"),dispose:()=>{var $,F;(F=_==null?void 0:($=_.stateChanged).disconnect)==null||F.call($,H),_=null}}}function td({title:k,rejection:S,className:p}){return Z.jsxs("div",{className:p,role:"alert","aria-live":"assertive",children:[Z.jsx("div",{className:"bridge-error-title",children:k}),Z.jsx("p",{className:"bridge-error-reason",children:S.reason}),S.details.length>0&&Z.jsx("ul",{className:"bridge-error-details",children:S.details.map(_=>Z.jsx("li",{children:_},_))}),Z.jsx("p",{className:"bridge-error-hint",children:S.kind==="version"?"Rebuilding the app's interface assets usually resolves this.":"This is a bug - the desktop app and this interface disagree about the message format."})]})}const nd=["pins","plugins","settings","help"];function rd(){const[k,S]=Ke.useState(Da),[p,_]=Ke.useState(null),[D,H]=Ke.useState(!1),L=Ke.useRef(null),$=Ke.useRef(null),F=Ke.useRef(null),de=Ke.useRef(null),ae=Ke.useRef(null),J=Ke.useRef(null),q={pins:F,plugins:de,settings:ae,help:J};Ke.useEffect(()=>{const E=ed(S,_);return L.current=E,E.ready(),()=>{E.dispose(),L.current=null}},[]);function Ne(){var E;for(const A of nd){const Ye=q[A].current;if(!Ye)continue;const Pe=Ye.getBoundingClientRect();(E=L.current)==null||E.reportAnchorRect(A,Math.round(Pe.left),Math.round(Pe.top),Math.round(Pe.width),Math.round(Pe.height))}}if(Ke.useEffect(()=>{const E=$.current;if(!E)return;const A=new ResizeObserver(Ne);return A.observe(E),()=>A.disconnect()},[]),Ke.useEffect(Ne,[k.revision]),p)return Z.jsx(td,{title:"The toolbar is unavailable",rejection:p,className:"toolbar-shell bridge-error"});function Le(){var A;const E=!D;H(E),(A=L.current)==null||A.toggleControls(E)}return Z.jsxs("div",{className:"toolbar-shell",ref:$,children:[Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.openLibrary()},children:"Library"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.saveChat()},children:"Save"}),Z.jsx("button",{type:"button",ref:F,className:"toolbar-btn toolbar-btn-checkable"+(k.pinsChecked?" checked":""),"aria-pressed":k.pinsChecked,title:"Show navigation pins",onClick:()=>{var E;return(E=L.current)==null?void 0:E.togglePins()},children:"Pins"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.organizeNodes()},children:"Organize"}),Z.jsx("span",{className:"toolbar-separator"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.zoomIn()},children:"Zoom In"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.zoomOut()},children:"Zoom Out"}),Z.jsx("span",{className:"toolbar-separator"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.resetZoom()},children:"Reset"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.fitAll()},children:"Fit All"}),Z.jsx("button",{type:"button",className:"toolbar-btn toolbar-btn-checkable"+(D?" checked":""),"aria-pressed":D,onClick:Le,children:"Controls"}),Z.jsxs("button",{type:"button",ref:de,className:"toolbar-btn toolbar-btn-plugins",onClick:()=>{var E;return(E=L.current)==null?void 0:E.togglePlugins()},children:["Plugins ",Z.jsx("span",{className:"toolbar-chevron",children:"▾"})]}),Z.jsx("span",{className:"toolbar-spacer"}),Z.jsx("select",{className:"toolbar-mode-select",value:k.currentMode,"aria-label":"Provider mode",onChange:E=>{var A;return(A=L.current)==null?void 0:A.selectMode(E.target.value)},children:k.modeOptions.map(E=>Z.jsx("option",{value:E,children:E},E))}),Z.jsx("button",{type:"button",ref:ae,className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.openSettings()},children:"Settings"}),Z.jsx("button",{type:"button",className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.openAbout()},children:"About"}),Z.jsx("button",{type:"button",ref:J,className:"toolbar-btn",onClick:()=>{var E;return(E=L.current)==null?void 0:E.openHelp()},children:"Help"})]})}Wf.createRoot(document.getElementById("root")).render(Z.jsx(Ke.StrictMode,{children:Z.jsx(rd,{})})); diff --git a/assets/toolbar/index.html b/assets/toolbar/index.html index c2f759c..8efb165 100644 --- a/assets/toolbar/index.html +++ b/assets/toolbar/index.html @@ -5,7 +5,7 @@ Graphlink Toolbar - + diff --git a/graphlink_app/graphlink_dialog_frame.py b/graphlink_app/graphlink_dialog_frame.py new file mode 100644 index 0000000..bd2c652 --- /dev/null +++ b/graphlink_app/graphlink_dialog_frame.py @@ -0,0 +1,106 @@ +"""UI-refactor P1: the shared native dialog shell (title + close + content). + +Audit finding B5: Settings shipped with NO close affordance and no title. +The P1 policy is "mandatory close button + title on dialogs" - this frame is +that policy as a widget. Library already had its own hand-built shell; +Settings (and any future embedded dialog) wraps its content in this instead +of inventing another one-off. + +Token-styled throughout (P0): every color/radius/size comes from +get_surface_color()/RADIUS_PX/TEXT_PX/SPACE_PX/ELEVATION_PARAMS - no +literals. Elevation level 3 matches the dialog tier of the shadow scale. +""" + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) + +from graphlink_config import get_surface_color +from graphlink_styles import ELEVATION_PARAMS, RADIUS_PX, SPACE_PX, TEXT_PX + + +class DialogFrame(QFrame): + close_requested = Signal() + + def __init__(self, title, content_widget, parent=None): + super().__init__(parent) + self.setObjectName("glDialogFrame") + + # NO QGraphicsDropShadowEffect here, deliberately: a QGraphicsEffect + # on an ancestor forces the whole subtree through a software render + # path that QWebEngineView (GPU-composited) cannot join - the web + # content renders as a SOLID BLACK RECTANGLE. Found live in the P1 + # drive; the same effect on ChatLibraryDialog's shell was the entire + # cause of the audit's "library body is a black void" finding (B7). + # Elevation for webview-bearing dialogs comes from the scrim contrast + # + border until a compositor-safe shadow technique lands (P9). + + layout = QVBoxLayout(self) + margin = SPACE_PX[4] + layout.setContentsMargins(margin, SPACE_PX[3], margin, margin) + layout.setSpacing(SPACE_PX[3]) + + header = QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.setSpacing(SPACE_PX[2]) + + self.title_label = QLabel(title) + self.title_label.setObjectName("glDialogTitle") + header.addWidget(self.title_label) + header.addStretch() + + self.close_button = QPushButton("✕") + self.close_button.setObjectName("glDialogClose") + self.close_button.setFixedSize(SPACE_PX[6] + SPACE_PX[1], SPACE_PX[6] + SPACE_PX[1]) + self.close_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.close_button.clicked.connect(self.close_requested.emit) + header.addWidget(self.close_button) + + layout.addLayout(header) + layout.addWidget(content_widget, 1) + + self.setStyleSheet(f""" + QFrame#glDialogFrame {{ + background-color: {get_surface_color("node_body")}; + border: 1px solid {get_surface_color("border")}; + border-radius: {RADIUS_PX["lg"]}px; + }} + QLabel#glDialogTitle {{ + color: {get_surface_color("text_strong")}; + font-size: {TEXT_PX["lg"]}px; + font-weight: 600; + background: transparent; + }} + QPushButton#glDialogClose {{ + background-color: transparent; + color: {get_surface_color("text_label")}; + border: none; + border-radius: {RADIUS_PX["sm"]}px; + font-size: {TEXT_PX["base"]}px; + }} + QPushButton#glDialogClose:hover {{ + background-color: {get_surface_color("border")}; + color: {get_surface_color("text_strong")}; + }} + """) + + def center_in_parent(self): + """Center inside the parent widget and clamp so no pixel leaves it + (audit B4: Settings used to overflow the window onto the desktop).""" + parent = self.parentWidget() + if parent is None: + return + margin = SPACE_PX[4] + width = min(self.width(), parent.width() - 2 * margin) + height = min(self.height(), parent.height() - 2 * margin) + self.resize(max(width, 0), max(height, 0)) + x = (parent.width() - self.width()) // 2 + y = (parent.height() - self.height()) // 2 + self.move(max(margin, x), max(margin, y)) diff --git a/graphlink_app/graphlink_overlay_manager.py b/graphlink_app/graphlink_overlay_manager.py new file mode 100644 index 0000000..7dcb53b --- /dev/null +++ b/graphlink_app/graphlink_overlay_manager.py @@ -0,0 +1,247 @@ +"""UI-refactor P1 (doc/UI_QA_AUDIT.md section 7): the one owner of every +transient surface's OPEN/DISMISS lifecycle. + +The audit's section-2 findings were a class, not incidents: popups stacked +(B1) because each surface managed only itself; Escape closed nothing (B2) +because no one owned it (and webview focus swallows key events aimed at +QShortcuts); Settings escaped the main window and z-ordered under Library +(B4) because it was a screen-clamped top-level Tool window; chips desynced +(B6) because the toolbar island latched click-state locally. + +OverlayManager fixes the class: +- one registry of named surfaces, two tiers: POPOVER (anchored, light) and + DIALOG (centered, scrimmed); +- single-open per tier + dialogs close popovers: opening anything closes + whatever else is open (the recorded policy - simplest model that makes + B1 impossible; no surface today needs to coexist with another); +- one QApplication event filter: Escape closes the top surface wherever + focus lives (verified against webview focus in the P1 drive test), and a + mouse press outside an open popover dismisses it (dialogs dismiss via + their close button / Escape / scrim click, not stray outside clicks - + the legacy SettingsDialog's stay-open-during-scans rationale, kept); +- a shared scrim widget under dialogs: modality the user can see, a + guaranteed z-order (scrim just under dialog, both above everything + registered with OverlayCoordinator), and click-to-dismiss; +- a visibility_changed(name, bool) signal the window binds to the toolbar + payload, so chip active-states reflect REAL visibility, never latched + click-state. + +Positioning stays with each surface (popovers keep their show_for_anchor/ +reposition functions; dialogs center-and-clamp inside the window via +reposition_dialogs(), called from the window's resize path) - this class +owns lifecycle, OverlayCoordinator keeps owning the embedded-overlay +reposition/raise pass it already owns. Complementary, not overlapping. + +This is deliberately not a QWidget (except the scrim): like +OverlayCoordinator, it holds behavior, not chrome. +""" + +from __future__ import annotations + +from PySide6.QtCore import QEvent, QObject, Qt, Signal +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import QApplication, QWidget + +POPOVER = "popover" +DIALOG = "dialog" + + +class DialogScrim(QWidget): + """Semi-transparent full-window layer shown under an open dialog. + + Absorbs every mouse press (modality) and reports clicks so the manager + can close the dialog. Painted, not stylesheet'd: a plain translucent + fill with no child machinery. + """ + + def __init__(self, parent, on_pressed): + super().__init__(parent) + self._on_pressed = on_pressed + self.setVisible(False) + # Alpha tuned to read as "background demoted", not "blackout". + self._fill = QColor(0, 0, 0, 110) + + def paintEvent(self, event): + painter = QPainter(self) + painter.fillRect(self.rect(), self._fill) + + def mousePressEvent(self, event): + event.accept() + self._on_pressed() + + def resize_to_parent(self): + parent = self.parentWidget() + if parent is not None: + self.setGeometry(parent.rect()) + + +class _Surface: + __slots__ = ("name", "tier", "widget_fn", "open_fn", "close_fn", "is_open_fn") + + def __init__(self, name, tier, widget_fn, open_fn, close_fn, is_open_fn): + self.name = name + self.tier = tier + self.widget_fn = widget_fn # () -> QWidget | None (for hit-testing/z) + self.open_fn = open_fn # () -> None (position + show) + self.close_fn = close_fn # () -> None (hide) + self.is_open_fn = is_open_fn # () -> bool + + +class OverlayManager(QObject): + visibility_changed = Signal(str, bool) + + def __init__(self, window): + super().__init__(window) + self._window = window + self._surfaces: dict[str, _Surface] = {} + self._open_name: str | None = None + self._scrim = DialogScrim(window, self._on_scrim_pressed) + QApplication.instance().installEventFilter(self) + + # -- registration ------------------------------------------------------ + + def register(self, name, tier, *, widget_fn, open_fn, close_fn, is_open_fn): + assert tier in (POPOVER, DIALOG) + assert name not in self._surfaces, f"surface {name!r} registered twice" + self._surfaces[name] = _Surface(name, tier, widget_fn, open_fn, close_fn, is_open_fn) + # Hosts exposing escape_pressed (WebIslandHost's widget-scoped Escape + # shortcut - fires even when Chromium owns keyboard focus) dismiss + # through the manager like any other Escape. + widget = widget_fn() + signal = getattr(widget, "escape_pressed", None) + if signal is not None: + signal.connect(self.close_all) + + # -- core lifecycle ---------------------------------------------------- + + def toggle(self, name): + if self.is_open(name): + self.close(name) + else: + self.open(name) + + def open(self, name): + surface = self._surfaces[name] + # Single-open policy across BOTH tiers: opening anything closes + # whatever else is open first (audit B1). + if self._open_name is not None and self._open_name != name: + self.close(self._open_name) + if surface.tier == DIALOG: + self._scrim.resize_to_parent() + self._scrim.setVisible(True) + self._scrim.raise_() + surface.open_fn() + widget = surface.widget_fn() + if widget is not None: + widget.raise_() + # Lazy surfaces register with widget_fn returning None until + # first open - hook escape_pressed on first sight, INCLUDING + # descendants: a dialog's Escape source is usually the embedded + # WebIslandHost inside it (its widget-scoped shortcut fires while + # Chromium owns the keyboard focus), not the outer shell. + for candidate in (widget, *widget.findChildren(QWidget)): + signal = getattr(candidate, "escape_pressed", None) + if signal is not None and not getattr(candidate, "_gl_escape_hooked", False): + signal.connect(self.close_all) + candidate._gl_escape_hooked = True + # No focus-steal here (an earlier draft did): hosts opting into + # dismiss_on_outside_focus would see the focus move and immediately + # close themselves. Escape coverage comes from the app filter plus + # each WebIslandHost's widget-scoped Escape shortcut instead. + self._open_name = name + self.visibility_changed.emit(name, True) + + def close(self, name): + surface = self._surfaces.get(name) + if surface is None: + return + # Capture BEFORE close_fn(): hiding a widget delivers ShowEvent/ + # HideEvent SYNCHRONOUSLY through this manager's own application + # event filter, so any state read after close_fn() can observe a + # mid-close world (found by the P1 acceptance tests - the scrim + # stayed up because _open_name had been re-derived to None by the + # time the tier check ran). + was_open = surface.is_open_fn() or self._open_name == name + was_current = self._open_name == name + surface.close_fn() + if surface.tier == DIALOG and was_current: + self._scrim.setVisible(False) + if self._open_name == name: + self._open_name = None + if was_open: + self.visibility_changed.emit(name, False) + + def close_all(self): + if self._open_name is not None: + self.close(self._open_name) + + def is_open(self, name): + surface = self._surfaces.get(name) + return surface is not None and surface.is_open_fn() + + def open_surface_name(self): + # Re-derive from real visibility, never trust the cached name alone: + # a surface hidden behind the manager's back (e.g. its own legacy + # close path) must not leave the manager believing it is open. + if self._open_name is not None and self.is_open(self._open_name): + return self._open_name + return None + + # -- window integration ------------------------------------------------ + + def reposition_for_resize(self): + """Called from the window's resize path: keep the scrim full-window + and let the open dialog re-clamp itself (its open_fn re-centers).""" + name = self.open_surface_name() + self._scrim.resize_to_parent() + if name is not None and self._surfaces[name].tier == DIALOG: + self._surfaces[name].open_fn() + widget = self._surfaces[name].widget_fn() + if widget is not None: + widget.raise_() + + def _on_scrim_pressed(self): + name = self.open_surface_name() + if name is not None: + self.close(name) + + # -- global dismissal (audit B2 + popover outside-click) --------------- + + def eventFilter(self, watched, event): + # NO state mutation here: this filter runs synchronously inside + # show/hide calls made by open()/close() themselves, so writing + # _open_name from here races the very methods that own it (the P1 + # acceptance tests caught exactly that). open_surface_name() already + # re-derives from real visibility for reads. + name = self.open_surface_name() + if name is None: + return False + + if ( + event.type() in (QEvent.Type.KeyPress, QEvent.Type.ShortcutOverride) + and event.key() == Qt.Key.Key_Escape + ): + # ShortcutOverride included deliberately: with keyboard focus + # inside a QWebEngineView, Chromium ACCEPTS the override and the + # KeyPress never reaches Qt shortcuts or this filter - but the + # override event itself passes through application filters + # first. Consuming it here is the only reliable Escape path over + # web content (verified live; the widget-scoped QShortcut and + # plain KeyPress handling cover every native-focus case). + self.close(name) + event.accept() + return True + + if event.type() == QEvent.Type.MouseButtonPress: + surface = self._surfaces[name] + if surface.tier == POPOVER: + widget = surface.widget_fn() + if widget is not None and widget.isVisible(): + global_pos = event.globalPosition().toPoint() + inside = widget.rect().contains(widget.mapFromGlobal(global_pos)) + if not inside: + self.close(name) + # Do NOT consume: the click still lands (canvas + # click both dismisses and acts - the standard + # light-dismiss contract). + return False diff --git a/graphlink_app/graphlink_settings_web.py b/graphlink_app/graphlink_settings_web.py index bf473c5..67b868a 100644 --- a/graphlink_app/graphlink_settings_web.py +++ b/graphlink_app/graphlink_settings_web.py @@ -51,12 +51,15 @@ def __init__(self, settings_manager, main_window=None, parent=None): unavailable_message=SETTINGS_UNAVAILABLE_MESSAGE, parent=parent, ) - # A persistent tool window, not a transient popup - same rationale - # the legacy SettingsDialog recorded: stays open during scans, - # message boxes, and incidental outside clicks. - self.setWindowFlags( - Qt.WindowType.Tool | Qt.WindowType.FramelessWindowHint | Qt.WindowType.NoDropShadowWindowHint - ) + # UI-refactor P1 (audit B4/B5): no longer a top-level Tool window + # positioned/clamped against the SCREEN - that let it hang past the + # main window's edge onto the desktop, z-order under other dialogs, + # and ship without any close affordance. It is now a plain child + # widget embedded in a DialogFrame (title + close button) that + # OverlayManager centers, clamps INSIDE the window, and scrims. + # The legacy "stays open during scans" rationale is preserved by + # the manager's dialog policy: dialogs ignore incidental outside + # clicks (only scrim click / close button / Escape dismiss). self.resize(SETTINGS_WIDTH, SETTINGS_HEIGHT) def set_current_section_by_mode(self, mode_text: str) -> None: @@ -68,28 +71,9 @@ def set_current_section_by_mode(self, mode_text: str) -> None: section = mode_text if mode_text in SECTION_NAMES else "General" self.bridge.set_active_section(section) - def show_for_anchor(self, anchor_widget) -> None: - self.resize(SETTINGS_WIDTH, SETTINGS_HEIGHT) - - target_global = anchor_widget.mapToGlobal( - QPoint(anchor_widget.width() - self.width(), anchor_widget.height() + 6) - ) - screen = QGuiApplication.screenAt(target_global) or QGuiApplication.primaryScreen() - available_geometry = screen.availableGeometry() if screen else None - - x = target_global.x() - y = target_global.y() - - if available_geometry is not None: - max_x = available_geometry.right() - self.width() - 12 - max_y = available_geometry.bottom() - self.height() - 12 - x = max(available_geometry.left() + 12, min(x, max_x)) - y = max(available_geometry.top() + 12, min(y, max_y)) - - self.move(x, y) - self.show() - self.raise_() - self.activateWindow() + # P1: show_for_anchor (screen-coordinate positioning) deleted - the host + # is embedded in a DialogFrame that OverlayManager centers and clamps + # inside the main window. Positioning is no longer this class's job. def _iter_running_workers(self): bridge = self.bridge diff --git a/graphlink_app/graphlink_toolbar_bridge.py b/graphlink_app/graphlink_toolbar_bridge.py index 48bf54a..2c31a5e 100644 --- a/graphlink_app/graphlink_toolbar_bridge.py +++ b/graphlink_app/graphlink_toolbar_bridge.py @@ -75,8 +75,11 @@ def get_anchor(self, name: str): return self._anchors.get(name, self.window) def _build_state_payload(self) -> dict[str, Any]: + manager = getattr(self.window, "overlay_manager", None) + active = manager.open_surface_name() if manager is not None else None return { "pinsChecked": self.window.pin_overlay.isVisible(), + "activeSurface": active or "", "modeOptions": MODE_OPTIONS, "currentMode": self.window.settings_manager.get_current_mode(), } @@ -128,7 +131,9 @@ def fitAll(self): @Slot(bool) def toggleControls(self, visible: bool): - self.window.chat_view.toggle_overlays_visibility(bool(visible)) + # P1: the island bool (its old latched state) is ignored - the + # OverlayManager owns open/close truth and republishes chip state. + self.window.overlay_manager.toggle("controls") @Slot() def togglePlugins(self): diff --git a/graphlink_app/graphlink_toolbar_payload.py b/graphlink_app/graphlink_toolbar_payload.py index 59fd8a7..623f095 100644 --- a/graphlink_app/graphlink_toolbar_payload.py +++ b/graphlink_app/graphlink_toolbar_payload.py @@ -34,6 +34,10 @@ class ToolbarStatePayload: schemaVersion: int revision: int pinsChecked: bool + # UI-refactor P1 (audit B6): the name of the surface currently open + # ("" when none) - chips render active from THIS, never from island-local + # latched click state. + activeSurface: str modeOptions: list[str] currentMode: str # See ComposerStatePayload's identical field for the full negotiation diff --git a/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py b/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py index f4cbd3a..9d87272 100644 --- a/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py +++ b/graphlink_app/graphlink_ui_dialogs/graphlink_library_dialog.py @@ -35,13 +35,15 @@ def __init__(self, session_manager, parent=None): self.setObjectName("libraryWindow") self.setWindowTitle("Chat Library") - self.setWindowFlags( - Qt.WindowType.Dialog - | Qt.WindowType.FramelessWindowHint - | Qt.WindowType.NoDropShadowWindowHint - | Qt.WindowType.WindowStaysOnTopHint - ) - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + # UI-refactor P1 (audit B3/B4): no longer a fresh top-level frameless + # stay-on-top window per open (whose first-show race was the + # first-click no-op) - a plain embedded child widget, cached once, + # opened/scrimmed/clamped by OverlayManager. WA_DeleteOnClose dropped + # for the same reason: the instance persists. + # QDialog sets the Dialog WINDOW flag by default even with a parent - + # without this explicit Widget flag it still opens as a top-level + # window (translucent + frameless-stripped = an INVISIBLE one). + self.setWindowFlags(Qt.WindowType.Widget) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) self.setModal(False) @@ -51,14 +53,15 @@ def __init__(self, session_manager, parent=None): outer_layout.setContentsMargins(12, 12, 12, 14) outer_layout.setSpacing(0) - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(24) - shadow.setOffset(0, 10) - shadow.setColor(Qt.GlobalColor.black) - + # UI-refactor P1: the QGraphicsDropShadowEffect that used to sit on + # this shell WAS audit finding B7 - the "library body is a black + # void". A QGraphicsEffect on an ancestor forces software rendering + # that the GPU-composited ChatLibraryWebHost inside cannot join, so + # the entire web region painted as a solid black rectangle while the + # native header rendered fine. No ancestor of a webview may carry a + # QGraphicsEffect, ever. self.shell = QFrame() self.shell.setObjectName("libraryShell") - self.shell.setGraphicsEffect(shadow) outer_layout.addWidget(self.shell) root_layout = QVBoxLayout(self.shell) @@ -119,26 +122,24 @@ def __init__(self, session_manager, parent=None): self.on_theme_changed() def _position_window(self): + # P1: parent-RELATIVE centering with a clamp inside the parent rect + # (this is a plain child widget now - audit B4's "no dialog pixel + # outside the window" applies here too). parent = self.parentWidget() - if parent is not None and parent.isVisible(): - parent_geometry = parent.frameGeometry() - target = parent_geometry.center() - self.rect().center() - self.move(target) + if parent is None: return - - screen = QGuiApplication.primaryScreen() - available_geometry = screen.availableGeometry() if screen else None - if available_geometry is None: - return - - target = available_geometry.center() - self.rect().center() - self.move(target) + margin = 16 + width = min(self.width(), parent.width() - 2 * margin) + height = min(self.height(), parent.height() - 2 * margin) + self.resize(max(width, 0), max(height, 0)) + x = (parent.width() - self.width()) // 2 + y = (parent.height() - self.height()) // 2 + self.move(max(margin, x), max(margin, y)) def show_centered(self): self._position_window() self.show() self.raise_() - self.activateWindow() # Give keyboard focus to the web content so its search input (which # autofocuses on mount) can receive typing immediately - the web # equivalent of the legacy search_input.setFocus(). diff --git a/graphlink_app/graphlink_web_island_host.py b/graphlink_app/graphlink_web_island_host.py index f6aad32..2ac2d2f 100644 --- a/graphlink_app/graphlink_web_island_host.py +++ b/graphlink_app/graphlink_web_island_host.py @@ -184,7 +184,21 @@ def replace_script(match: re.Match[str]) -> str: 'connect-src \'none\';">' ) theme_root_style = f"" - document = document.replace("", f"{csp}{theme_root_style}", 1) + # UI-refactor P1: the Escape relay. QtWebEngine delivers key input + # straight into the render process for the focused web content - the + # QApplication event filter, application QShortcuts, and even + # ShortcutOverride interception never see Escape while an island owns + # the keyboard (all three verified live). The page itself is therefore + # the only reliable observer; it signals the host through titleChanged, + # the one always-available page->host channel that needs no bridge + # schema, no per-island code, and no rebuild. Capture-phase listener so + # island code cannot swallow it first. + escape_relay = ( + "" + ) + document = document.replace("", f"{csp}{theme_root_style}{escape_relay}", 1) channel_script = '' document = document.replace("", f"{channel_script}", 1) return document @@ -410,6 +424,8 @@ class WebIslandHost(QFrame): widget - native or another QWebEngineView - focus lands on. """ + escape_pressed = Signal() + heightChanged = Signal(int) textFocusChanged = Signal(bool) @@ -489,6 +505,7 @@ def __init__( channel.registerObject("islandHost", self) self.web_view.page().setWebChannel(channel) self.web_view.loadFinished.connect(self._on_load_finished) + self.web_view.titleChanged.connect(self._on_title_changed) if self._dev_origin is not None: # Live dev-server mode (developer opt-in, double-gated - see # resolve_dev_server_origin). The request interceptor @@ -509,6 +526,24 @@ def __init__( self._apply_native_mask() register(self) + self._install_escape_shortcut() + + def _on_title_changed(self, title): + # The page-side Escape relay (see _inline_bundle) signals through the + # title - the unique-suffix scheme keeps repeat presses distinct. + if title.startswith("__gl_escape__"): + self.escape_pressed.emit() + + def _install_escape_shortcut(self): + # UI-refactor P1: Escape must dismiss whatever surface is open even + # while keyboard focus sits inside this host's webview, where the + # QApplication event filter may never see the key (Chromium accepts + # the ShortcutOverride). A widget-with-children shortcut scoped to + # the host fires ahead of normal delivery for the host's subtree. + from PySide6.QtGui import QKeySequence, QShortcut + shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) + shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(self.escape_pressed.emit) def resizeEvent(self, event): super().resizeEvent(event) diff --git a/graphlink_app/graphlink_window.py b/graphlink_app/graphlink_window.py index 5e26409..80cafd7 100644 --- a/graphlink_app/graphlink_window.py +++ b/graphlink_app/graphlink_window.py @@ -36,6 +36,8 @@ from graphlink_about_web import AboutWebHost from graphlink_help_web import HelpWebHost from graphlink_settings_web import SettingsWebHost +from graphlink_dialog_frame import DialogFrame +from graphlink_overlay_manager import DIALOG, OverlayManager, POPOVER from graphlink_session import ChatSessionManager from graphlink_command_palette import CommandManager @@ -103,6 +105,7 @@ def __init__(self, settings_manager): self.setGeometry(100, 100, 1200, 800) self.library_dialog = None self.settings_panel = None + self.settings_frame = None self.help_panel = None self.about_panel = None self._initial_show_complete = False @@ -286,6 +289,26 @@ def __init__(self, settings_manager): # continuously repositioned during a live resize either). self.plugin_picker = PluginPickerHost(self.plugin_portal, parent=self.composer_overlay_parent) + # UI-refactor P1 (doc/UI_QA_AUDIT.md section 7): every transient + # surface's open/dismiss lifecycle routes through ONE manager - + # single-open, Escape + outside-click dismissal, scrimmed clamped + # dialogs, chip state from real visibility. Lazy dialogs register + # accessor closures; the manager tolerates not-yet-created panels. + self.overlay_manager = OverlayManager(self) + self._register_overlay_surfaces() + self.overlay_manager.visibility_changed.connect(self._on_overlay_visibility_changed) + # Escape must dismiss the open surface no matter WHICH island owns + # keyboard focus at press time (e.g. the toolbar webview right after + # a chip click) - every existing host's page-side Escape relay routes + # to close_all. Later-created hosts are hooked by the manager's + # open()-time descendant pass. + import graphlink_web_island_host as _wih + for _host in list(getattr(_wih, "_hosts", [])): + signal = getattr(_host, "escape_pressed", None) + if signal is not None and not getattr(_host, "_gl_escape_hooked", False): + signal.connect(self.overlay_manager.close_all) + _host._gl_escape_hooked = True + self.new_chat_shortcut = QShortcut(QKeySequence("Ctrl+T"), self); self.new_chat_shortcut.activated.connect(self.new_chat) self.library_shortcut = QShortcut(QKeySequence("Ctrl+L"), self); self.library_shortcut.activated.connect(self.show_library) self.save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self); self.save_shortcut.activated.connect(self.save_chat) @@ -406,6 +429,10 @@ def showEvent(self, event): def resizeEvent(self, event): super().resizeEvent(event) self._update_overlay_positions() + # P1: keep the scrim full-window and re-clamp any open dialog so no + # pixel ever leaves the window during a live resize (audit B4). + if getattr(self, "overlay_manager", None) is not None: + self.overlay_manager.reposition_for_resize() def eventFilter(self, watched, event): chat_view = getattr(self, 'chat_view', None) @@ -778,15 +805,119 @@ def show_search_overlay(self): self.search_overlay.setVisible(True) self._update_overlay_positions() - def show_library(self): - if self.library_dialog and self.library_dialog.isVisible(): - self.library_dialog.raise_() - self.library_dialog.activateWindow() - return + def _register_overlay_surfaces(self): + manager = self.overlay_manager + + manager.register( + "plugins", POPOVER, + widget_fn=lambda: self.plugin_picker, + open_fn=self._open_plugin_picker, + close_fn=lambda: self.plugin_picker.setVisible(False), + is_open_fn=lambda: self.plugin_picker.isVisible(), + ) + manager.register( + "pins", POPOVER, + widget_fn=lambda: self.pin_overlay, + open_fn=lambda: self.pin_overlay.show_for_anchor(self.toolbar_host.bridge.get_anchor("pins")), + close_fn=lambda: self.pin_overlay.setVisible(False), + is_open_fn=lambda: self.pin_overlay.isVisible(), + ) + manager.register( + "help", POPOVER, + widget_fn=lambda: self.help_panel, + open_fn=self._open_help_panel, + close_fn=lambda: self.help_panel.close() if self.help_panel else None, + is_open_fn=lambda: bool(self.help_panel and self.help_panel.isVisible()), + ) + manager.register( + "controls", POPOVER, + # The three view-control hosts toggle as ONE logical surface; + # outside-click hit-testing uses the grid panel (largest). The + # single-popover approximation is acceptable until P5 merges + # them into one real View popover. + widget_fn=lambda: self.chat_view.grid_control_host, + open_fn=lambda: self.chat_view.toggle_overlays_visibility(True), + close_fn=lambda: self.chat_view.toggle_overlays_visibility(False), + is_open_fn=lambda: self.chat_view.grid_control_host.isVisible(), + ) + manager.register( + "about", DIALOG, + widget_fn=lambda: self.about_panel, + open_fn=self._open_about_panel, + close_fn=lambda: self.about_panel.close() if self.about_panel else None, + is_open_fn=lambda: bool(self.about_panel and self.about_panel.isVisible()), + ) + manager.register( + "settings", DIALOG, + widget_fn=lambda: self.settings_frame, + open_fn=self._open_settings_frame, + close_fn=lambda: self.settings_frame.setVisible(False) if self.settings_frame else None, + is_open_fn=lambda: bool(self.settings_frame and self.settings_frame.isVisible()), + ) + manager.register( + "library", DIALOG, + widget_fn=lambda: self.library_dialog, + open_fn=self._open_library_dialog, + close_fn=lambda: self.library_dialog.setVisible(False) if self.library_dialog else None, + is_open_fn=lambda: bool(self.library_dialog and self.library_dialog.isVisible()), + ) - self.library_dialog = ChatLibraryDialog(self.session_manager, self) - self.library_dialog.destroyed.connect(lambda *_: setattr(self, "library_dialog", None)) + def _on_overlay_visibility_changed(self, name, is_open): + # Chip active-state binds to REAL visibility (audit B6) - the toolbar + # island republishes its payload with the live surface name. + bridge = getattr(self.toolbar_host, "bridge", None) + if bridge is not None: + bridge.publish() + + def _open_plugin_picker(self): + self.plugin_picker.reposition(self.toolbar_host.bridge.get_anchor("plugins")) + self.plugin_picker.setVisible(True) + + def _open_help_panel(self): + if not self.help_panel: + self.help_panel = HelpWebHost(self) + self.help_panel.show_for_anchor(self.toolbar_host.bridge.get_anchor("help")) + + def _open_about_panel(self): + if not self.about_panel: + self.about_panel = AboutWebHost(self) + self.about_panel.show_centered_over_parent() + # P1 (audit B4): show_centered_over_parent uses frame-geometry math + # that can land partially outside a small window - re-clamp inside. + self._clamp_child_in_window(self.about_panel) + + def _clamp_child_in_window(self, widget, margin=16): + width = min(widget.width(), self.width() - 2 * margin) + height = min(widget.height(), self.height() - 2 * margin) + widget.resize(max(width, 0), max(height, 0)) + x = (self.width() - widget.width()) // 2 + y = (self.height() - widget.height()) // 2 + widget.move(max(margin, x), max(margin, y)) + + def _open_settings_frame(self): + if self.settings_panel is None: + self.settings_panel = SettingsWebHost(self.settings_manager, main_window=self) + self.settings_frame = DialogFrame("Settings", self.settings_panel, parent=self) + self.settings_frame.resize( + self.settings_panel.width() + 40, self.settings_panel.height() + 80 + ) + self.settings_frame.close_requested.connect(lambda: self.overlay_manager.close("settings")) + self.settings_panel.set_current_section_by_mode(self.settings_manager.get_current_mode()) + self.settings_frame.center_in_parent() + self.settings_frame.setVisible(True) + + def _open_library_dialog(self): + if self.library_dialog is None: + # Cached once, plain embedded child (P1): the fresh-top-level- + # window-per-open construction was the audit-B3 first-click + # no-op; an embedded cached widget has no first-show race. + self.library_dialog = ChatLibraryDialog(self.session_manager, self) + self.library_dialog.close_button.clicked.disconnect() + self.library_dialog.close_button.clicked.connect(lambda: self.overlay_manager.close("library")) self.library_dialog.show_centered() + + def show_library(self): + self.overlay_manager.toggle("library") def keyPressEvent(self, event): if event.key() == Qt.Key.Key_Escape: @@ -809,24 +940,10 @@ def should_show_notification(self, msg_type): return self.settings_manager.get_notification_type_enabled(msg_type) def _toggle_plugin_picker(self): - if self.plugin_picker.isVisible(): - # setVisible(False), not .close() - PluginPickerHost is a plain - # embedded child with no Window flag, never meant to go through - # a native closeEvent (see graphlink_plugin_picker_web.py). - self.plugin_picker.setVisible(False) - return - self.plugin_picker.reposition(self.toolbar_host.bridge.get_anchor("plugins")) - self.plugin_picker.setVisible(True) - self.plugin_picker.raise_() + self.overlay_manager.toggle("plugins") def toggle_pin_overlay(self, checked=False): - if self.pin_overlay.isVisible(): - # setVisible(False), not .close() - PinOverlayHost is a plain - # embedded child with no Window flag, never meant to go through - # a native closeEvent (see graphlink_pin_overlay_web.py). - self.pin_overlay.setVisible(False) - return - self.pin_overlay.show_for_anchor(self.toolbar_host.bridge.get_anchor("pins")) + self.overlay_manager.toggle("pins") def update_title_bar(self): title = "Graphlink" @@ -836,25 +953,10 @@ def update_title_bar(self): self.setWindowTitle(title) def show_about_dialog(self): - # Cached once and toggled, replacing the legacy AboutDialog(self).exec() - # (modal, constructed fresh every call) - see graphlink_about_web.py's - # module docstring for the modal->non-modal rationale. - if self.about_panel and self.about_panel.isVisible(): - self.about_panel.close() - return - if not self.about_panel: - self.about_panel = AboutWebHost(self) - self.about_panel.show_centered_over_parent() + self.overlay_manager.toggle("about") def show_help(self): - if self.help_panel and self.help_panel.isVisible(): - self.help_panel.close() - return - - if not self.help_panel: - self.help_panel = HelpWebHost(self) - - self.help_panel.show_for_anchor(self.toolbar_host.bridge.get_anchor("help")) + self.overlay_manager.toggle("help") def _build_document_section(self, title, body): text = str(body or "").strip() @@ -966,18 +1068,7 @@ def show_document_view(self, node): def hide_document_view(self): self.doc_viewer_panel.setVisible(False) def show_settings(self): - if self.settings_panel and self.settings_panel.isVisible(): - self.settings_panel.close() - return - - if not self.settings_panel: - # Unconditional since Phase 3 increment 10 deleted the legacy - # SettingsDialog and its renderer flag (pre-deletion code is at - # the legacy-settings-final git tag). - self.settings_panel = SettingsWebHost(self.settings_manager, main_window=self, parent=self) - - self.settings_panel.set_current_section_by_mode(self.settings_manager.get_current_mode()) - self.settings_panel.show_for_anchor(self.toolbar_host.bridge.get_anchor("settings")) + self.overlay_manager.toggle("settings") def _initialize_mode(self, mode_text, *, show_dialogs): if mode_text == config.MODE_OLLAMA_LOCAL: diff --git a/graphlink_app/tests/test_overlay_manager.py b/graphlink_app/tests/test_overlay_manager.py new file mode 100644 index 0000000..65d7391 --- /dev/null +++ b/graphlink_app/tests/test_overlay_manager.py @@ -0,0 +1,250 @@ +"""UI-refactor P1 (doc/UI_QA_AUDIT.md section 7): OverlayManager unit tests + +the phase's acceptance criteria as executable checks. + +Acceptance (audit section 7, P1): single-open across every surface, +Escape closes whatever is open, and no dialog pixel outside the window at +any window size. Unit tier here uses plain QWidgets as fake surfaces so the +manager's LOGIC is pinned independent of any real host; the integration +tier drives a REAL ChatWindow's registered surfaces (plugins/pins popovers, +settings/library dialogs) through toolbar-intent entry points at three +window sizes. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QWidget + +_APP = QApplication.instance() or QApplication([]) + +from graphlink_overlay_manager import DIALOG, POPOVER, OverlayManager + + +class _FakeSurface: + def __init__(self, parent): + self.widget = QWidget(parent) + self.widget.setVisible(False) + self.open_calls = 0 + + def open(self): + self.open_calls += 1 + self.widget.setVisible(True) + + def close(self): + self.widget.setVisible(False) + + def is_open(self): + return self.widget.isVisible() + + +@pytest.fixture +def rig(): + window = QWidget() + window.resize(800, 600) + # Children of a hidden parent always report isVisible() False - the + # manager's is_open contract is real visibility, so the rig must show. + window.show() + manager = OverlayManager(window) + surfaces = {} + for name, tier in (("pop_a", POPOVER), ("pop_b", POPOVER), ("dlg_a", DIALOG), ("dlg_b", DIALOG)): + surface = _FakeSurface(window) + surfaces[name] = surface + manager.register( + name, tier, + widget_fn=lambda s=surface: s.widget, + open_fn=surface.open, + close_fn=surface.close, + is_open_fn=surface.is_open, + ) + yield window, manager, surfaces + # The manager installs a QApplication-wide event filter; remove it so + # rigs never stack across tests. + QApplication.instance().removeEventFilter(manager) + window.deleteLater() + + +class TestSingleOpenPolicy: + def test_opening_a_popover_closes_the_other_popover(self, rig): + _, manager, surfaces = rig + manager.open("pop_a") + manager.open("pop_b") + assert not surfaces["pop_a"].is_open() + assert surfaces["pop_b"].is_open() + + def test_opening_a_dialog_closes_an_open_popover(self, rig): + _, manager, surfaces = rig + manager.open("pop_a") + manager.open("dlg_a") + assert not surfaces["pop_a"].is_open() + assert surfaces["dlg_a"].is_open() + + def test_opening_a_dialog_closes_the_other_dialog(self, rig): + _, manager, surfaces = rig + manager.open("dlg_a") + manager.open("dlg_b") + assert not surfaces["dlg_a"].is_open() + assert surfaces["dlg_b"].is_open() + + def test_toggle_closes_when_open_and_opens_when_closed(self, rig): + _, manager, surfaces = rig + manager.toggle("pop_a") + assert surfaces["pop_a"].is_open() + manager.toggle("pop_a") + assert not surfaces["pop_a"].is_open() + + def test_at_most_one_surface_visible_after_any_sequence(self, rig): + _, manager, surfaces = rig + for name in ("pop_a", "dlg_a", "pop_b", "dlg_b", "pop_a"): + manager.toggle(name) + visible = [n for n, s in surfaces.items() if s.is_open()] + assert len(visible) <= 1, f"multiple surfaces open: {visible}" + + +class TestDialogScrim: + def test_scrim_visible_only_while_a_dialog_is_open(self, rig): + _, manager, surfaces = rig + assert not manager._scrim.isVisible() + manager.open("pop_a") + assert not manager._scrim.isVisible(), "popovers must not scrim" + manager.open("dlg_a") + assert manager._scrim.isVisible() + manager.close("dlg_a") + assert not manager._scrim.isVisible() + + def test_scrim_click_closes_the_dialog(self, rig): + _, manager, surfaces = rig + manager.open("dlg_a") + manager._on_scrim_pressed() + assert not surfaces["dlg_a"].is_open() + assert not manager._scrim.isVisible() + + def test_scrim_covers_the_whole_window_after_resize(self, rig): + window, manager, _ = rig + manager.open("dlg_a") + window.resize(1200, 900) + manager.reposition_for_resize() + assert manager._scrim.geometry() == window.rect() + + +class TestVisibilityChangedSignal: + def test_emits_true_on_open_and_false_on_close(self, rig): + _, manager, _ = rig + events = [] + manager.visibility_changed.connect(lambda name, is_open: events.append((name, is_open))) + manager.open("pop_a") + manager.close("pop_a") + assert events == [("pop_a", True), ("pop_a", False)] + + def test_single_open_displacement_emits_close_for_the_displaced(self, rig): + _, manager, _ = rig + events = [] + manager.visibility_changed.connect(lambda name, is_open: events.append((name, is_open))) + manager.open("pop_a") + manager.open("dlg_a") + assert ("pop_a", False) in events + assert events[-1] == ("dlg_a", True) + + def test_open_surface_name_rederives_from_real_visibility(self, rig): + _, manager, surfaces = rig + manager.open("pop_a") + # A surface hidden behind the manager's back must not be reported open. + surfaces["pop_a"].close() + assert manager.open_surface_name() is None + + +class TestEscapeClosesEverything: + @pytest.mark.parametrize("name", ["pop_a", "pop_b", "dlg_a", "dlg_b"]) + def test_escape_closes_the_open_surface(self, rig, name): + window, manager, surfaces = rig + window.show() + manager.open(name) + from PySide6.QtGui import QKeyEvent + from PySide6.QtCore import QEvent + event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier) + # Delivered through the application filter exactly as a real key + # press would be seen, whatever widget owns focus. + assert manager.eventFilter(window, event) is True + assert not surfaces[name].is_open() + + def test_escape_with_nothing_open_is_not_consumed(self, rig): + window, manager, _ = rig + from PySide6.QtGui import QKeyEvent + from PySide6.QtCore import QEvent + event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier) + assert manager.eventFilter(window, event) is False + + +class TestRealChatWindowAcceptanceDrive: + """The audit's P1 acceptance, against the real window: toolbar-intent + entry points, three window sizes, geometry containment.""" + + @pytest.fixture(scope="class") + def window(self): + from unittest.mock import patch + from graphlink_window import ChatWindow + import graphlink_licensing + + with patch.object(ChatWindow, "show_previous_crash_notice", create=True): + settings_manager = graphlink_licensing.SettingsManager() + win = ChatWindow(settings_manager) + win.show() + QApplication.processEvents() + yield win + QApplication.instance().removeEventFilter(win.overlay_manager) + win.close() + + def test_single_open_across_real_surfaces(self, window): + manager = window.overlay_manager + window.resize(1100, 750) + manager.open("plugins") + assert manager.is_open("plugins") + manager.open("pins") + assert not manager.is_open("plugins") + manager.open("settings") + assert not manager.is_open("pins") + assert manager.is_open("settings") + manager.open("library") + assert not manager.is_open("settings") + assert manager.is_open("library") + manager.close_all() + assert manager.open_surface_name() is None + + @pytest.mark.parametrize("size", [(900, 600), (1300, 850), (700, 500)]) + def test_no_dialog_pixel_outside_the_window_at_any_size(self, window, size): + manager = window.overlay_manager + window.resize(*size) + for name in ("settings", "library", "about"): + manager.open(name) + widget = manager._surfaces[name].widget_fn() + assert widget is not None and widget.isVisible(), f"{name} did not open" + geometry = widget.geometry() + assert window.rect().contains(geometry), ( + f"{name} at window size {size} escapes the window: " + f"{geometry} vs {window.rect()}" + ) + manager.close(name) + + def test_escape_closes_each_real_surface(self, window): + from PySide6.QtGui import QKeyEvent + from PySide6.QtCore import QEvent + manager = window.overlay_manager + for name in ("plugins", "pins", "settings", "library"): + manager.open(name) + event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier) + assert manager.eventFilter(window, event) is True, f"Escape not consumed for {name}" + assert not manager.is_open(name), f"Escape did not close {name}" + + def test_library_first_open_actually_shows(self, window): + # Audit B3: the first Library invocation used to be a no-op. The + # cached embedded child must be visible on the FIRST open call. + manager = window.overlay_manager + manager.close_all() + window.library_dialog = None # force the true first-open path + manager.open("library") + assert window.library_dialog is not None + assert window.library_dialog.isVisible() + manager.close("library") diff --git a/graphlink_app/tests/test_settings_web_host.py b/graphlink_app/tests/test_settings_web_host.py index ca9ac80..aac6d86 100644 --- a/graphlink_app/tests/test_settings_web_host.py +++ b/graphlink_app/tests/test_settings_web_host.py @@ -59,13 +59,18 @@ def test_construction_registers_with_the_shared_shutdown_registry(tmp_path): assert host in wih._hosts -def test_window_flags_are_a_persistent_frameless_tool_window(tmp_path): +def test_host_is_a_plain_embedded_child_not_a_tool_window(tmp_path): + # UI-refactor P1 (audit B4/B5): the Tool-window flags are GONE - the host + # embeds in a DialogFrame that OverlayManager centers, clamps inside the + # main window, and scrims. A Tool flag reappearing here would resurrect + # the dialog-escapes-the-window bug. host = _make_host(tmp_path) flags = host.windowFlags() - assert flags & Qt.WindowType.Tool - assert flags & Qt.WindowType.FramelessWindowHint + # Bitwise note: Tool INCLUDES the Window bit, so `flags & Tool` is + # truthy for any parentless default widget - compare the masked type. + assert (flags & Qt.WindowType.WindowType_Mask) != Qt.WindowType.Tool def test_construction_sizes_to_820x560(tmp_path): @@ -116,16 +121,13 @@ def test_falls_back_to_general_for_an_unrecognized_mode(self, tmp_path): class TestShowForAnchor: - def test_shows_the_panel_and_resizes_to_820x560(self, tmp_path): + def test_screen_coordinate_positioning_is_gone(self, tmp_path): + # P1: show_for_anchor (screen-coordinate positioning) was deleted - + # positioning belongs to OverlayManager/DialogFrame now. Its + # resurrection would bypass the window-clamp guarantee. host = _make_host(tmp_path) - anchor = QWidget() - anchor.resize(80, 24) - host.show_for_anchor(anchor) - - assert host.isVisible() is True - assert host.width() == 820 - assert host.height() == 560 + assert not hasattr(host, "show_for_anchor") class TestCloseGuard: diff --git a/graphlink_app/tests/test_toolbar_bridge.py b/graphlink_app/tests/test_toolbar_bridge.py index 8438064..60bd6ae 100644 --- a/graphlink_app/tests/test_toolbar_bridge.py +++ b/graphlink_app/tests/test_toolbar_bridge.py @@ -62,8 +62,22 @@ def reset_zoom(self): def fit_all(self): self.fit_calls += 1 - def toggle_overlays_visibility(self, visible): - self.overlays_visible = visible + +class _FakeOverlayManager: + """Mirrors the real OverlayManager's toggle()/open_surface_name() contract: + toggle opens a closed surface, closes the same surface, and swaps to a + different one; open_surface_name() returns the open name or None.""" + + def __init__(self): + self.toggle_calls = [] + self._open = None + + def toggle(self, name): + self.toggle_calls.append(name) + self._open = None if self._open == name else name + + def open_surface_name(self): + return self._open class _FakeWindow: @@ -71,6 +85,7 @@ def __init__(self): self.pin_overlay = _FakePinOverlay() self.settings_manager = _FakeSettingsManager() self.chat_view = _FakeChatView() + self.overlay_manager = _FakeOverlayManager() self.calls = [] def show_library(self): @@ -188,13 +203,38 @@ def test_organize_zoom_reset_fit_all_reach_the_real_chat_view(): assert window.chat_view.fit_calls == 1 -def test_toggle_controls_forwards_the_bool_to_chat_view(): +def test_toggle_controls_routes_through_the_overlay_manager_ignoring_the_bool(): + # P1 (audit B6): the island's bool is ignored - the OverlayManager owns + # open/close truth; the slot only asks it to toggle the controls surface. window = _FakeWindow() bridge = ToolbarBridge(window) bridge.toggleControls(True) + bridge.toggleControls(True) + + assert window.overlay_manager.toggle_calls == ["controls", "controls"] + + +def test_active_surface_publishes_the_open_surface_and_empty_when_none(): + window = _FakeWindow() + bridge = ToolbarBridge(window) + assert _snapshot(bridge)["activeSurface"] == "" + + window.overlay_manager.toggle("settings") + assert _snapshot(bridge)["activeSurface"] == "settings" + + window.overlay_manager.toggle("settings") + assert _snapshot(bridge)["activeSurface"] == "" + + +def test_active_surface_falls_back_to_empty_without_an_overlay_manager(): + # Models the startup window before the manager attribute exists - + # _build_state_payload getattr-guards it rather than crashing publish. + window = _FakeWindow() + del window.overlay_manager + bridge = ToolbarBridge(window) - assert window.chat_view.overlays_visible is True + assert _snapshot(bridge)["activeSurface"] == "" class TestAnchorRect: diff --git a/web_ui/src/islands/toolbar/App.test.tsx b/web_ui/src/islands/toolbar/App.test.tsx index 8379493..258bfce 100644 --- a/web_ui/src/islands/toolbar/App.test.tsx +++ b/web_ui/src/islands/toolbar/App.test.tsx @@ -126,7 +126,7 @@ describe("App against a real (faked) QWebChannel connection", () => { expect(pinsButton).toHaveAttribute("aria-pressed", "false"); }); - it("Controls toggles purely client-side and calls toggleControls with the new value", async () => { + it("clicking Controls fires toggleControls but never flips itself locally", async () => { const remote = installFakeQWebChannel(); const user = userEvent.setup(); render(); @@ -136,11 +136,57 @@ describe("App against a real (faked) QWebChannel connection", () => { await user.click(controlsButton); expect(remote.toggleControls).toHaveBeenCalledWith(true); - expect(controlsButton).toHaveAttribute("aria-pressed", "true"); + // Server-authoritative: no payload arrived, so the chip stays unpressed. + expect(controlsButton).toHaveAttribute("aria-pressed", "false"); + + push(remote, { activeSurface: "controls" }); + await waitFor(() => expect(controlsButton).toHaveAttribute("aria-pressed", "true")); await user.click(controlsButton); - expect(remote.toggleControls).toHaveBeenCalledWith(false); - expect(controlsButton).toHaveAttribute("aria-pressed", "false"); + expect(remote.toggleControls).toHaveBeenLastCalledWith(false); + // Still pressed until the server republishes the close. + expect(controlsButton).toHaveAttribute("aria-pressed", "true"); + }); + + it("a chip shows active when activeSurface matches and clears when it goes back to empty", async () => { + const remote = installFakeQWebChannel(); + render(); + push(remote, { activeSurface: "" }); + + const chips: Array<[string, RegExp | string]> = [ + ["library", "Library"], + ["controls", "Controls"], + ["plugins", /Plugins/], + ["settings", "Settings"], + ["about", "About"], + ["help", "Help"], + ]; + + for (const [surface, label] of chips) { + const chip = await screen.findByRole("button", { name: label }); + expect(chip).toHaveAttribute("aria-pressed", "false"); + + push(remote, { activeSurface: surface }); + await waitFor(() => expect(chip).toHaveAttribute("aria-pressed", "true")); + expect(chip.className).toContain("checked"); + + push(remote, { activeSurface: "" }); + await waitFor(() => expect(chip).toHaveAttribute("aria-pressed", "false")); + expect(chip.className).not.toContain("checked"); + } + }); + + it("only the chip matching activeSurface is pressed, never its siblings", async () => { + const remote = installFakeQWebChannel(); + render(); + push(remote, { activeSurface: "settings" }); + + const settings = await screen.findByRole("button", { name: "Settings" }); + await waitFor(() => expect(settings).toHaveAttribute("aria-pressed", "true")); + for (const label of ["Library", "Controls", "About", "Help"]) { + expect(screen.getByRole("button", { name: label })).toHaveAttribute("aria-pressed", "false"); + } + expect(screen.getByRole("button", { name: /Plugins/ })).toHaveAttribute("aria-pressed", "false"); }); it("the mode select shows the real options and calls selectMode on change", async () => { diff --git a/web_ui/src/islands/toolbar/App.tsx b/web_ui/src/islands/toolbar/App.tsx index 239a717..fbae46a 100644 --- a/web_ui/src/islands/toolbar/App.tsx +++ b/web_ui/src/islands/toolbar/App.tsx @@ -13,7 +13,6 @@ type AnchorName = (typeof ANCHOR_NAMES)[number]; function App() { const [state, setState] = useState(initialToolbarState); const [rejection, setRejection] = useState(null); - const [controlsChecked, setControlsChecked] = useState(false); const bridgeRef = useRef(null); const rootRef = useRef(null); const pinsAnchorRef = useRef(null); @@ -90,15 +89,23 @@ function App() { ); } - function onToggleControls() { - const next = !controlsChecked; - setControlsChecked(next); - bridgeRef.current?.toggleControls(next); - } + // UI-refactor P1 (audit B6): every chip's active visual derives from the + // server-published activeSurface ("" when nothing is open) - never from + // island-local latched click state. Clicks only fire intents; the bridge + // republishes on every open/close, so the visual follows real visibility + // (including surfaces closed via Esc, their own Close button, etc.). + const isActive = (surface: string) => state.activeSurface === surface; + const chipClass = (surface: string) => + "toolbar-btn toolbar-btn-checkable" + (isActive(surface) ? " checked" : ""); return (
- @@ -147,7 +154,8 @@ function App() { -