diff --git a/theme.py b/theme.py
index e4441c8..40808fe 100644
--- a/theme.py
+++ b/theme.py
@@ -471,11 +471,16 @@ QComboBox#navProjectPick {
}
QPushButton#navSettingsBtn {
background: transparent; border: none; color: $text_muted;
- padding: 6px 8px; text-align: left; border-radius: ${radius}px; margin: 2px 6px 6px 6px;
+ padding: 6px 8px 6px 7px; text-align: left; border-radius: ${radius}px;
+ /* No side margin: Settings reads as one more row under Dashboard/Giám sát,
+ so its icon has to start on their x. A 6px margin put it at 14 — near
+ enough the middle of the collapsed 54px rail to look centred. */
+ margin: 2px 0px 6px 0px;
}
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
QPushButton#navSettingsBtn:pressed { background: $active; }
+
/* ---- surfaces --------------------------------------------------------- */
QGroupBox {
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
diff --git a/tools/capture_screens.py b/tools/capture_screens.py
index 03e3fd0..b7c2a53 100644
--- a/tools/capture_screens.py
+++ b/tools/capture_screens.py
@@ -75,6 +75,22 @@ def _load_fonts() -> int:
return loaded
+def _apply_theme(app, name: str | None = None) -> str:
+ """Load the app's real stylesheet onto `app`.
+
+ `MainWindow` does not style itself — `run()` calls `app.setStyleSheet` — so a
+ checker that builds the window directly measures a window with no padding,
+ no margins and no borders. Every QSS-driven layout bug is invisible there.
+ """
+ from cowork_local import theme
+ from cowork_local.config import AppConfig
+
+ name = name or AppConfig.load().theme
+ theme.set_active_theme(name)
+ app.setStyleSheet(theme.stylesheet(name))
+ return name
+
+
def _freeze_schedulers() -> None:
"""No-op the background engines so nothing is executed while we capture."""
from cowork_local.core.task_scheduler import TaskScheduler
diff --git a/tools/check_co4e.py b/tools/check_co4e.py
index 4864bce..ac3a87b 100644
--- a/tools/check_co4e.py
+++ b/tools/check_co4e.py
@@ -12,6 +12,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -19,7 +21,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402
# Every control the sidebar and the flow area had before these changes.
EXPECTED = [
@@ -40,6 +42,7 @@ def main() -> int:
app = QApplication([])
_load_fonts()
+ _apply_theme(app) # measure the styled widget, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py
index 6f4cb81..c961320 100644
--- a/tools/check_controls_alive.py
+++ b/tools/check_controls_alive.py
@@ -16,6 +16,8 @@ from __future__ import annotations
import json
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -23,7 +25,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
# controls.json lists every control in a FILE, and several files hold more than
# one class (schedule_task_tab.py alone has the tab plus three dialogs). Only
@@ -125,6 +127,7 @@ def main() -> int:
_load_fonts()
_freeze_schedulers()
+ _apply_theme(app) # measure the styled window, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_dashboard.py b/tools/check_dashboard.py
index 94230d6..0c856a2 100644
--- a/tools/check_dashboard.py
+++ b/tools/check_dashboard.py
@@ -10,6 +10,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -17,7 +19,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402
HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn",
"gran_combo", "metric_combo", "currency_lbl", "currency_combo",
@@ -31,6 +33,7 @@ def main() -> int:
app = QApplication([])
_load_fonts()
+ _apply_theme(app) # measure the styled widget, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py
index d97419b..37ef0d8 100644
--- a/tools/check_design_parity.py
+++ b/tools/check_design_parity.py
@@ -1,363 +1,366 @@
-"""Compare the running app against every proposal on the audit page.
-
-The checklist is not written here — it is read from build_audit_page.ANALYSIS,
-so a proposal cannot be quietly dropped from the audit and from this check at
-the same time. Each item has a probe against a real MainWindow built offscreen.
-
-Verdicts:
- OK the probe passes
- CHUA not implemented
- KHAC implemented differently on purpose (reason printed)
- TAY cannot be probed mechanically — inspect by eye
-
-Run: python tools/check_design_parity.py
-"""
-from __future__ import annotations
-
-import os
-import sys
-from pathlib import Path
-
-REPO = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(REPO.parent))
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-
-from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
-
-
-def page_proposals():
- """The 'Thay đổi' bullets as they appear ON THE PAGE, per section.
-
- Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight
- sections are hand-written, and for those two the generator's text is NOT
- what the page shows. Checking against ANALYSIS reported Settings and the
- Task editor as matching the design when the page asked for something else
- (and, for the Task editor, the opposite).
- """
- import re
- html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
- html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
- out: dict[str, list[str]] = {}
- for m in re.finditer(r'', html, re.S):
- block = re.search(r'Thay đổi
', m.group(2), re.S)
- if not block:
- continue
- out[m.group(1)] = [
- re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
- for li in re.findall(r"(.*?)", block.group(1), re.S)]
- return out
-
-
-def build():
- sandbox = _isolate_home()
- from PySide6.QtWidgets import QApplication
-
- app = QApplication.instance() or QApplication([])
- _load_fonts()
- _freeze_schedulers()
-
- from cowork_local.config import AppConfig, CONFIG_DIR
- assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
-
- from seed_demo_data import seed
- seed()
-
- from cowork_local.app import MainWindow
- from cowork_local.i18n import set_language
- from cowork_local.state import AppContext
-
- set_language("vi")
- win = MainWindow(AppContext(AppConfig.load()), user_name="local")
- win.resize(1600, 900)
- win.show()
- for _ in range(8):
- app.processEvents()
- return app, win
-
-
-def main() -> int:
- app, win = build()
- ws = win.workspace
-
- def goto(sub):
- win._goto(win._ROW_WORKSPACE, sub)
- for _ in range(6):
- app.processEvents()
-
- def page(row):
- win._goto(row, None)
- for _ in range(6):
- app.processEvents()
- return win._page_widgets[row]
-
- import cowork_local.ui.co4e_tab as co4e_mod
- co4e = win.findChildren(co4e_mod.Co4ETab)[0]
- dash = page(win._ROW_DASHBOARD)
- mon = page(win._ROW_MONITORING)
- sched = page(win._ROW_SCHEDULE)
- goto(ws._cowork_tab_idx)
- chat = ws._cowork
- dock = win.help_agent
-
- def rows_of(widget, names):
- """How many distinct y-bands the named widgets occupy."""
- bands = set()
- for n in names:
- w = getattr(widget, n, None)
- if w is not None:
- bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
- return len(bands)
-
- # (slug, proposal, verdict, evidence)
- R: list[tuple[str, str, str, str]] = []
-
- def add(slug, text, ok, ev, other=None):
- R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
-
- # --- 1 Dashboard ---
- n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
- "currency_combo"])
- add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng")
- # Taller than the small tiles AND a bigger number = it reads as the headline.
- taller = dash.card_cost.height() > dash.card_total.height() * 1.5
- bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
- add("dashboard", "Chi phí làm thẻ chính", taller and bigger,
- f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
- f"cỡ số {'34px' if bigger else 'như cũ'}")
-
- # --- 2 Schedule Kanban ---
- lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
- if not lanes:
- from cowork_local.core.tasks import STATUSES
- lanes = len(STATUSES)
- add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
- has_combo = getattr(sched, "view_combo", None) is not None
- add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
- "vẫn là combo" if has_combo else "đã thành tab")
- # The lane is only outlined while it actually holds something — seed data
- # may leave it empty, so drop a card in and read the style back.
- run_col = sched.columns.get("running")
- styled = ""
- if run_col is not None:
- from PySide6.QtWidgets import QListWidgetItem
- run_col.addItem(QListWidgetItem("probe"))
- sched.column_headers["running"].setStyleSheet("")
- sched.refresh()
- app.processEvents()
- styled = run_col.styleSheet()
- add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
- styled or "không có viền")
-
- # --- 4/5 Workspace ---
- add("workspace-project", "History lên sidebar thành RECENTS",
- win.nav_recents.topLevelItemCount() > 0,
- f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
- add("workspace-project", "Thanh chọn project dùng chung mọi màn",
- win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
- goto(ws._co4e_tab_idx)
- hdr_off = ws._header.isHidden()
- goto(ws._project_tab_idx)
- hdr_on = not ws._header.isHidden()
- add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
- "chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
- # The design's own wireframes draw the rail on every screen and a different
- # in-page pane per screen, so "the fixed left pane" is the rail — which now
- # carries the project picker and RECENTS on all of them.
- fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
- win.nav_recents.topLevelItemCount() > 0
- add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
- "rail (project + RECENTS) không đổi theo màn")
- add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
- win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
- add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
- any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
- for i in range(win.nav_recents.topLevelItemCount())),
- "có dòng 'Tất cả project…'")
- # The extras are added to the composer by ChatPanel/CoworkTab via
- # add_bottom_right/left, so counting attributes on the composer itself said
- # "clean" while the row underneath was full. Count the row instead.
- # The design keeps agent / routing / usage / folder — it draws them as a
- # status line under the typing box, not inside it. So the test is that the
- # TYPING row holds only input + attach/send/stop, and the rest sits in its
- # own strip below. Demanding an empty strip would mean deleting features.
- composer = getattr(chat, "composer", None)
- bar = getattr(composer, "extra_bar", None)
- from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
- typing = composer.input
- in_typing_row = typing.parentWidget() is composer
- below = bar is not None and bar.objectName() == "composerStatus"
- usage = getattr(chat, "_usage_total_lbl", None)
- usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage)
- add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi",
- below and usage_in_bar,
- f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · "
- f"{bar.layout().count() if bar else 0} mục")
-
- # --- 6 Co4E ---
- add("workspace-co4e", "Bỏ dải tab flow",
- not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn")
- add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách",
- len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng")
- add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải",
- not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
-
- # --- 7 Folder / 8 GraphRAG ---
- folder = ws.tabs.widget(ws._folder_tab_idx)
- title_lbl = getattr(folder, "path_lbl", None)
- add("workspace-folder", "Path bar gộp vào tiêu đề",
- title_lbl is not None and getattr(folder, "path_edit", None) is None,
- f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập")
- # "Thin bar at the bottom" = the terminal is the last thing in the column
- # and starts collapsed; the AI panel is a hideable right-hand pane.
- # Geometry is meaningless for a page that has never been shown, so ask the
- # widgets what state they are in instead of how tall they currently are.
- term = getattr(folder, "terminal", None)
- lay = folder.layout()
- last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
- collapsed = term is not None and term._body.isHidden()
- at_bottom = term is not None and last is term
- add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
- collapsed and at_bottom,
- f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
- graph = ws.tabs.widget(ws._graphrag_tab_idx)
- # One row = the path box and Export share a y-band.
- def band(w):
- return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
- one_row = band(graph.path_edit) == band(graph._export_btn)
- add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row,
- f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}")
- # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
- # _msg_btn — a stale name); while it exists, this is still one button whose
- # label flips, not a pair of tabs.
- toggle = getattr(graph, "_msgs_toggle_btn", None)
- add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None,
- "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
-
- # --- 9/15 Monitoring ---
- from PySide6.QtWidgets import QHBoxLayout, QScrollArea
- from PySide6.QtWidgets import QSpinBox as QSpinBoxT
- ov = mon.findChildren(QScrollArea)[0].widget()
- one_col = not isinstance(ov.layout(), QHBoxLayout)
- add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
- "cột dọc" if one_col else "vẫn 2 cột")
- # Its own section = it is a direct child of the single column, not sharing a
- # row with the resource meters as it used to.
- own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
- add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
- f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
- strip = not mon.tabs.tabBar().isHidden()
- add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
- strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
-
- # --- 17/18 dialogs ---
- from cowork_local.ui.settings_dialog import SettingsDialog
- from cowork_local.ui.task_editor_dialog import TaskEditorDialog
- s = SettingsDialog(win.ctx)
- add("dialog-settings", "Thêm cột mục lục bên trái",
- s.section_list.count() == 5, f"{s.section_list.count()} mục")
- # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
- # left as its own group — not everything merged together.
- from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
- in_general = s._general_box.isAncestorOf(s.language_combo) and \
- s._general_box.isAncestorOf(s.theme_combo)
- prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
- add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
- in_general and prov_apart,
- f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
- n_switch = len(s.findChildren(ToggleSwitch))
- n_seg = len(s.findChildren(SegmentedControl))
- steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
- add("dialog-settings",
- "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
- n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
- f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
- s.close()
- t = TaskEditorDialog(ctx=win.ctx)
- rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
- like_settings = (t.section_list.count() == 5
- and t.section_stack.count() == 5
- and not hasattr(t, "step_tabs"))
- add("dialog-task-editor",
- "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
- like_settings, " · ".join(rows))
- t.close()
-
- # --- 27 help dock ---
- add("overlay-help-panel", "Một chấm 26px, không chữ",
- dock.width() <= 30 and not dock.launcher.text().strip(), f"{dock.width()}px")
- from cowork_local.i18n import tr
- dock.launcher._set_open(True)
- app.processEvents()
- add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
- tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
- dock.launcher._set_open(False)
- items = [a.text() for a in dock.more_btn.menu().actions()]
- add("overlay-help-panel", "'Ẩn trợ lý' dời vào menu ⋯",
- tr("help_agent.hide_tooltip") in items, str(items))
- add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
- dock._hide_to_edge()
- app.processEvents()
- add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
- dock._show_launcher()
- app.processEvents()
- goto(ws._cowork_tab_idx)
- comp = chat.composer
- dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
- comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
- add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
- dock_top + dock.height() <= comp_top,
- f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
-
- # --- coverage: is every bullet ON THE PAGE actually probed? -------------
- proposals = page_proposals()
- probed = {}
- for slug, text, _v, _e in R:
- probed.setdefault(slug, 0)
- probed[slug] += 1
- gaps = []
- for slug, bullets in proposals.items():
- n_probe = probed.get(slug, 0)
- if len(bullets) > n_probe:
- for extra in bullets[n_probe:]:
- gaps.append((slug, extra))
-
- # --- report ---
- order = ["OK", "KHAC", "CHUA", "TAY"]
- counts = {k: 0 for k in order}
- cur = None
- for slug, text, verdict, ev in R:
- counts[verdict] = counts.get(verdict, 0) + 1
- if slug != cur:
- print(f"\n{slug}")
- cur = slug
- print(f" [{verdict:4}] {text}")
- print(f" {ev}")
- if gaps:
- print()
- print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
- for slug, text in gaps:
- print(f" {slug}")
- print(f" {text[:160]}")
- print()
- print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
- f" · da co phep do : {len(R)}")
- print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
- print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
- print(f" KHAC = co y lam khac, da ghi ly do")
- print(f" CHUA = chua lam")
- # This used to return 0 unconditionally — a report, not a check. Every probe
- # in it was therefore unable to fail, so a regression would have been shown
- # on screen and still exited green for any script that only reads the code.
- return 1 if counts.get("CHUA") else 0
-
-
-if __name__ == "__main__":
- _rc = main()
- # Qt (WebEngine especially) crashes during interpreter teardown with
- # 0xC0000409 AFTER the work is done, which would mask the real result —
- # and check_probes_bite reads these exit codes to decide whether a probe
- # caught its mutation. Leave immediately with the verdict instead.
- sys.stdout.flush()
- sys.stderr.flush()
- os._exit(_rc)
+"""Compare the running app against every proposal on the audit page.
+
+The checklist is not written here — it is read from build_audit_page.ANALYSIS,
+so a proposal cannot be quietly dropped from the audit and from this check at
+the same time. Each item has a probe against a real MainWindow built offscreen.
+
+Verdicts:
+ OK the probe passes
+ CHUA not implemented
+ KHAC implemented differently on purpose (reason printed)
+ TAY cannot be probed mechanically — inspect by eye
+
+Run: python tools/check_design_parity.py
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
+
+
+def page_proposals():
+ """The 'Thay đổi' bullets as they appear ON THE PAGE, per section.
+
+ Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight
+ sections are hand-written, and for those two the generator's text is NOT
+ what the page shows. Checking against ANALYSIS reported Settings and the
+ Task editor as matching the design when the page asked for something else
+ (and, for the Task editor, the opposite).
+ """
+ import re
+ html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8")
+ html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html)
+ out: dict[str, list[str]] = {}
+ for m in re.finditer(r'', html, re.S):
+ block = re.search(r'Thay đổi', m.group(2), re.S)
+ if not block:
+ continue
+ out[m.group(1)] = [
+ re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip()
+ for li in re.findall(r"(.*?)", block.group(1), re.S)]
+ return out
+
+
+def build():
+ sandbox = _isolate_home()
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication.instance() or QApplication([])
+ _load_fonts()
+ _freeze_schedulers()
+
+ _apply_theme(app) # measure the styled window, not a bare one
+ from cowork_local.config import AppConfig, CONFIG_DIR
+ assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
+
+ from seed_demo_data import seed
+ seed()
+
+ from cowork_local.app import MainWindow
+ from cowork_local.i18n import set_language
+ from cowork_local.state import AppContext
+
+ set_language("vi")
+ win = MainWindow(AppContext(AppConfig.load()), user_name="local")
+ win.resize(1600, 900)
+ win.show()
+ for _ in range(8):
+ app.processEvents()
+ return app, win
+
+
+def main() -> int:
+ app, win = build()
+ ws = win.workspace
+
+ def goto(sub):
+ win._goto(win._ROW_WORKSPACE, sub)
+ for _ in range(6):
+ app.processEvents()
+
+ def page(row):
+ win._goto(row, None)
+ for _ in range(6):
+ app.processEvents()
+ return win._page_widgets[row]
+
+ import cowork_local.ui.co4e_tab as co4e_mod
+ co4e = win.findChildren(co4e_mod.Co4ETab)[0]
+ dash = page(win._ROW_DASHBOARD)
+ mon = page(win._ROW_MONITORING)
+ sched = page(win._ROW_SCHEDULE)
+ goto(ws._cowork_tab_idx)
+ chat = ws._cowork
+ dock = win.help_agent
+
+ def rows_of(widget, names):
+ """How many distinct y-bands the named widgets occupy."""
+ bands = set()
+ for n in names:
+ w = getattr(widget, n, None)
+ if w is not None:
+ bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12))
+ return len(bands)
+
+ # (slug, proposal, verdict, evidence)
+ R: list[tuple[str, str, str, str]] = []
+
+ def add(slug, text, ok, ev, other=None):
+ R.append((slug, text, other or ("OK" if ok else "CHUA"), ev))
+
+ # --- 1 Dashboard ---
+ n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn",
+ "currency_combo"])
+ add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng")
+ # Taller than the small tiles AND a bigger number = it reads as the headline.
+ taller = dash.card_cost.height() > dash.card_total.height() * 1.5
+ bigger = "34px" in dash.card_cost.value_lbl.styleSheet()
+ add("dashboard", "Chi phí làm thẻ chính", taller and bigger,
+ f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · "
+ f"cỡ số {'34px' if bigger else 'như cũ'}")
+
+ # --- 2 Schedule Kanban ---
+ lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or []))
+ if not lanes:
+ from cowork_local.core.tasks import STATUSES
+ lanes = len(STATUSES)
+ add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane")
+ has_combo = getattr(sched, "view_combo", None) is not None
+ add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
+ "vẫn là combo" if has_combo else "đã thành tab")
+ # The lane is only outlined while it actually holds something — seed data
+ # may leave it empty, so drop a card in and read the style back.
+ run_col = sched.columns.get("running")
+ styled = ""
+ if run_col is not None:
+ from PySide6.QtWidgets import QListWidgetItem
+ run_col.addItem(QListWidgetItem("probe"))
+ sched.column_headers["running"].setStyleSheet("")
+ sched.refresh()
+ app.processEvents()
+ styled = run_col.styleSheet()
+ add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
+ styled or "không có viền")
+
+ # --- 4/5 Workspace ---
+ add("workspace-project", "History lên sidebar thành RECENTS",
+ win.nav_recents.topLevelItemCount() > 0,
+ f"{win.nav_recents.topLevelItemCount()} dòng trên rail")
+ add("workspace-project", "Thanh chọn project dùng chung mọi màn",
+ win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail")
+ goto(ws._co4e_tab_idx)
+ hdr_off = ws._header.isHidden()
+ goto(ws._project_tab_idx)
+ hdr_on = not ws._header.isHidden()
+ add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
+ "chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
+ # The design's own wireframes draw the rail on every screen and a different
+ # in-page pane per screen, so "the fixed left pane" is the rail — which now
+ # carries the project picker and RECENTS on all of them.
+ fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
+ win.nav_recents.topLevelItemCount() > 0
+ add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
+ "rail (project + RECENTS) không đổi theo màn")
+ add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
+ win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
+ add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
+ any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all")
+ for i in range(win.nav_recents.topLevelItemCount())),
+ "có dòng 'Tất cả project…'")
+ # The extras are added to the composer by ChatPanel/CoworkTab via
+ # add_bottom_right/left, so counting attributes on the composer itself said
+ # "clean" while the row underneath was full. Count the row instead.
+ # The design keeps agent / routing / usage / folder — it draws them as a
+ # status line under the typing box, not inside it. So the test is that the
+ # TYPING row holds only input + attach/send/stop, and the rest sits in its
+ # own strip below. Demanding an empty strip would mean deleting features.
+ composer = getattr(chat, "composer", None)
+ bar = getattr(composer, "extra_bar", None)
+ from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
+ typing = composer.input
+ in_typing_row = typing.parentWidget() is composer
+ below = bar is not None and bar.objectName() == "composerStatus"
+ usage = getattr(chat, "_usage_total_lbl", None)
+ usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage)
+ add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi",
+ below and usage_in_bar,
+ f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · "
+ f"{bar.layout().count() if bar else 0} mục")
+
+ # --- 6 Co4E ---
+ add("workspace-co4e", "Bỏ dải tab flow",
+ not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn")
+ add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách",
+ len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng")
+ add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải",
+ not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải")
+
+ # --- 7 Folder / 8 GraphRAG ---
+ folder = ws.tabs.widget(ws._folder_tab_idx)
+ title_lbl = getattr(folder, "path_lbl", None)
+ add("workspace-folder", "Path bar gộp vào tiêu đề",
+ title_lbl is not None and getattr(folder, "path_edit", None) is None,
+ f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập")
+ # "Thin bar at the bottom" = the terminal is the last thing in the column
+ # and starts collapsed; the AI panel is a hideable right-hand pane.
+ # Geometry is meaningless for a page that has never been shown, so ask the
+ # widgets what state they are in instead of how tall they currently are.
+ term = getattr(folder, "terminal", None)
+ lay = folder.layout()
+ last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
+ collapsed = term is not None and term._body.isHidden()
+ at_bottom = term is not None and last is term
+ add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
+ collapsed and at_bottom,
+ f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
+ graph = ws.tabs.widget(ws._graphrag_tab_idx)
+ # One row = the path box and Export share a y-band.
+ def band(w):
+ return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
+ one_row = band(graph.path_edit) == band(graph._export_btn)
+ add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row,
+ f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}")
+ # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
+ # _msg_btn — a stale name); while it exists, this is still one button whose
+ # label flips, not a pair of tabs.
+ toggle = getattr(graph, "_msgs_toggle_btn", None)
+ add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None,
+ "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab")
+
+ # --- 9/15 Monitoring ---
+ from PySide6.QtWidgets import QHBoxLayout, QScrollArea
+ from PySide6.QtWidgets import QSpinBox as QSpinBoxT
+ ov = mon.findChildren(QScrollArea)[0].widget()
+ one_col = not isinstance(ov.layout(), QHBoxLayout)
+ add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col,
+ "cột dọc" if one_col else "vẫn 2 cột")
+ # Its own section = it is a direct child of the single column, not sharing a
+ # row with the resource meters as it used to.
+ own = ov.layout().indexOf(mon.ov_pricing_group) >= 0
+ add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own,
+ f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px")
+ strip = not mon.tabs.tabBar().isHidden()
+ add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng",
+ strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab")
+
+ # --- 17/18 dialogs ---
+ from cowork_local.ui.settings_dialog import SettingsDialog
+ from cowork_local.ui.task_editor_dialog import TaskEditorDialog
+ s = SettingsDialog(win.ctx)
+ add("dialog-settings", "Thêm cột mục lục bên trái",
+ s.section_list.count() == 5, f"{s.section_list.count()} mục")
+ # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider
+ # left as its own group — not everything merged together.
+ from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch
+ in_general = s._general_box.isAncestorOf(s.language_combo) and \
+ s._general_box.isAncestorOf(s.theme_combo)
+ prov_apart = not s._general_box.isAncestorOf(s.provider_combo)
+ add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng",
+ in_general and prov_apart,
+ f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}")
+ n_switch = len(s.findChildren(ToggleSwitch))
+ n_seg = len(s.findChildren(SegmentedControl))
+ steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2]
+ add("dialog-settings",
+ "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper",
+ n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1,
+ f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper")
+ s.close()
+ t = TaskEditorDialog(ctx=win.ctx)
+ rows = [t.section_list.item(i).text() for i in range(t.section_list.count())]
+ like_settings = (t.section_list.count() == 5
+ and t.section_stack.count() == 5
+ and not hasattr(t, "step_tabs"))
+ add("dialog-task-editor",
+ "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group",
+ like_settings, " · ".join(rows))
+ t.close()
+
+ # --- 27 help dock ---
+ add("overlay-help-panel", "Một chấm 26px, không chữ",
+ dock.width() <= 30 and not dock.launcher.text().strip(), f"{dock.width()}px")
+ from cowork_local.i18n import tr
+ dock.launcher._set_open(True)
+ app.processEvents()
+ add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'",
+ tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip())
+ dock.launcher._set_open(False)
+ items = [a.text() for a in dock.more_btn.menu().actions()]
+ add("overlay-help-panel", "'Ẩn trợ lý' dời vào menu ⋯",
+ tr("help_agent.hide_tooltip") in items, str(items))
+ add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn")
+ dock._hide_to_edge()
+ app.processEvents()
+ add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px")
+ dock._show_launcher()
+ app.processEvents()
+ goto(ws._cowork_tab_idx)
+ comp = chat.composer
+ dock_top = dock.mapTo(win, dock.rect().topLeft()).y()
+ comp_top = comp.mapTo(win, comp.rect().topLeft()).y()
+ add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập",
+ dock_top + dock.height() <= comp_top,
+ f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}")
+
+ # --- coverage: is every bullet ON THE PAGE actually probed? -------------
+ proposals = page_proposals()
+ probed = {}
+ for slug, text, _v, _e in R:
+ probed.setdefault(slug, 0)
+ probed[slug] += 1
+ gaps = []
+ for slug, bullets in proposals.items():
+ n_probe = probed.get(slug, 0)
+ if len(bullets) > n_probe:
+ for extra in bullets[n_probe:]:
+ gaps.append((slug, extra))
+
+ # --- report ---
+ order = ["OK", "KHAC", "CHUA", "TAY"]
+ counts = {k: 0 for k in order}
+ cur = None
+ for slug, text, verdict, ev in R:
+ counts[verdict] = counts.get(verdict, 0) + 1
+ if slug != cur:
+ print(f"\n{slug}")
+ cur = slug
+ print(f" [{verdict:4}] {text}")
+ print(f" {ev}")
+ if gaps:
+ print()
+ print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***")
+ for slug, text in gaps:
+ print(f" {slug}")
+ print(f" {text[:160]}")
+ print()
+ print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}"
+ f" · da co phep do : {len(R)}")
+ print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order))
+ print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})")
+ print(f" KHAC = co y lam khac, da ghi ly do")
+ print(f" CHUA = chua lam")
+ # This used to return 0 unconditionally — a report, not a check. Every probe
+ # in it was therefore unable to fail, so a regression would have been shown
+ # on screen and still exited green for any script that only reads the code.
+ return 1 if counts.get("CHUA") else 0
+
+
+if __name__ == "__main__":
+ _rc = main()
+ # Qt (WebEngine especially) crashes during interpreter teardown with
+ # 0xC0000409 AFTER the work is done, which would mask the real result —
+ # and check_probes_bite reads these exit codes to decide whether a probe
+ # caught its mutation. Leave immediately with the verdict instead.
+ sys.stdout.flush()
+ sys.stderr.flush()
+ os._exit(_rc)
diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py
index 72be70f..47ad800 100644
--- a/tools/check_dialogs.py
+++ b/tools/check_dialogs.py
@@ -11,6 +11,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -18,7 +20,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402
# Every field each dialog must still offer after the move.
SETTINGS_FIELDS = [
@@ -78,6 +80,7 @@ def main() -> int:
app = QApplication([])
_load_fonts()
+ _apply_theme(app) # measure the styled widget, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_help_dock.py b/tools/check_help_dock.py
index d3da0d8..150b881 100644
--- a/tools/check_help_dock.py
+++ b/tools/check_help_dock.py
@@ -11,6 +11,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -18,7 +20,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402
def main() -> int:
@@ -28,6 +30,7 @@ def main() -> int:
app = QApplication([])
_load_fonts()
+ _apply_theme(app) # measure the styled widget, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_help_i18n.py b/tools/check_help_i18n.py
new file mode 100644
index 0000000..adc9f4e
--- /dev/null
+++ b/tools/check_help_i18n.py
@@ -0,0 +1,80 @@
+"""The help panel must follow a language switch — transcript included.
+
+retranslate() re-labelled the chrome but not the rendered HTML transcript, so
+the greeting and the "AI Assistant" speaker label stayed in the language the
+panel happened to be built in.
+"""
+from __future__ import annotations
+
+import os
+import re
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from capture_screens import ( # noqa: E402
+ _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
+
+
+def main() -> int:
+ sandbox = _isolate_home()
+ from PySide6.QtWidgets import QApplication
+
+ sys.stdout.reconfigure(encoding="utf-8") # ja/vi text on a cp932 console
+ app = QApplication([])
+ _load_fonts()
+ _freeze_schedulers()
+ _apply_theme(app)
+
+ from cowork_local.config import CONFIG_DIR
+ assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
+
+ from seed_demo_data import seed
+ seed()
+
+ from cowork_local.app import MainWindow
+ from cowork_local.config import AppConfig
+ from cowork_local.i18n import set_language
+ from cowork_local.state import AppContext
+
+ set_language("vi")
+ win = MainWindow(AppContext(AppConfig.load()), user_name="local")
+ win.resize(1400, 900)
+ win.show()
+ app.processEvents()
+
+ panel = win.help_agent
+ fails = []
+ for lang in ("en", "ja", "vi"):
+ win._set_language(lang) if hasattr(win, "_set_language") else set_language(lang)
+ if not hasattr(win, "_set_language"):
+ panel.retranslate()
+ app.processEvents()
+
+ # The panel greets by the signed-in name, not the placeholder.
+ want = panel._greeting()
+ shown = re.sub("<[^>]+>", " ", panel.log.toHtml())
+ shown = " ".join(shown.split())
+ # compare on the stable half of the sentence, the name is substituted
+ probe = " ".join(want.split())[:28]
+ state = "ok" if probe and probe in shown else "MISSING"
+ print(f"{lang}: title={panel.title.text()!r} greeting={state}")
+ if state != "ok":
+ print(f" muon: {probe!r}")
+ print(f" thay: {shown[:160]!r}")
+ if state != "ok":
+ fails.append(f"{lang}: transcript still shows another language")
+
+ print()
+ print("PASS panel follows the language" if not fails
+ else "\n".join(f"FAIL {f}" for f in fails))
+ sys.stdout.flush()
+ os._exit(1 if fails else 0)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py
index 22ebe5d..abb9edc 100644
--- a/tools/check_layout_geometry.py
+++ b/tools/check_layout_geometry.py
@@ -1,203 +1,206 @@
-"""Round 2: does the built layout have the SHAPE the wireframes draw?
-
-Round 1 asks "does the feature exist". A screen can pass that and still be laid
-out wrongly — right widgets, wrong order, wrong side, wrong proportions. This
-round measures real geometry against what the audit page's wireframes depict:
-reading order of the rail, section order down Monitoring, which side each pane
-is on, and the size relationships the design calls out (hero card, 26px dot).
-
-Run: python tools/check_layout_geometry.py
-"""
-from __future__ import annotations
-
-import os
-import sys
-from pathlib import Path
-
-REPO = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(REPO.parent))
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-
-from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
-
-# The rail, top to bottom, as the audit page's rail() helper draws it.
-RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"]
-RAIL_BOTTOM = ["Dashboard", "Giám sát"]
-# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost →
-# what the machine is doing → what the agent may touch → per-model prices →
-# what actually happened.
-MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group",
- "ov_pricing_group", "ov_activity_group", "ov_audit_group"]
-
-
-def main() -> int:
- sandbox = _isolate_home()
- from PySide6.QtWidgets import QApplication
-
- app = QApplication([])
- _load_fonts()
- _freeze_schedulers()
-
- from cowork_local.config import AppConfig, CONFIG_DIR
- assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
-
- from seed_demo_data import seed
- seed()
-
- from cowork_local.app import MainWindow
- from cowork_local.i18n import set_language
- from cowork_local.state import AppContext
-
- set_language("vi")
- win = MainWindow(AppContext(AppConfig.load()), user_name="local")
- win.resize(1600, 950)
- win.show()
- for _ in range(8):
- app.processEvents()
- ws = win.workspace
- fails: list[str] = []
-
- def top_of(w, ref):
- return w.mapTo(ref, w.rect().topLeft()).y()
-
- def left_of(w, ref):
- return w.mapTo(ref, w.rect().topLeft()).x()
-
- # --- 1. rail: reading order, and the rail is on the LEFT ---------------
- rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
- bottom = [win.nav_bottom.topLevelItem(i).text(0)
- for i in range(win.nav_bottom.topLevelItemCount())]
- print(f"thanh menu : {rows}")
- print(f"nhom day : {bottom}")
- if rows != RAIL_ORDER:
- fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}")
- if bottom != RAIL_BOTTOM:
- fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}")
- rail_x = left_of(win._nav_wrap, win)
- content_x = left_of(win.pages, win)
- print(f"rail x={rail_x} · noi dung x={content_x}")
- if rail_x >= content_x:
- fails.append("rail khong nam ben trai noi dung")
-
- # --- 2. rail header order: picker ABOVE the new-chat button ------------
- py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win)
- ry = top_of(win.nav_recents, win)
- ay = top_of(win._account_row, win)
- print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}")
- if not (py < by < ry < ay):
- fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)")
-
- # --- 3. Monitoring: one column, sections in the drawn order ------------
- win._goto(win._ROW_MONITORING, None)
- for _ in range(8):
- app.processEvents()
- mon = win._page_widgets[win._ROW_MONITORING]
- tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)]
- lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops}
- print("Monitoring, tu tren xuong:")
- for n, y in tops:
- print(f" {n:28} y={y:5} x={lefts[n]}")
- if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]:
- fails.append("thu tu muc trong Monitoring khong khop ban ve")
- # Sandbox and Permissions share a row; everything else is full width.
- perm_y = top_of(mon.ov_permissions_group, mon)
- sbx_y = top_of(mon.ov_sandbox_details_group, mon)
- same_row = abs(perm_y - sbx_y) < 20
- print(f"Sandbox | Quyen cung hang: {same_row}")
- if not same_row:
- fails.append("Sandbox va Quyen khong cung mot hang")
- price_w = mon.ov_pricing_group.width()
- res_w = mon.ov_resource_group.width()
- print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)")
- if price_w < res_w * 0.95:
- fails.append("bang gia model khong chiem tron be ngang")
-
- # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll -----
- win._goto(win._ROW_SCHEDULE, None)
- for _ in range(8):
- app.processEvents()
- sched = win._page_widgets[win._ROW_SCHEDULE]
- from PySide6.QtWidgets import QScrollArea
- lanes = list(sched.columns.values())
- # The page holds more than one scroll area — take the one the lanes live in.
- board = next(sa for sa in sched.findChildren(QScrollArea)
- if sa.isAncestorOf(lanes[0]))
- rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes)
- fits = rightmost <= board.viewport().width() + 2
- print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · "
- f"khung rong {board.viewport().width()} · vua mot man = {fits}")
- if len(lanes) != 7:
- fails.append(f"chi co {len(lanes)} lane, thiet ke la 7")
- if not fits:
- fails.append(f"lane thu 7 nam ngoai man ({rightmost} > "
- f"{board.viewport().width()}) — phai cuon ngang")
-
- # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 --------
- win._goto(win._ROW_DASHBOARD, None)
- for _ in range(8):
- app.processEvents()
- dash = win._page_widgets[win._ROW_DASHBOARD]
- hero, small = dash.card_cost, dash.card_total
- print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · "
- f"the phu x={left_of(small, dash)} cao={small.height()}")
- if left_of(hero, dash) >= left_of(small, dash):
- fails.append("the Chi phi khong nam ben trai cac the phu")
- if hero.height() < small.height() * 1.5:
- fails.append("the Chi phi khong cao gap ruoi the phu")
- row1 = top_of(dash.card_total, dash)
- row2 = top_of(dash.card_out, dash)
- print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)")
- if row2 <= row1:
- fails.append("4 the phu khong xep 2x2")
-
- # --- 5. Cowork: the dot clears the composer, dot is 26px --------------
- win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx)
- for _ in range(8):
- app.processEvents()
- dock = win.help_agent
- comp = ws._cowork.composer
- dock_bottom = top_of(dock, win) + dock.height()
- comp_top = top_of(comp, win)
- print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}")
- if dock.width() > 30:
- fails.append(f"cham tro ly rong {dock.width()}px, thiet ke la 26px")
- if dock_bottom > comp_top:
- fails.append("cham tro ly de len o nhap")
- if left_of(dock, win) + dock.width() > win.width():
- fails.append("cham tro ly tran ra ngoai cua so")
-
- # --- 6. Co4E: sidebar left, canvas middle, config right ---------------
- win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx)
- for _ in range(8):
- app.processEvents()
- import cowork_local.ui.co4e_tab as co4e_mod
- c4 = win.findChildren(co4e_mod.Co4ETab)[0]
- xs = [c4._split.widget(i).x() for i in range(c4._split.count())]
- print(f"Co4E 3 pane x = {xs}")
- if xs != sorted(xs):
- fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)")
- heads = [h.text() for h, _b, _s in c4._sections.values()]
- print(f"cot sidebar: {heads}")
- if len(heads) != 4:
- fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4")
-
- print()
- if fails:
- print("*** LECH BO CUC ***")
- for f in fails:
- print(" " + f)
- return 1
- print("KET QUA VONG 2: hinh hoc khop ban ve")
- return 0
-
-
-if __name__ == "__main__":
- _rc = main()
- # Qt (WebEngine especially) crashes during interpreter teardown with
- # 0xC0000409 AFTER the work is done, which would mask the real result —
- # and check_probes_bite reads these exit codes to decide whether a probe
- # caught its mutation. Leave immediately with the verdict instead.
- sys.stdout.flush()
- sys.stderr.flush()
- os._exit(_rc)
+"""Round 2: does the built layout have the SHAPE the wireframes draw?
+
+Round 1 asks "does the feature exist". A screen can pass that and still be laid
+out wrongly — right widgets, wrong order, wrong side, wrong proportions. This
+round measures real geometry against what the audit page's wireframes depict:
+reading order of the rail, section order down Monitoring, which side each pane
+is on, and the size relationships the design calls out (hero card, 26px dot).
+
+Run: python tools/check_layout_geometry.py
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
+
+# The rail, top to bottom, as the audit page's rail() helper draws it.
+RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"]
+RAIL_BOTTOM = ["Dashboard", "Giám sát"]
+# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost →
+# what the machine is doing → what the agent may touch → per-model prices →
+# what actually happened.
+MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group",
+ "ov_pricing_group", "ov_activity_group", "ov_audit_group"]
+
+
+def main() -> int:
+ sandbox = _isolate_home()
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication([])
+ _load_fonts()
+ _freeze_schedulers()
+
+ _apply_theme(app) # measure the styled window, not a bare one
+ from cowork_local.config import AppConfig, CONFIG_DIR
+ assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
+
+ from seed_demo_data import seed
+ seed()
+
+ from cowork_local.app import MainWindow
+ from cowork_local.i18n import set_language
+ from cowork_local.state import AppContext
+
+ set_language("vi")
+ win = MainWindow(AppContext(AppConfig.load()), user_name="local")
+ win.resize(1600, 950)
+ win.show()
+ for _ in range(8):
+ app.processEvents()
+ ws = win.workspace
+ fails: list[str] = []
+
+ def top_of(w, ref):
+ return w.mapTo(ref, w.rect().topLeft()).y()
+
+ def left_of(w, ref):
+ return w.mapTo(ref, w.rect().topLeft()).x()
+
+ # --- 1. rail: reading order, and the rail is on the LEFT ---------------
+ rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
+ bottom = [win.nav_bottom.topLevelItem(i).text(0)
+ for i in range(win.nav_bottom.topLevelItemCount())]
+ print(f"thanh menu : {rows}")
+ print(f"nhom day : {bottom}")
+ if rows != RAIL_ORDER:
+ fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}")
+ if bottom != RAIL_BOTTOM:
+ fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}")
+ rail_x = left_of(win._nav_wrap, win)
+ content_x = left_of(win.pages, win)
+ print(f"rail x={rail_x} · noi dung x={content_x}")
+ if rail_x >= content_x:
+ fails.append("rail khong nam ben trai noi dung")
+
+ # --- 2. rail header order: picker ABOVE the new-chat button ------------
+ py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win)
+ ry = top_of(win.nav_recents, win)
+ ay = top_of(win._account_row, win)
+ print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}")
+ if not (py < by < ry < ay):
+ fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)")
+
+ # --- 3. Monitoring: one column, sections in the drawn order ------------
+ win._goto(win._ROW_MONITORING, None)
+ for _ in range(8):
+ app.processEvents()
+ mon = win._page_widgets[win._ROW_MONITORING]
+ tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)]
+ lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops}
+ print("Monitoring, tu tren xuong:")
+ for n, y in tops:
+ print(f" {n:28} y={y:5} x={lefts[n]}")
+ if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]:
+ fails.append("thu tu muc trong Monitoring khong khop ban ve")
+ # Sandbox and Permissions share a row; everything else is full width.
+ perm_y = top_of(mon.ov_permissions_group, mon)
+ sbx_y = top_of(mon.ov_sandbox_details_group, mon)
+ same_row = abs(perm_y - sbx_y) < 20
+ print(f"Sandbox | Quyen cung hang: {same_row}")
+ if not same_row:
+ fails.append("Sandbox va Quyen khong cung mot hang")
+ price_w = mon.ov_pricing_group.width()
+ res_w = mon.ov_resource_group.width()
+ print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)")
+ if price_w < res_w * 0.95:
+ fails.append("bang gia model khong chiem tron be ngang")
+
+ # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll -----
+ win._goto(win._ROW_SCHEDULE, None)
+ for _ in range(8):
+ app.processEvents()
+ sched = win._page_widgets[win._ROW_SCHEDULE]
+ from PySide6.QtWidgets import QScrollArea
+ lanes = list(sched.columns.values())
+ # The page holds more than one scroll area — take the one the lanes live in.
+ board = next(sa for sa in sched.findChildren(QScrollArea)
+ if sa.isAncestorOf(lanes[0]))
+ rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes)
+ fits = rightmost <= board.viewport().width() + 2
+ print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · "
+ f"khung rong {board.viewport().width()} · vua mot man = {fits}")
+ if len(lanes) != 7:
+ fails.append(f"chi co {len(lanes)} lane, thiet ke la 7")
+ if not fits:
+ fails.append(f"lane thu 7 nam ngoai man ({rightmost} > "
+ f"{board.viewport().width()}) — phai cuon ngang")
+
+ # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 --------
+ win._goto(win._ROW_DASHBOARD, None)
+ for _ in range(8):
+ app.processEvents()
+ dash = win._page_widgets[win._ROW_DASHBOARD]
+ hero, small = dash.card_cost, dash.card_total
+ print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · "
+ f"the phu x={left_of(small, dash)} cao={small.height()}")
+ if left_of(hero, dash) >= left_of(small, dash):
+ fails.append("the Chi phi khong nam ben trai cac the phu")
+ if hero.height() < small.height() * 1.5:
+ fails.append("the Chi phi khong cao gap ruoi the phu")
+ row1 = top_of(dash.card_total, dash)
+ row2 = top_of(dash.card_out, dash)
+ print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)")
+ if row2 <= row1:
+ fails.append("4 the phu khong xep 2x2")
+
+ # --- 5. Cowork: the dot clears the composer, dot is 26px --------------
+ win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx)
+ for _ in range(8):
+ app.processEvents()
+ dock = win.help_agent
+ comp = ws._cowork.composer
+ dock_bottom = top_of(dock, win) + dock.height()
+ comp_top = top_of(comp, win)
+ print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}")
+ if dock.width() > 30:
+ fails.append(f"cham tro ly rong {dock.width()}px, thiet ke la 26px")
+ if dock_bottom > comp_top:
+ fails.append("cham tro ly de len o nhap")
+ if left_of(dock, win) + dock.width() > win.width():
+ fails.append("cham tro ly tran ra ngoai cua so")
+
+ # --- 6. Co4E: sidebar left, canvas middle, config right ---------------
+ win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx)
+ for _ in range(8):
+ app.processEvents()
+ import cowork_local.ui.co4e_tab as co4e_mod
+ c4 = win.findChildren(co4e_mod.Co4ETab)[0]
+ xs = [c4._split.widget(i).x() for i in range(c4._split.count())]
+ print(f"Co4E 3 pane x = {xs}")
+ if xs != sorted(xs):
+ fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)")
+ heads = [h.text() for h, _b, _s in c4._sections.values()]
+ print(f"cot sidebar: {heads}")
+ if len(heads) != 4:
+ fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4")
+
+ print()
+ if fails:
+ print("*** LECH BO CUC ***")
+ for f in fails:
+ print(" " + f)
+ return 1
+ print("KET QUA VONG 2: hinh hoc khop ban ve")
+ return 0
+
+
+if __name__ == "__main__":
+ _rc = main()
+ # Qt (WebEngine especially) crashes during interpreter teardown with
+ # 0xC0000409 AFTER the work is done, which would mask the real result —
+ # and check_probes_bite reads these exit codes to decide whether a probe
+ # caught its mutation. Leave immediately with the verdict instead.
+ sys.stdout.flush()
+ sys.stderr.flush()
+ os._exit(_rc)
diff --git a/tools/check_multi_screen.py b/tools/check_multi_screen.py
index f159b19..b684efb 100644
--- a/tools/check_multi_screen.py
+++ b/tools/check_multi_screen.py
@@ -16,6 +16,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -23,7 +25,7 @@ sys.path.insert(0, str(REPO.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
# Real-world panels, from a small laptop up to 4K-at-150%-effective.
SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (2560, 1440)]
@@ -40,6 +42,7 @@ def main() -> int:
_load_fonts()
_freeze_schedulers()
+ _apply_theme(app) # measure the styled window, not a bare one
from cowork_local.config import AppConfig, CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_nav.py b/tools/check_nav.py
index 330b7aa..d5cd6da 100644
--- a/tools/check_nav.py
+++ b/tools/check_nav.py
@@ -15,6 +15,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
@@ -22,7 +24,7 @@ sys.path.insert(0, str(REPO.parent)) # `import cowork_loc
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling tools
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
+from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402
def rows(tree):
@@ -44,6 +46,7 @@ def main() -> int:
_load_fonts()
_freeze_schedulers()
+ _apply_theme(app) # measure the styled window, not a bare one
from cowork_local.config import CONFIG_DIR
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py
index 1d68c96..70bf2f9 100644
--- a/tools/check_no_hscroll.py
+++ b/tools/check_no_hscroll.py
@@ -1,123 +1,156 @@
-"""Prove the long dialogs never scroll sideways — including at large fonts.
-
-The report that started this came from a display at 125–150% scaling, where
-every label is wider than on a 100% screen. Rather than trusting one font size,
-this runs each dialog at several point sizes and several widths and fails if any
-horizontal scrollbar turns up, in the scroll area or in the section index.
-
-Run: python tools/check_no_hscroll.py
-"""
-from __future__ import annotations
-
-import os
-import sys
-from pathlib import Path
-
-REPO = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(REPO.parent))
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-
-from capture_screens import _isolate_home, _load_fonts # noqa: E402
-
-WIDTHS = (1100, 964, 820, 700)
-POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling
-
-
-def hscroll(dlg, app):
- """(scroll-area overflow, index overflow) — each True means content is
- wider than the space it is given.
-
- A dialog built from step tabs has one scroll area per page, and a page that
- is not current has stale geometry — so each tab is brought to the front
- before its page is measured.
- """
- from PySide6.QtWidgets import QListWidget, QScrollArea
- over_area = False
- stack = getattr(dlg, "section_stack", None)
- if stack is not None:
- # One scroll area per section; a page that is not current has stale
- # geometry, so bring each to the front before measuring it.
- idx = dlg.section_list
- keep = idx.currentRow()
- for i in range(stack.count()):
- idx.setCurrentRow(i)
- for _ in range(3):
- app.processEvents()
- sa = stack.widget(i)
- if sa.widget().sizeHint().width() > sa.viewport().width():
- over_area = True
- idx.setCurrentRow(keep)
- else:
- sa = dlg.findChildren(QScrollArea)[0]
- over_area = sa.widget().sizeHint().width() > sa.viewport().width()
- idx = dlg.findChild(QListWidget, "sectionIndex")
- over_idx = False
- if idx is not None:
- over_idx = idx.sizeHintForColumn(0) > idx.viewport().width()
- return over_area, over_idx
-
-
-def main() -> int:
- sandbox = _isolate_home()
- from PySide6.QtGui import QFont
- from PySide6.QtWidgets import QApplication
-
- app = QApplication([])
- _load_fonts()
-
- from cowork_local.config import AppConfig, CONFIG_DIR
- assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
-
- from cowork_local.i18n import set_language
- from cowork_local.state import AppContext
- from cowork_local.ui.settings_dialog import SettingsDialog
- from cowork_local.ui.task_editor_dialog import TaskEditorDialog
-
- set_language("vi")
- ctx = AppContext(AppConfig.load())
- fails: list[str] = []
-
- for pt in POINTS:
- f = QFont(app.font())
- f.setPointSize(pt)
- app.setFont(f)
- for name, make in (("Cai dat", lambda: SettingsDialog(ctx)),
- ("Task editor", lambda: TaskEditorDialog(ctx=ctx))):
- dlg = make()
- dlg.show()
- row = []
- for w in WIDTHS:
- dlg.resize(w, 900)
- for _ in range(4):
- app.processEvents()
- over_area, over_idx = hscroll(dlg, app)
- row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}")
- if over_area:
- fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang")
- if over_idx:
- fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang")
- print(f" {pt:>2}pt {name:12} {' '.join(row)}")
- dlg.close()
- print()
-
- print("A = vung cuon tran · I = muc luc tran · . = khong tran")
- print()
- if fails:
- print("*** LOI ***")
- for x in fails:
- print(" " + x)
- return 1
- print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu")
- return 0
-
-
-if __name__ == "__main__":
- _rc = main()
- # Qt (WebEngine especially) crashes during interpreter teardown with
- # 0xC0000409 AFTER the work is done, which would mask the real result —
- # and check_probes_bite reads these exit codes to decide whether a probe
- # caught its mutation. Leave immediately with the verdict instead.
- sys.stdout.flush()
- sys.stderr.flush()
- os._exit(_rc)
+"""Prove the long dialogs never scroll sideways — including at large fonts.
+
+The report that started this came from a display at 125–150% scaling, where
+every label is wider than on a 100% screen. Rather than trusting one font size,
+this runs each dialog at several point sizes and several widths and fails if any
+horizontal scrollbar turns up, in the scroll area or in the section index.
+
+Run: python tools/check_no_hscroll.py
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402
+
+WIDTHS = (1100, 964, 820, 700)
+POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling
+
+
+def hscroll(dlg, app):
+ """(scroll-area overflow, index overflow) — each True means content is
+ wider than the space it is given.
+
+ A dialog built from step tabs has one scroll area per page, and a page that
+ is not current has stale geometry — so each tab is brought to the front
+ before its page is measured.
+ """
+ from PySide6.QtWidgets import QListWidget, QScrollArea
+ over_area = False
+ stack = getattr(dlg, "section_stack", None)
+ if stack is not None:
+ # One scroll area per section; a page that is not current has stale
+ # geometry, so bring each to the front before measuring it.
+ idx = dlg.section_list
+ keep = idx.currentRow()
+ for i in range(stack.count()):
+ idx.setCurrentRow(i)
+ for _ in range(3):
+ app.processEvents()
+ sa = stack.widget(i)
+ if sa.widget().sizeHint().width() > sa.viewport().width():
+ over_area = True
+ idx.setCurrentRow(keep)
+ else:
+ sa = dlg.findChildren(QScrollArea)[0]
+ over_area = sa.widget().sizeHint().width() > sa.viewport().width()
+ idx = dlg.findChild(QListWidget, "sectionIndex")
+ over_idx = False
+ if idx is not None:
+ over_idx = _index_elides(idx)
+ return over_area, over_idx
+
+
+def _index_elides(idx) -> bool:
+ """True when a section name does not fit the visible width of the list.
+
+ Two earlier attempts got this wrong:
+ · `sizeHintForColumn(0) > viewport().width()` returns 182px at 9pt, 11pt
+ and 14pt alike — it does not track the font, so it called the 9pt
+ dialog broken while nothing on screen was clipped.
+ · Asking the delegate whether it elides. It does not: the view lays each
+ row out at its natural width and the viewport simply clips what runs
+ past it, so a list squeezed to 90px still reported "no elision".
+
+ So compare the painted text against the width that is actually on screen.
+ """
+ from PySide6.QtGui import QFontMetrics
+ from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
+
+ for i in range(idx.count()):
+ row = idx.indexFromItem(idx.item(i))
+ opt = QStyleOptionViewItem()
+ idx.initViewItemOption(opt)
+ opt.rect = idx.visualRect(row)
+ idx.itemDelegate().initStyleOption(opt, row)
+ box = idx.style().subElementRect(QStyle.SE_ItemViewItemText, opt, idx)
+ label = idx.item(i).text()
+ visible = idx.viewport().width() - box.left()
+ if QFontMetrics(opt.font).horizontalAdvance(label) > visible:
+ return True
+ return False
+
+
+def main() -> int:
+ sandbox = _isolate_home()
+ from PySide6.QtGui import QFont
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication([])
+ _load_fonts()
+
+ _apply_theme(app) # measure the styled widget, not a bare one
+ from cowork_local.config import AppConfig, CONFIG_DIR
+ assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
+
+ from cowork_local.i18n import set_language
+ from cowork_local.state import AppContext
+ from cowork_local.ui.settings_dialog import SettingsDialog
+ from cowork_local.ui.task_editor_dialog import TaskEditorDialog
+
+ set_language("vi")
+ ctx = AppContext(AppConfig.load())
+ fails: list[str] = []
+
+ for pt in POINTS:
+ f = QFont(app.font())
+ f.setPointSize(pt)
+ app.setFont(f)
+ for name, make in (("Cai dat", lambda: SettingsDialog(ctx)),
+ ("Task editor", lambda: TaskEditorDialog(ctx=ctx))):
+ dlg = make()
+ dlg.show()
+ row = []
+ for w in WIDTHS:
+ dlg.resize(w, 900)
+ for _ in range(4):
+ app.processEvents()
+ over_area, over_idx = hscroll(dlg, app)
+ row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}")
+ if over_area:
+ fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang")
+ if over_idx:
+ fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang")
+ print(f" {pt:>2}pt {name:12} {' '.join(row)}")
+ dlg.close()
+ print()
+
+ print("A = vung cuon tran · I = muc luc tran · . = khong tran")
+ print()
+ if fails:
+ print("*** LOI ***")
+ for x in fails:
+ print(" " + x)
+ return 1
+ print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu")
+ return 0
+
+
+if __name__ == "__main__":
+ _rc = main()
+ # Qt (WebEngine especially) crashes during interpreter teardown with
+ # 0xC0000409 AFTER the work is done, which would mask the real result —
+ # and check_probes_bite reads these exit codes to decide whether a probe
+ # caught its mutation. Leave immediately with the verdict instead.
+ sys.stdout.flush()
+ sys.stderr.flush()
+ os._exit(_rc)
diff --git a/tools/check_orphans.py b/tools/check_orphans.py
index 4337a8e..83fd7a5 100644
--- a/tools/check_orphans.py
+++ b/tools/check_orphans.py
@@ -20,6 +20,8 @@ import ast
import io
import json
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py
index a8fa372..bf0cff3 100644
--- a/tools/check_probes_bite.py
+++ b/tools/check_probes_bite.py
@@ -1,130 +1,132 @@
-"""Round 5: do the checks actually bite?
-
-Rounds 1–4 all report green. That is only worth something if the checks would
-have turned red had the work not been done. So this round breaks the app on
-purpose, one feature at a time, and fails if the corresponding check still
-passes — a check that cannot fail is not evidence.
-
-Each mutation is applied by monkey-patching the module BEFORE the checker
-builds its own window, then undone.
-
-Run: python tools/check_probes_bite.py
-"""
-from __future__ import annotations
-
-import io
-import os
-import runpy
-import subprocess
-import sys
-from pathlib import Path
-
-REPO = Path(__file__).resolve().parent.parent
-sys.path.insert(0, str(REPO.parent))
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-
-# (name, file, find, replace, checker that must FAIL because of it)
-MUTATIONS = [
- ("bo cham tro ly 26px -> 64px",
- "ui/help_agent_widget.py", "_DOT = 26", "_DOT = 64",
- "check_layout_geometry.py"),
- ("tra lane Running ve khong vien",
- "ui/schedule_task_tab.py",
- 'if status == "running" and counts[status]:',
- 'if False:',
- "check_design_parity.py"),
- ("bo cot muc luc cua Cai dat",
- "ui/settings_dialog.py",
- "self.section_list, self.section_stack = section_panels(pages)",
- "self.section_list, self.section_stack = section_panels(pages[:1])",
- "check_dialogs.py"),
- ("noi lai dai tab flow Co4E",
- "ui/co4e_tab.py",
- "self.flow_scroll.setVisible(False)",
- "self.flow_scroll.setVisible(True)",
- "check_co4e.py"),
- ("bo dong 'Tat ca project...' khoi GAN DAY",
- "app.py",
- 'more.setData(0, Qt.UserRole, {"all": True})',
- 'more.setData(0, Qt.UserRole, {})',
- "check_design_parity.py"),
- ("tra thanh menu ve accordion (bo nhom day)",
- "app.py",
- 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
- 'rows.append((self.nav, self._ROW_DASHBOARD, None,',
- "check_layout_geometry.py"),
-]
-
-
-def run_checker(script: str) -> int:
- """Run a checker in a fresh process; return its exit code."""
- proc = subprocess.run(
- [sys.executable, str(REPO / "tools" / script)],
- cwd=REPO, capture_output=True, text=True, encoding="utf-8",
- errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
- "PYTHONIOENCODING": "utf-8"})
- return proc.returncode
-
-
-def tree_state() -> str:
- return subprocess.run(["git", "status", "--short"], cwd=REPO,
- capture_output=True, text=True).stdout.strip()
-
-
-def main() -> int:
- fails: list[str] = []
- # Compare the tree BEFORE and AFTER, not against a clean tree: work in
- # progress is legitimately uncommitted, and demanding a clean tree made this
- # round fail for a reason that has nothing to do with the mutations.
- before = tree_state()
- print(f"{'hong gi':44} {'phep do':26} ket qua")
- print("-" * 88)
- for name, rel, find, repl, checker in MUTATIONS:
- path = REPO / rel
- # newline="" both ways: the default translates on read AND write, so a
- # LF file came back as CRLF and every mutated file was left "modified"
- # even after being restored.
- with io.open(path, "r", encoding="utf-8", newline="") as fh:
- original = fh.read()
- if find not in original:
- fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
- print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
- continue
-
- def write(text: str) -> None:
- with io.open(path, "w", encoding="utf-8", newline="") as fh:
- fh.write(text)
-
- write(original.replace(find, repl, 1))
- try:
- code = run_checker(checker)
- finally:
- write(original) # always restore
- bit = code != 0
- print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
- if not bit:
- fails.append(f"{name}: {checker} van bao xanh du da lam hong")
-
- # Everything must be back exactly as it was before this run.
- after = tree_state()
- same = after == before
- print()
- print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
- if not same:
- print(" truoc:", before.replace("\n", " | ") or "(sach)")
- print(" sau :", after.replace("\n", " | ") or "(sach)")
- fails.append("file chua duoc khoi phuc sau khi thu")
-
- print()
- if fails:
- print("*** VONG 5 THAT BAI ***")
- for f in fails:
- print(" " + f)
- return 1
- print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
+"""Round 5: do the checks actually bite?
+
+Rounds 1–4 all report green. That is only worth something if the checks would
+have turned red had the work not been done. So this round breaks the app on
+purpose, one feature at a time, and fails if the corresponding check still
+passes — a check that cannot fail is not evidence.
+
+Each mutation is applied by monkey-patching the module BEFORE the checker
+builds its own window, then undone.
+
+Run: python tools/check_probes_bite.py
+"""
+from __future__ import annotations
+
+import io
+import os
+import runpy
+import subprocess
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+# (name, file, find, replace, checker that must FAIL because of it)
+MUTATIONS = [
+ ("bo cham tro ly 26px -> 64px",
+ "ui/help_agent_widget.py", "_DOT = 26", "_DOT = 64",
+ "check_layout_geometry.py"),
+ ("tra lane Running ve khong vien",
+ "ui/schedule_task_tab.py",
+ 'if status == "running" and counts[status]:',
+ 'if False:',
+ "check_design_parity.py"),
+ ("bo cot muc luc cua Cai dat",
+ "ui/settings_dialog.py",
+ "self.section_list, self.section_stack = section_panels(pages)",
+ "self.section_list, self.section_stack = section_panels(pages[:1])",
+ "check_dialogs.py"),
+ ("noi lai dai tab flow Co4E",
+ "ui/co4e_tab.py",
+ "self.flow_scroll.setVisible(False)",
+ "self.flow_scroll.setVisible(True)",
+ "check_co4e.py"),
+ ("bo dong 'Tat ca project...' khoi GAN DAY",
+ "app.py",
+ 'more.setData(0, Qt.UserRole, {"all": True})',
+ 'more.setData(0, Qt.UserRole, {})',
+ "check_design_parity.py"),
+ ("tra thanh menu ve accordion (bo nhom day)",
+ "app.py",
+ 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
+ 'rows.append((self.nav, self._ROW_DASHBOARD, None,',
+ "check_layout_geometry.py"),
+]
+
+
+def run_checker(script: str) -> int:
+ """Run a checker in a fresh process; return its exit code."""
+ proc = subprocess.run(
+ [sys.executable, str(REPO / "tools" / script)],
+ cwd=REPO, capture_output=True, text=True, encoding="utf-8",
+ errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
+ "PYTHONIOENCODING": "utf-8"})
+ return proc.returncode
+
+
+def tree_state() -> str:
+ return subprocess.run(["git", "status", "--short"], cwd=REPO,
+ capture_output=True, text=True).stdout.strip()
+
+
+def main() -> int:
+ fails: list[str] = []
+ # Compare the tree BEFORE and AFTER, not against a clean tree: work in
+ # progress is legitimately uncommitted, and demanding a clean tree made this
+ # round fail for a reason that has nothing to do with the mutations.
+ before = tree_state()
+ print(f"{'hong gi':44} {'phep do':26} ket qua")
+ print("-" * 88)
+ for name, rel, find, repl, checker in MUTATIONS:
+ path = REPO / rel
+ # newline="" both ways: the default translates on read AND write, so a
+ # LF file came back as CRLF and every mutated file was left "modified"
+ # even after being restored.
+ with io.open(path, "r", encoding="utf-8", newline="") as fh:
+ original = fh.read()
+ if find not in original:
+ fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
+ print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
+ continue
+
+ def write(text: str) -> None:
+ with io.open(path, "w", encoding="utf-8", newline="") as fh:
+ fh.write(text)
+
+ write(original.replace(find, repl, 1))
+ try:
+ code = run_checker(checker)
+ finally:
+ write(original) # always restore
+ bit = code != 0
+ print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
+ if not bit:
+ fails.append(f"{name}: {checker} van bao xanh du da lam hong")
+
+ # Everything must be back exactly as it was before this run.
+ after = tree_state()
+ same = after == before
+ print()
+ print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
+ if not same:
+ print(" truoc:", before.replace("\n", " | ") or "(sach)")
+ print(" sau :", after.replace("\n", " | ") or "(sach)")
+ fails.append("file chua duoc khoi phuc sau khi thu")
+
+ print()
+ if fails:
+ print("*** VONG 5 THAT BAI ***")
+ for f in fails:
+ print(" " + f)
+ return 1
+ print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/check_rail_align.py b/tools/check_rail_align.py
new file mode 100644
index 0000000..c64b1c2
--- /dev/null
+++ b/tools/check_rail_align.py
@@ -0,0 +1,176 @@
+"""Rail icons must sit on one vertical line, and stay there when it collapses.
+
+Two bugs this catches:
+ · "Cài đặt" sat 42px right of "Dashboard"/"Giám sát" — its QSS margin pushed
+ the button in while the tree rows above start at the rail edge.
+ · Collapsing re-placed the icon of every label-less button, sliding + to the
+ middle of the 54px rail.
+
+Runs with the app's real stylesheet loaded. Without it the window has no
+padding, margins or borders and neither bug is visible — see _apply_theme.
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from capture_screens import ( # noqa: E402
+ _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
+
+TOL = 2 # px; anti-aliasing on an icon edge
+
+
+def main() -> int:
+ sandbox = _isolate_home()
+ from PySide6.QtCore import QPoint
+ from PySide6.QtWidgets import QApplication
+
+ app = QApplication([])
+ _load_fonts()
+ _freeze_schedulers()
+ theme_name = _apply_theme(app)
+
+ from cowork_local.config import CONFIG_DIR
+ assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
+
+ from seed_demo_data import seed
+ seed()
+
+ from cowork_local.app import MainWindow
+ from cowork_local.config import AppConfig
+ from cowork_local.i18n import set_language
+ from cowork_local.state import AppContext
+
+ set_language("vi")
+ win = MainWindow(AppContext(AppConfig.load()), user_name="local")
+ win.resize(1400, 900)
+ win.show()
+ app.processEvents()
+ rail = win._nav_wrap
+
+ def ink_x(w):
+ """Leftmost painted pixel of a widget, in rail coordinates."""
+ img = w.grab().toImage()
+ bg = img.pixelColor(w.width() - 3, 2)
+ for x in range(img.width()):
+ for y in range(2, img.height() - 2):
+ c = img.pixelColor(x, y)
+ if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
+ + abs(c.blue() - bg.blue())) > 60:
+ return w.mapTo(rail, QPoint(x, 0)).x()
+ return None
+
+ def tree_text_x(tree):
+ from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
+ index = tree.indexFromItem(tree.topLevelItem(0), 0)
+ opt = QStyleOptionViewItem()
+ tree.initViewItemOption(opt)
+ opt.rect = tree.visualRect(index)
+ tree.itemDelegate().initStyleOption(opt, index)
+ txt = tree.style().subElementRect(QStyle.SE_ItemViewItemText, opt, tree)
+ return tree.mapTo(rail, QPoint(txt.left(), 0)).x()
+
+ def btn_text_x(w):
+ """Left edge of the label: first ink past the icon's gap."""
+ from PySide6.QtGui import QIcon
+ if not w.text():
+ return None
+ img = w.grab().toImage()
+ bg = img.pixelColor(w.width() - 3, 2)
+ ink = []
+ for x in range(img.width()):
+ for y in range(2, img.height() - 2):
+ c = img.pixelColor(x, y)
+ if (abs(c.red() - bg.red()) + abs(c.green() - bg.green())
+ + abs(c.blue() - bg.blue())) > 60:
+ ink.append(x)
+ break
+ if not ink:
+ return None
+ for a, b in zip(ink, ink[1:]): # first gap = icon/label spacing
+ if b - a > 2:
+ return w.mapTo(rail, QPoint(b, 0)).x()
+ return None
+
+ def tree_icon_x(tree):
+ from PySide6.QtWidgets import QStyle, QStyleOptionViewItem
+ index = tree.indexFromItem(tree.topLevelItem(0), 0)
+ opt = QStyleOptionViewItem()
+ tree.initViewItemOption(opt)
+ opt.rect = tree.visualRect(index)
+ tree.itemDelegate().initStyleOption(opt, index)
+ deco = tree.style().subElementRect(
+ QStyle.SE_ItemViewItemDecoration, opt, tree)
+ return tree.mapTo(rail, QPoint(deco.left(), 0)).x()
+
+ def snapshot():
+ app.processEvents()
+ out = {"nav rows": tree_icon_x(win.nav),
+ "bottom rows": tree_icon_x(win.nav_bottom),
+ "bottom rows text": tree_text_x(win.nav_bottom)}
+ for label, attr in (("MENU", "_nav_toggle_btn"),
+ ("new chat", "nav_new_chat"),
+ ("settings", "_nav_settings_btn")):
+ w = getattr(win, attr, None)
+ if w is not None and w.isVisible():
+ out[label] = ink_x(w)
+ if label == "settings":
+ out["settings text"] = btn_text_x(w)
+ return out
+
+ fails = []
+ for theme_name in ("dark", "light"):
+ _apply_theme(app, theme_name)
+ app.processEvents()
+ if win._nav_collapsed:
+ win._toggle_nav()
+ opened = snapshot()
+ win._toggle_nav()
+ app.processEvents()
+ closed = snapshot()
+ win._toggle_nav()
+ app.processEvents()
+ fails += compare(theme_name, rail, opened, closed)
+
+ print()
+ for f in fails:
+ print(f"FAIL {f}")
+ print("PASS every rail icon holds its line" if not fails
+ else f"{len(fails)} problem(s)")
+ sys.stdout.flush()
+ os._exit(1 if fails else 0)
+
+
+def compare(theme_name, rail, opened, closed):
+ print()
+ print(f"theme={theme_name}")
+ print(f"{'element':<12}{'open':>7}{'collapsed':>11}{'drift':>8}")
+ fails = []
+ for key in opened:
+ a, b = opened[key], closed.get(key)
+ drift = "-" if a is None or b is None else f"{b - a:+d}"
+ print(f"{key:<12}{str(a):>7}{str(b):>11}{drift:>8}")
+ if a is not None and b is not None and abs(b - a) > TOL:
+ fails.append(f"{key}: icon moves {b - a:+d}px when the rail collapses")
+
+ # Settings is a button but reads as one more row in the bottom list, so
+ # both its icon and its label have to start where theirs do.
+ for state, snap in (("open", opened), ("collapsed", closed)):
+ for what in ("", " text"):
+ ref, got = snap.get("bottom rows" + what), snap.get("settings" + what)
+ if ref is not None and got is not None and abs(got - ref) > TOL:
+ fails.append(f"{theme_name} {state}: settings{what} x={got} but "
+ f"the rows above it start at x={ref}")
+ return fails
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/check_responsive.py b/tools/check_responsive.py
index 31a7654..de41bb6 100644
--- a/tools/check_responsive.py
+++ b/tools/check_responsive.py
@@ -13,6 +13,8 @@ from __future__ import annotations
import os
import sys
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py
index 016982e..b90b28c 100644
--- a/ui/help_agent_widget.py
+++ b/ui/help_agent_widget.py
@@ -46,7 +46,12 @@ _PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size
# assistant is teal, not the app accent, and the same in both themes —
# it is one recognisable object floating over every screen.
_TEAL_BG, _TEAL_LINE = "#E6F6F4", "#7FD0C4"
-_TEAL_TEXT, _TEAL_SPARK = "#0F6E62", "#0F9B8A"
+_TEAL_TEXT = "#0F6E62"
+# The page's CSS says .spark{color:#0F9B8A}, but the glyph is the ✨ EMOJI and a
+# colour emoji ignores CSS colour — so what the page actually renders is the
+# gold star. Sampled from the page's own render of section 27 (1055 pixels of
+# the star, averaged): #FDBE59.
+_SPARK_GOLD = "#FDBE59"
# The three states the floating assistant cycles through.
_HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel"
@@ -120,9 +125,12 @@ class HelpAgentWidget(QWidget):
self._worker: Optional[AgentWorker] = None
# Conversation history (excludes the system prompt, prepended per call).
# Seeded with the greeting so the panel always opens on a friendly hello.
- self._history: List[Dict[str, str]] = [
- {"role": "assistant", "content": self._greeting()}
- ]
+ # Kept by identity so retranslate() can rewrite it without having to
+ # guess which language the visible text is in — and without touching a
+ # real reply that happens to look like a greeting.
+ self._greet_msg: Dict[str, str] = {
+ "role": "assistant", "content": self._greeting()}
+ self._history: List[Dict[str, str]] = [self._greet_msg]
self.setAttribute(Qt.WA_StyledBackground, True)
self._pal = self._compute_palette()
self._build_edge_tab()
@@ -147,7 +155,7 @@ class HelpAgentWidget(QWidget):
self._apply_style()
muted = self._pal.text_muted
self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT))
- self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_TEAL_SPARK))
+ self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
self.min_btn.setIcon(icon("minus", color=muted))
self._render()
@@ -164,8 +172,8 @@ class HelpAgentWidget(QWidget):
border-radius: {_DOT // 2}px; color: {_TEAL_TEXT}; font-weight: 700;
font-size: 12px; padding: 0; text-align: center; }}
#helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }}
- #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_SPARK}; }}
- #helpLauncher:focus {{ border: 1px solid {_TEAL_SPARK}; }}
+ #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_LINE}; }}
+ #helpLauncher:focus {{ border: 1px solid {_TEAL_TEXT}; }}
#helpEdgeTab {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE};
border-right: none; border-top-left-radius: {r}px;
border-bottom-left-radius: {r}px; }}
@@ -213,7 +221,7 @@ class HelpAgentWidget(QWidget):
# is gone — hiding to the edge is now a line in the panel's ⋯ menu.
self.launcher = _HoverPill(self)
self.launcher.setObjectName("helpLauncher")
- self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_TEAL_SPARK))
+ self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
self.launcher.setCursor(Qt.PointingHandCursor)
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
self.launcher.clicked.connect(self._expand)
@@ -233,7 +241,7 @@ class HelpAgentWidget(QWidget):
hb.setContentsMargins(12, 8, 8, 8)
self.title_icon = QLabel(header)
self.title_icon.setPixmap(
- icon("sparkle", size=16, color=_TEAL_SPARK).pixmap(16, 16))
+ icon("sparkle", size=16, color=_SPARK_GOLD).pixmap(16, 16))
hb.addWidget(self.title_icon)
self.title = QLabel(tr("help_agent.title"), header)
self.title.setObjectName("helpTitle")
@@ -444,6 +452,12 @@ class HelpAgentWidget(QWidget):
self.send_btn.setEnabled(not busy)
def retranslate(self) -> None:
+ # The transcript is rendered HTML, so switching language left the
+ # greeting — and every "AI Assistant" speaker label — in the language
+ # the panel was built in.
+ if self._history and self._history[0] is self._greet_msg:
+ self._greet_msg["content"] = self._greeting()
+ self._render()
self.title.setText(tr("help_agent.title"))
self.input.setPlaceholderText(tr("help_agent.placeholder"))
self.launcher.setToolTip(tr("help_agent.open_tooltip"))