Settings sat 7px further in than the Dashboard/Giám sát rows above it — its
QSS gave it a 6px side margin where those rows start at the rail edge. At the
collapsed 54px width that put its icon near the middle of the rail, which is
what "thu gọn menu lại ra giữa" was describing. Icon and label now start on
the same x as the rows, open and collapsed, in both themes.
The help panel's transcript is rendered HTML, so switching language re-labelled
the chrome but left the greeting — and the "AI Assistant" speaker label — in
whatever language the panel was built in. retranslate() now rewrites the
greeting (matched by identity, so a real reply is never touched) and re-renders.
The sparkle is #FDBE59, sampled from the audit page's own render. Its CSS says
.spark{color:#0F9B8A}, but the glyph is the ✨ emoji and a colour emoji ignores
CSS colour, so the page has always drawn a gold star.
Behind all three: MainWindow does not style itself — run() calls
app.setStyleSheet — so 12 of 13 checkers were measuring a window with no
padding, margins or borders. Every QSS-driven layout bug was invisible to them,
and an unstyled window reported an icon drift that does not exist. Added
_apply_theme() and wired it through.
Two checker repairs that followed:
· check_no_hscroll flagged the 9pt dialogs on sizeHintForColumn(0), which
returns 182px at 9pt, 11pt and 14pt alike. Nothing was clipped. It now
compares the painted text against the width actually on screen, and fails
on a squeezed list (24 combos) where the old test passed.
· the checkers print Vietnamese and died mid-report on a cp932 console.
New: check_rail_align (icons hold one line, both themes, both states) and
check_help_i18n (transcript follows the language). Both verified to fail
without their fix.
15/15 checkers pass. check_nav and check_design_parity segfault in Qt teardown
roughly one run in three — pre-existing, after the verdict prints, and it
happens with or without the theme change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page.
|
|
|
|
Run: python tools/capture_screens.py
|
|
|
|
Two safety measures, both mandatory — this script drives the REAL application:
|
|
|
|
1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a
|
|
module-level constant resolved at import time. We copy that folder to a temp
|
|
directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing
|
|
``cowork_local``, so every write the app makes lands in the copy. The user's
|
|
real data is never opened for writing.
|
|
|
|
2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and
|
|
``RoutingScheduler``, which would *execute the user's scheduled tasks* — real
|
|
agent turns writing real files. Both ``start`` methods are patched to no-ops
|
|
before the window is built.
|
|
|
|
We also construct ``MainWindow`` directly rather than calling ``app.run()``:
|
|
``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``.
|
|
|
|
Screens that fail to render (QtWebEngine generally cannot initialise offscreen)
|
|
are recorded in the manifest with their error. They are never silently skipped —
|
|
the audit page renders an explicit "could not capture" placeholder for them.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent # …/cowork_local
|
|
OUT_DIR = REPO / "docs" / "screens"
|
|
THEMES = ("dark", "light")
|
|
|
|
|
|
def _isolate_home() -> Path:
|
|
"""Copy the real config dir into a temp HOME and repoint the env at it."""
|
|
real = Path.home() / ".cowork_local"
|
|
sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-"))
|
|
if real.exists():
|
|
shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True)
|
|
else:
|
|
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
|
|
for var in ("USERPROFILE", "HOME"):
|
|
os.environ[var] = str(sandbox)
|
|
os.environ.pop("HOMEDRIVE", None)
|
|
os.environ.pop("HOMEPATH", None)
|
|
return sandbox
|
|
|
|
|
|
def _load_fonts() -> int:
|
|
"""Register system fonts with the offscreen platform.
|
|
|
|
The offscreen plugin ships with NO font database (``QFontDatabase.families()``
|
|
returns an empty list), so every glyph renders as a tofu box — unusable when
|
|
the screenshots are the deliverable. Loading the real Windows faces fixes
|
|
both Latin and Vietnamese diacritics, and Consolas covers the code views.
|
|
"""
|
|
from PySide6.QtGui import QFontDatabase
|
|
|
|
wanted = [
|
|
"SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf",
|
|
"seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf",
|
|
]
|
|
root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts"
|
|
loaded = 0
|
|
for name in wanted:
|
|
path = root / name
|
|
if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1:
|
|
loaded += 1
|
|
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
|
|
TaskScheduler.start = lambda self: None # type: ignore[assignment]
|
|
try:
|
|
from cowork_local.core.routing.scheduler import RoutingScheduler
|
|
RoutingScheduler.start = lambda self: None # type: ignore[assignment]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main() -> int:
|
|
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
|
sandbox = _isolate_home()
|
|
sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import QApplication
|
|
app = QApplication([])
|
|
|
|
n_fonts = _load_fonts()
|
|
print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform")
|
|
if not n_fonts:
|
|
print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes")
|
|
|
|
_freeze_schedulers()
|
|
|
|
import cowork_local.theme as theme
|
|
from cowork_local.config import AppConfig, CONFIG_DIR
|
|
from cowork_local.i18n import set_language, tr
|
|
from cowork_local.state import AppContext
|
|
|
|
assert str(sandbox) in str(CONFIG_DIR), (
|
|
f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}")
|
|
print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}")
|
|
|
|
# Fill the sandbox with demo data so the screenshots show a working app.
|
|
# Safe by construction: seed() re-asserts it is inside a capture sandbox.
|
|
from seed_demo_data import seed
|
|
counts = seed()
|
|
print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items()))
|
|
|
|
from cowork_local.app import MainWindow
|
|
|
|
cfg = AppConfig.load()
|
|
set_language("vi")
|
|
ctx = AppContext(cfg)
|
|
|
|
manifest: list[dict] = []
|
|
# Label of the nav row selected right now, recorded into every shot so the
|
|
# "is the rail pointing at the right thing?" question is machine-checked
|
|
# instead of eyeballed across 54 images.
|
|
nav_state = {"label": "", "expected": ""}
|
|
|
|
def nav_to(win, page: int, sub=None, expect: str = "") -> None:
|
|
"""Navigate the way a user does, and record where the rail ends up.
|
|
|
|
Since the rail became a flat list, ``_goto`` moves the highlight itself
|
|
(``_select_nav_row``), so this no longer needs the two-step workaround
|
|
that existed while selecting a Workspace child destroyed the row being
|
|
selected.
|
|
"""
|
|
win._ensure_page(page)
|
|
win._goto(page, sub)
|
|
app.processEvents()
|
|
app.processEvents()
|
|
cur = next((t.currentItem() for t in (win.nav, win.nav_bottom)
|
|
if t.currentItem() is not None and t.currentItem().isSelected()),
|
|
None)
|
|
nav_state["label"] = cur.text(0) if cur is not None else ""
|
|
nav_state["expected"] = expect or nav_state["label"]
|
|
|
|
def shot(widget, slug: str, title: str, note: str = "") -> None:
|
|
"""Grab `widget` for the active theme; record success or the error."""
|
|
rec = {"slug": slug, "title": title, "theme": theme.current_theme(),
|
|
"note": note, "file": "", "error": "",
|
|
"nav": nav_state["label"], "nav_expected": nav_state["expected"]}
|
|
try:
|
|
app.processEvents()
|
|
app.processEvents()
|
|
pm = widget.grab()
|
|
if pm.isNull() or pm.width() < 2:
|
|
raise RuntimeError("grab() returned an empty pixmap")
|
|
name = f"{slug}-{theme.current_theme()}.png"
|
|
pm.save(str(OUT_DIR / name))
|
|
rec["file"] = f"screens/{name}"
|
|
print(f" ok {name} ({pm.width()}x{pm.height()})")
|
|
except Exception as exc: # noqa: BLE001
|
|
rec["error"] = f"{type(exc).__name__}: {exc}"
|
|
print(f" FAIL {slug}: {rec['error']}")
|
|
manifest.append(rec)
|
|
|
|
for th in THEMES:
|
|
print(f"\n=== theme: {th} ===")
|
|
ctx.config.theme = th
|
|
theme.set_active_theme(th)
|
|
app.setStyleSheet(theme.stylesheet(th))
|
|
|
|
win = MainWindow(ctx, user_name="local")
|
|
win.resize(1600, 1000)
|
|
win.show()
|
|
app.processEvents()
|
|
|
|
# ---- main screens, driven through the app's own navigation API ------
|
|
ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3
|
|
|
|
nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard"))
|
|
shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35")
|
|
|
|
nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule"))
|
|
sched = win._page_widgets[ROW_SCHED]
|
|
shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70")
|
|
try: # combo index 1 == Calendar view
|
|
sched.view_combo.setCurrentIndex(1)
|
|
app.processEvents()
|
|
shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88")
|
|
sched.view_combo.setCurrentIndex(0)
|
|
except Exception as exc: # noqa: BLE001
|
|
manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar",
|
|
"theme": th, "note": "ui/calendar_view.py:88", "file": "",
|
|
"error": f"{type(exc).__name__}: {exc}"})
|
|
print(f" FAIL schedule-calendar: {exc}")
|
|
|
|
# Workspace: capture with no project selected, then with one selected so
|
|
# the project-gated sub-tabs (Cowork, GraphRAG) actually exist.
|
|
nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace"))
|
|
ws = win.workspace
|
|
shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188")
|
|
try:
|
|
if ws.project_list.count():
|
|
ws.project_list.setCurrentRow(0)
|
|
app.processEvents()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
for attr, slug, title, note in (
|
|
("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"),
|
|
("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"),
|
|
("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"),
|
|
("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"),
|
|
):
|
|
idx = getattr(ws, attr, None)
|
|
if idx is None:
|
|
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
|
|
"file": "", "error": "sub-tab index not present"})
|
|
continue
|
|
nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx))
|
|
shot(win, slug, title, note)
|
|
|
|
# Monitoring: enumerate its sub-tabs from the app itself.
|
|
nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring"))
|
|
mon = win._page_widgets[ROW_MON]
|
|
try:
|
|
subs = mon.nav_subtabs()
|
|
except Exception as exc: # noqa: BLE001
|
|
subs = []
|
|
print(f" FAIL monitoring subtabs: {exc}")
|
|
for label, sub, _icon in subs:
|
|
nav_to(win, ROW_MON, sub, expect=label)
|
|
slug = "monitoring-" + "".join(
|
|
c.lower() if c.isalnum() else "-" for c in label).strip("-")
|
|
shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132")
|
|
|
|
# Dialogs/overlays below are not nav destinations.
|
|
nav_state["label"] = nav_state["expected"] = ""
|
|
|
|
# ---- dialogs: built directly and shown (never exec(), it blocks) -----
|
|
for slug, title, note, build in _dialog_specs(ctx, win):
|
|
try:
|
|
dlg = build()
|
|
dlg.show()
|
|
app.processEvents()
|
|
shot(dlg, slug, title, note)
|
|
dlg.close()
|
|
except Exception as exc: # noqa: BLE001
|
|
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
|
|
"file": "", "error": f"{type(exc).__name__}: {exc}"})
|
|
print(f" FAIL {slug}: {type(exc).__name__}: {exc}")
|
|
|
|
# ---- overlays --------------------------------------------------------
|
|
try:
|
|
help_dock = win.help_agent
|
|
help_dock._expand()
|
|
app.processEvents()
|
|
shot(help_dock, "overlay-help-panel", "Help dock — expanded panel",
|
|
"ui/help_agent_widget.py:79")
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" FAIL overlay-help-panel: {exc}")
|
|
|
|
win.close()
|
|
|
|
(OUT_DIR / "manifest.json").write_text(
|
|
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
bad_nav = [r for r in manifest
|
|
if r["nav_expected"] and r["nav"] != r["nav_expected"]]
|
|
checked = sum(1 for r in manifest if r["nav_expected"])
|
|
print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}")
|
|
for r in bad_nav:
|
|
print(f" MISMATCH {r['slug']} [{r['theme']}]: "
|
|
f"rail says '{r['nav']}', screen is '{r['nav_expected']}'")
|
|
|
|
ok = sum(1 for r in manifest if r["file"])
|
|
bad = [r for r in manifest if not r["file"]]
|
|
print(f"\ncaptured {ok}/{len(manifest)}")
|
|
if bad:
|
|
print("could NOT capture (recorded in manifest, shown as placeholders):")
|
|
for r in bad:
|
|
print(f" - {r['slug']} [{r['theme']}]: {r['error']}")
|
|
print(f"sandbox (safe to delete): {sandbox}")
|
|
return 0
|
|
|
|
|
|
def _dialog_specs(ctx, win):
|
|
"""(slug, title, note, factory) for each dialog we can build headlessly.
|
|
|
|
Signatures differ per dialog (some take ctx first, some take parent first,
|
|
some require a real model object) — each factory below matches the actual
|
|
``__init__`` it calls, not a guessed one.
|
|
"""
|
|
# NOTE: two different classes share the name `CustomAgent` —
|
|
# core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the
|
|
# co4e one (it has `.role`); importing the other raises AttributeError.
|
|
from cowork_local.core.co4e import CustomAgent
|
|
from cowork_local.ui.settings_dialog import SettingsDialog
|
|
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
|
from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog
|
|
from cowork_local.ui.file_edit_dialog import FileEditDialog
|
|
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
|
|
from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog
|
|
from cowork_local.ui.permission_dialog import PermissionDialog
|
|
from cowork_local.ui.agents_admin_tab import AgentEditDialog
|
|
from cowork_local.ui.login_dialog import LoginDialog
|
|
|
|
return [
|
|
# SettingsDialog(ctx, parent)
|
|
("dialog-settings", "Settings", "ui/settings_dialog.py:26",
|
|
lambda: SettingsDialog(ctx, win)),
|
|
# TaskEditorDialog(task, all_tasks, parent, ctx)
|
|
("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55",
|
|
lambda: TaskEditorDialog(None, [], win, ctx)),
|
|
# SkillsDialog(parent, ctx)
|
|
("dialog-skills", "Skills manager", "ui/skills_dialog.py:108",
|
|
lambda: SkillsDialog(win, ctx)),
|
|
# SkillEditDialog(parent, skill, ctx)
|
|
("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23",
|
|
lambda: SkillEditDialog(win, None, ctx)),
|
|
("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50",
|
|
lambda: FileEditDialog(ctx, "", win)),
|
|
# Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real
|
|
("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23",
|
|
lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)),
|
|
# ExtConnectorEditDialog(parent, category, connector)
|
|
("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23",
|
|
lambda: ExtConnectorEditDialog(win, "other", None)),
|
|
# PermissionDialog(action, parent) — `preview` is a dict, not a string
|
|
("dialog-permission", "Permission request", "ui/permission_dialog.py:13",
|
|
lambda: PermissionDialog(
|
|
{"name": "run_command",
|
|
"preview": {"title": "Run command", "kind": "command",
|
|
"text": "npm install --save-dev vitest"}}, win)),
|
|
# AgentEditDialog(parent, ctx, agent, default_model_hint)
|
|
("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35",
|
|
lambda: AgentEditDialog(win, ctx, None, "")),
|
|
("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57",
|
|
lambda: LoginDialog(ctx, win)),
|
|
]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|