docs(agent): thư viện instruction cho việc sửa bug UI/UX
Bộ 7 role chuyên biệt (triage → specialist → implementer → reviewer) cùng lớp dùng chung: guardrail, tri thức về repo, checklist, và contract đầu ra. Vì sao có: bug UI/UX được báo bằng lời kể triệu chứng, và người sửa hay bỏ qua ba thứ mà repo này rất dễ vi phạm — luật "không file nào ngoài theme/ được đặt tên một màu", trần LOC theo bánh cóc, và việc ui/ với presentation/ cùng tồn tại nên sửa nhầm file là "đã fix mà vẫn thấy lỗi". knowledge/qt_pitfalls.md chép lại 20 nguyên nhân gốc hay gặp của bug PySide6; examples/bad_fix.md có hai ca CÓ THẬT, gồm ca chính bản vá trong nhánh này từng mắc (compare_digest trên str ngoài ASCII) và lọt qua vòng review đầu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# i18n — luật chuỗi hiển thị
|
||||
|
||||
Nguồn: docstring `i18n/__init__.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ba ngôn ngữ, mặc định tiếng Việt
|
||||
|
||||
```python
|
||||
LANGUAGES = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
|
||||
LANGUAGE_SHORT = {"en": "EN", "ja": "JP", "vi": "VN"} # switcher gọn ở top bar
|
||||
DEFAULT_LANGUAGE = "vi"
|
||||
```
|
||||
|
||||
`tr(key, **kwargs)` trả chuỗi theo ngôn ngữ hiện tại, fallback lần lượt:
|
||||
**ngôn ngữ hiện tại → `en` → chính cái key**. Nghĩa là thiếu entry thì UI hiện ra
|
||||
`workspace.tab_folder` chứ không crash — nếu người dùng chụp màn hình có chuỗi dạng
|
||||
`a.b_c` thì đó chính là triệu chứng thiếu key.
|
||||
|
||||
`.format(**kwargs)` được áp dụng khi có placeholder: `tr("composer.attachments", n=3)`.
|
||||
|
||||
## 2. Widget nào phải đăng ký callback
|
||||
|
||||
| Loại widget | Cách xử lý |
|
||||
|---|---|
|
||||
| **Sống lâu** — chrome cửa sổ chính, tab, sidebar, composer | Đăng ký `on_language_changed(cb)`; `cb` áp lại `tr()` cho chính widget đó. Callback chạy **ngay một lần** và mỗi lần đổi ngôn ngữ |
|
||||
| **Tạm thời** — Settings, Skills, Flow, Permission dialog | Dựng lại từ đầu mỗi lần mở, nên chỉ cần gọi `tr()` lúc construct, **không** đăng ký |
|
||||
|
||||
Quy ước đặt tên hàm callback trong repo: `_retranslate()` / `_apply_i18n()` — xem
|
||||
`ui/workspace_tab.py:484` trở đi làm mẫu chuẩn.
|
||||
|
||||
**Bug điển hình:** "Đổi ngôn ngữ nhưng nhãn X không đổi" → widget sống lâu mà quên đăng ký,
|
||||
hoặc có đăng ký nhưng callback bỏ sót đúng nhãn đó. Không sửa bằng cách gọi `tr()` lại ở
|
||||
chỗ khác — sửa trong callback.
|
||||
|
||||
## 3. File từ điển
|
||||
|
||||
`i18n/` chia theo màn hình, không phải một file khổng lồ:
|
||||
|
||||
```text
|
||||
i18n/login_dialog.py i18n/sidebar.py i18n/composer.py
|
||||
i18n/cowork_tab.py i18n/settings_dialog.py i18n/skills_dialog.py
|
||||
i18n/libreoffice_view.py i18n/agents_admin_tab.py i18n/monitoring_overview.py
|
||||
i18n/hint.py
|
||||
```
|
||||
|
||||
Mỗi file export dict `key -> {"en":..., "ja":..., "vi":...}`, được `i18n/__init__.py`
|
||||
import và gộp lại. Thêm key mới:
|
||||
|
||||
1. Chọn đúng file theo màn hình (không nhét đại vào `login_dialog.py` chỉ vì nó lớn nhất).
|
||||
2. Điền **đủ 3 ngôn ngữ**. Thiếu `ja` là lỗi hay gặp nhất và chỉ lộ ra khi khách Nhật dùng.
|
||||
3. Đặt key theo `<màn>.<thành_phần>` — `workspace.tab_folder`, `app.nav.recents`.
|
||||
|
||||
## 4. Rủi ro riêng của tiếng Nhật và tiếng Việt
|
||||
|
||||
| Rủi ro | Triệu chứng | Cách xử lý |
|
||||
|---|---|---|
|
||||
| Tiếng Nhật ngắn hơn, tiếng Việt dài hơn tiếng Anh | Nút vừa với `EN`, tràn với `VI`; label bị `...` với `JA` | Không `setFixedWidth` theo chuỗi tiếng Anh. Dùng `sizeHint` + `minimumWidth`, hoặc cho phép wrap |
|
||||
| Dấu tiếng Việt bị cắt phần trên/dưới | `Ắ`, `ộ` mất dấu ở nhãn cao cố định | Không đặt `setFixedHeight` cho label theo pixel; để layout tự tính |
|
||||
| Font mặc định thiếu glyph Nhật | Ô vuông tofu `□□□` trên máy chưa cài font | Kiểm tra `_FONT` trong `theme/palettes.py`, khai báo fallback |
|
||||
| Sắp xếp / so sánh chuỗi | Danh sách project sắp sai với tên có dấu | Dùng `locale`-aware sort, không `sorted()` thô |
|
||||
| Chiều dài chuỗi tính bằng ký tự ≠ chiều rộng hiển thị | Elide sai với chữ Nhật | Đo bằng `QFontMetrics.horizontalAdvance`, không `len()` |
|
||||
|
||||
## 5. Checklist sửa bug i18n
|
||||
|
||||
- [ ] Key mới có đủ `en` / `ja` / `vi`?
|
||||
- [ ] Đã thử đổi qua cả 3 ngôn ngữ **trong lúc app đang chạy** (không phải restart)?
|
||||
- [ ] Widget sống lâu đã đăng ký `on_language_changed`?
|
||||
- [ ] Không còn chuỗi hardcode nào trong bản vá?
|
||||
- [ ] Layout còn đúng với chuỗi dài nhất trong 3 ngôn ngữ?
|
||||
- [ ] Không dùng `len()` để đo bề rộng chữ?
|
||||
@@ -0,0 +1,101 @@
|
||||
# Project Map — Cowork Local (dành cho agent sửa bug UI/UX)
|
||||
|
||||
Nguồn sự thật: `README.md`, `docs/architecture/ADR-001-layered-architecture.md`,
|
||||
`docs/governance/contributor-recipes.md`. File này chỉ tóm tắt phần **một người sửa bug
|
||||
UI cần biết**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bốn tầng
|
||||
|
||||
```text
|
||||
presentation/ PySide6 UI — Shell, NavRail, Chat, Scheduling, Settings, Dashboard
|
||||
↓
|
||||
application/ Orchestration thuần Python — Conversations, Scheduling, Workspaces, Monitoring, Routing
|
||||
↓
|
||||
domain/ Entity, ExecutionRequest bất biến, AgentEvent, Descriptor (thuần Python)
|
||||
↑
|
||||
infrastructure/ Adapter — LLM provider, persistence atomic JSON, Keyring SecretStore, MCP
|
||||
```
|
||||
|
||||
- `domain/` và `application/` **không được** import PySide6/PyQt/`ui`/`app`
|
||||
(`scripts/check_imports.py::FORBIDDEN_MODULE_PREFIXES`).
|
||||
- Widget chỉ gọi xuống service của `application/`, không chạm SQLite/JSON/LLM trực tiếp.
|
||||
- Mọi module production `<= 400 LOC`.
|
||||
|
||||
## 2. ⚠️ Hai thư mục UI cùng tồn tại — điểm dễ sửa nhầm file nhất
|
||||
|
||||
| Thư mục | Vai trò hiện tại | Sửa bug ở đây khi |
|
||||
|---|---|---|
|
||||
| `presentation/` | Kết quả refactor R08 — các màn đã tách module | Bug thuộc Chat, Co4E, Dashboard, Folder, Graph, Scheduling, Settings, Shell |
|
||||
| `ui/` | **Vẫn đang chạy**, không phải code chết | Bug thuộc Monitoring, Workspace, các dialog, icon, widget dùng chung |
|
||||
|
||||
`presentation/` vẫn import ngược sang `ui/` cho phần dùng chung, ví dụ:
|
||||
|
||||
```text
|
||||
presentation/shell/page_registry.py:14 from ...ui.monitoring_tab import MonitoringTab
|
||||
presentation/shell/main_window.py:38 from ...ui.workspace_tab import WorkspaceTab
|
||||
presentation/dashboard/dashboard_tab.py:24 from cowork_local.ui.icons import icon
|
||||
```
|
||||
|
||||
**Luật:** trước khi sửa, `grep` tên class/hàm trên **cả hai** thư mục. Sửa bản không được
|
||||
import vào runtime là lỗi "đã fix nhưng user vẫn thấy lỗi" phổ biến nhất của repo này.
|
||||
|
||||
```bash
|
||||
grep -rn "class DashboardTab" ui/ presentation/
|
||||
```
|
||||
|
||||
## 3. Điểm vào & trạng thái
|
||||
|
||||
| File | Vai trò |
|
||||
|---|---|
|
||||
| `app.py`, `__main__.py` | Bootstrap `QApplication`, dựng `MainWindow` |
|
||||
| `presentation/shell/main_window.py` | Cửa sổ chính, `_nav_defs`, top bar, toast, help agent |
|
||||
| `presentation/shell/page_registry.py` | Chuyển trang; Dashboard/Schedule/Monitoring **dựng lười** |
|
||||
| `presentation/shell/nav_rail.py` | Nav rail trái, thu gọn/mở rộng, cây project & recents |
|
||||
| `presentation/shell/top_bar.py` | Thanh trên: theme switch, language switch |
|
||||
| `presentation/shell/toast.py` | Popup "task xong" góc trên trái |
|
||||
| `state.py` | `AppContext` — cầu nối UI ↔ service |
|
||||
| `config.py` | Đọc/ghi cấu hình người dùng (theme, ngôn ngữ, provider...) |
|
||||
| `paths.py` | Vị trí dữ liệu runtime (`%USERPROFILE%\.cowork_local`) |
|
||||
| `theme/` | Toàn bộ màu sắc & stylesheet (xem `theme_tokens.md`) |
|
||||
| `i18n/` | Toàn bộ chuỗi hiển thị (xem `i18n_rules.md`) |
|
||||
|
||||
### Hệ quả của "dựng lười" khi debug
|
||||
|
||||
Dashboard, Schedule và Monitoring **chưa tồn tại** cho tới lần đầu người dùng bấm vào.
|
||||
Nghĩa là:
|
||||
|
||||
- Bug "lần đầu mở màn X bị nhấp nháy / sai theme / sai ngôn ngữ" gần như luôn nằm ở
|
||||
`_ensure_page` / `_goto` chứ không nằm trong widget của màn đó.
|
||||
- Widget dựng lười **bỏ lỡ** các sự kiện đã phát trước đó (đổi theme, đổi ngôn ngữ).
|
||||
Xem `qt_pitfalls.md` P07.
|
||||
|
||||
## 4. Bảng đối chiếu tính năng → file
|
||||
|
||||
| Khu vực | File chính |
|
||||
|---|---|
|
||||
| Chat / composer / bubble | `presentation/chat/` (`chat_panel.py`, `composer_widget.py`, `chat_bubble_style.py`) |
|
||||
| Co4E canvas & node | `presentation/co4e/` (`co4e_canvas_widget.py`, `node_property_panel.py`, `canvas_geometry.py`) |
|
||||
| Dashboard & biểu đồ | `presentation/dashboard/` + `ui/spline_chart.py`, `ui/widgets.py` |
|
||||
| Folder / preview tài liệu | `presentation/folder/` (`folder_tab.py`, `code_editor.py`, `office_document_renderer.py`) |
|
||||
| GraphRAG | `presentation/graph/` |
|
||||
| Lịch / Kanban | `presentation/scheduling/` |
|
||||
| Settings | `presentation/settings/` + `ui/settings_dialog.py` |
|
||||
| Monitoring (8 sub-view) | `ui/monitoring_tab.py` + `presentation/monitoring/` |
|
||||
| Workspace + sub-tab | `ui/workspace_tab.py`, `ui/cowork_tab.py`, `ui/co4e_tab.py` |
|
||||
| Dialog (login, permission, skill, task...) | `ui/*_dialog.py` |
|
||||
| Icon | `ui/icons.py` |
|
||||
| Widget dùng chung (StatCard, BudgetCard...) | `ui/widgets.py` |
|
||||
|
||||
## 5. Test
|
||||
|
||||
| Đường dẫn | Nội dung |
|
||||
|---|---|
|
||||
| `tests/ui/` | Test widget, có `conftest.py` riêng |
|
||||
| `tests/integration/` | Test ghép nhiều thành phần |
|
||||
| `tests/e2e/test_smoke.py` | Smoke test bản release |
|
||||
| `tests/characterization/` | Chốt hành vi hiện tại trước khi refactor |
|
||||
|
||||
Chạy headless: `QT_QPA_PLATFORM=offscreen pytest tests/ui -q`.
|
||||
64/108 module test dựng widget thật, nên môi trường phải có PySide6.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Nguyên nhân gốc hay gặp của bug UI PySide6
|
||||
|
||||
Danh mục để **chẩn đoán**, không phải để đoán bừa. Mỗi mục: triệu chứng người dùng mô tả →
|
||||
nguyên nhân → cách xác minh → hướng sửa.
|
||||
|
||||
---
|
||||
|
||||
## Nhóm A — Layout & kích thước
|
||||
|
||||
### P01. Widget bị bóp/giãn sai khi resize
|
||||
**Triệu chứng:** "kéo cửa sổ to ra thì bảng bên phải nuốt hết chỗ", "panel trái biến mất".
|
||||
**Nguyên nhân:** thiếu `stretch` factor, hoặc `QSizePolicy` sai (`Preferred` vs `Expanding`).
|
||||
**Xác minh:** đọc `addWidget(w, stretch)` / `setStretchFactor` / `setSizePolicy` quanh chỗ dựng.
|
||||
**Sửa:** đặt stretch tường minh trên `QSplitter`/`QBoxLayout`. Không sửa bằng `setFixedWidth`.
|
||||
|
||||
### P02. Chữ bị cắt / hiện `...` ở một số ngôn ngữ hoặc scale
|
||||
**Triệu chứng:** "nút bị mất chữ", "tên project chỉ hiện một nửa".
|
||||
**Nguyên nhân:** `setFixedWidth`/`setFixedSize` tính theo chuỗi tiếng Anh ở 100% scale.
|
||||
**Xác minh:** `grep -n "setFixedWidth\|setFixedSize\|setMaximumWidth" <file>`; thử với `vi`/`ja`.
|
||||
**Sửa:** dùng `minimumWidth` + `sizeHint`, hoặc `QFontMetrics.horizontalAdvance` cho chuỗi
|
||||
dài nhất trong 3 ngôn ngữ. Xem `i18n_rules.md` §4.
|
||||
|
||||
### P03. Nội dung trong `QScrollArea` không cuộn được / bị nén
|
||||
**Nguyên nhân:** quên `setWidgetResizable(True)`, hoặc đặt widget con vào scroll area
|
||||
**sau** khi đã `setWidget`.
|
||||
**Sửa:** `setWidgetResizable(True)` và dựng xong nội dung rồi mới `setWidget`.
|
||||
|
||||
### P04. Khoảng trắng thừa quanh panel
|
||||
**Nguyên nhân:** `setContentsMargins`/`setSpacing` mặc định của layout lồng nhau cộng dồn.
|
||||
**Xác minh:** đếm số layout lồng; repo dùng `setContentsMargins(10,10,10,10)` +
|
||||
`setSpacing(10)` ở shell (`main_window.py:145`), layout con thường phải là `(0,0,0,0)`.
|
||||
|
||||
### P05. Bug chỉ xảy ra trên màn hình scale 125%/150%
|
||||
**Triệu chứng:** "máy em bình thường, máy sếp bị lệch".
|
||||
**Nguyên nhân:** hằng số pixel cứng, icon raster không có bản @2x, `QPixmap` không set
|
||||
`devicePixelRatio`.
|
||||
**Xác minh:** hỏi người dùng độ phân giải + mức scale Windows; test lại bằng biến môi trường
|
||||
`QT_SCALE_FACTOR=1.5`.
|
||||
**Sửa:** dùng đơn vị theo `QFontMetrics`, icon SVG hoặc `icon()` từ `ui/icons.py`.
|
||||
|
||||
---
|
||||
|
||||
## Nhóm B — Stylesheet & theme
|
||||
|
||||
### P06. `setStyleSheet` cục bộ đè mất style toàn app
|
||||
**Triệu chứng:** "một chỗ nhìn khác hẳn phần còn lại", "combo box mất mũi tên".
|
||||
**Nguyên nhân:** gọi `widget.setStyleSheet(...)` — QSS con **thay thế** chứ không merge với
|
||||
QSS ứng dụng cho subcontrol đó. Riêng `::drop-down` bị style là Qt ngừng vẽ mũi tên mặc
|
||||
định (xem `theme_tokens.md` §5).
|
||||
**Sửa:** gỡ stylesheet cục bộ, gán `objectName`, style trong `theme/qss.py`.
|
||||
|
||||
### P07. Widget dựng lười không nhận theme / ngôn ngữ mới
|
||||
**Triệu chứng:** "đổi sang giao diện sáng rồi mà màn Giám sát vẫn tối", "chỉ màn đó bị".
|
||||
**Nguyên nhân:** Dashboard / Schedule / Monitoring chỉ được dựng ở lần mở đầu tiên
|
||||
(`presentation/shell/page_registry.py::_ensure_page`). Chúng **bỏ lỡ** sự kiện đổi theme
|
||||
hoặc đổi ngôn ngữ đã phát trước đó.
|
||||
**Xác minh:** mở app → đổi theme → *rồi mới* bấm vào màn đó. Nếu lỗi tái hiện thì đúng P07.
|
||||
**Sửa:** áp lại stylesheet/`tr()` trong `_ensure_page` sau khi dựng, hoặc để widget tự đăng ký
|
||||
listener ngay trong `__init__`. Không sửa trong từng widget con.
|
||||
|
||||
### P08. Style không áp lại sau khi đổi property động
|
||||
**Triệu chứng:** "nút vẫn xám sau khi đã chọn xong".
|
||||
**Nguyên nhân:** QSS selector dạng `[state="active"]` chỉ được đánh giá lại khi ép polish.
|
||||
**Sửa:** `w.style().unpolish(w); w.style().polish(w)` sau khi `setProperty`.
|
||||
|
||||
### P09. Bug chỉ có ở một theme
|
||||
**Xác minh bắt buộc:** đối chiếu `docs/screens/<slug>-dark.png` và `<slug>-light.png`.
|
||||
**Nguyên nhân thường gặp:** dùng `accent` ở chỗ cần `accent_solid`, hoặc token bề mặt sai bậc
|
||||
(`surface` thay vì `surface_raised`).
|
||||
|
||||
---
|
||||
|
||||
## Nhóm C — Signal, slot, luồng
|
||||
|
||||
### P10. Bấm một lần chạy hai lần
|
||||
**Triệu chứng:** "gửi 1 tin mà hiện 2", "tạo trùng task".
|
||||
**Nguyên nhân:** `connect()` được gọi lại mỗi lần refresh/rebuild mà không `disconnect()`.
|
||||
**Xác minh:** `grep -n "\.connect(" <file>` và tìm xem có nằm trong hàm được gọi nhiều lần không.
|
||||
**Sửa:** connect một lần trong `__init__`, hoặc `Qt.UniqueConnection`.
|
||||
|
||||
### P11. UI đứng khi chạy tác vụ dài
|
||||
**Triệu chứng:** "app treo khi bấm Phân tích", "vòng xoay không quay".
|
||||
**Nguyên nhân:** gọi LLM / đọc file lớn / gọi MCP ngay trong GUI thread.
|
||||
**Sửa:** đẩy xuống service của `application/` chạy async/worker; GUI chỉ nhận signal.
|
||||
Đây cũng là vi phạm kiến trúc (`guardrail.md` G3), không chỉ là bug hiệu năng.
|
||||
|
||||
### P12. Widget biến mất không lý do
|
||||
**Nguyên nhân:** không có parent, bị Python GC thu hồi; hoặc bị `deleteLater` sớm.
|
||||
**Sửa:** truyền `parent` khi khởi tạo, hoặc giữ tham chiếu trên `self`.
|
||||
|
||||
### P13. Truy cập widget đã bị xoá → crash
|
||||
**Triệu chứng:** "đóng dialog xong app tắt luôn".
|
||||
**Nguyên nhân:** slot vẫn chạy sau khi C++ object đã destroy (`RuntimeError: Internal C++ object already deleted`).
|
||||
**Sửa:** `disconnect` trong `closeEvent`, hoặc dùng `QPointer`/kiểm tra `shiboken6.isValid`.
|
||||
|
||||
### P14. Dữ liệu cũ hiện lại sau khi đã cập nhật
|
||||
**Nguyên nhân:** view đọc từ cache/model không được `beginResetModel`/`endResetModel`,
|
||||
hoặc widget được `hide()` chứ không rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Nhóm D — Vẽ tay & hiệu năng
|
||||
|
||||
### P15. Nhấp nháy khi chuyển màn hoặc khi cuộn
|
||||
**Nguyên nhân:** `repaint()` gọi tay trong vòng lặp, hoặc `paintEvent` đọc file/config.
|
||||
**Sửa:** dùng `update()` (gộp lần vẽ), và đọc màu qua `current_palette()` — đã được cache
|
||||
sẵn chính vì lý do này (`theme_tokens.md` §2).
|
||||
|
||||
### P16. Chart / canvas vẽ đè, để lại vệt
|
||||
**Nguyên nhân:** không xoá nền trong `paintEvent`, hoặc `QPainter` không `end()`.
|
||||
|
||||
### P17. Icon mờ hoặc sai màu ở dark/light
|
||||
**Nguyên nhân:** icon raster một màu cố định.
|
||||
**Sửa:** lấy qua `ui/icons.py::icon`, không load PNG trực tiếp.
|
||||
|
||||
---
|
||||
|
||||
## Nhóm E — Vòng đời & dữ liệu
|
||||
|
||||
### P18. Trạng thái rỗng/đang tải/lỗi không có giao diện riêng
|
||||
**Triệu chứng:** "màn hình trắng trơn, không biết đang chạy hay hỏng".
|
||||
Đây là **bug UX**, không phải bug kỹ thuật → route sang `3_ux_flow_fixer.md`.
|
||||
|
||||
### P19. Người dùng mất dữ liệu khi đóng nhầm
|
||||
**Triệu chứng:** "gõ instruction xong đóng tab, mất hết".
|
||||
**Nguyên nhân:** không có dirty-state, không chặn `closeEvent`.
|
||||
Đây là bug UX mức nghiêm trọng, ưu tiên cao hơn phần lớn bug hiển thị.
|
||||
|
||||
### P20. Dialog mở sau lưng cửa sổ chính / mở lệch màn hình
|
||||
**Nguyên nhân:** dialog không truyền `parent`, hoặc set vị trí bằng toạ độ tuyệt đối.
|
||||
**Sửa:** luôn truyền parent; căn giữa theo `parent.geometry()`, không theo `screen(0)`.
|
||||
|
||||
---
|
||||
|
||||
## Cách dùng danh mục này
|
||||
|
||||
1. Ánh xạ triệu chứng người dùng → 1-3 mục khả dĩ.
|
||||
2. Với mỗi mục, chạy đúng bước **Xác minh** — đọc code hoặc tái hiện.
|
||||
3. Loại trừ cho tới khi còn một nguyên nhân có `file:line` cụ thể.
|
||||
4. Nếu không mục nào khớp: ghi giả thuyết mới vào `fix_plan.md`, và **bổ sung mục mới vào
|
||||
file này** khi đã xác nhận. Danh mục phải lớn dần theo bug thật của sản phẩm.
|
||||
@@ -0,0 +1,124 @@
|
||||
# CASAN Quality Gate — cổng bắt buộc trước PR
|
||||
|
||||
Nguồn: `README.md`, `scripts/run_quality_gate.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Năm cổng
|
||||
|
||||
| Cổng | Script | Kiểm tra |
|
||||
|---|---|---|
|
||||
| **C** — Clean Architecture | `scripts/check_imports.py` | `domain/` và `application/` không import `PySide6`, `PySide2`, `PyQt6`, `PyQt5`, `ui`, `app` |
|
||||
| **A** — Atomic & Secrets | `scripts/audit_security.py` | Secret/plaintext trong file `.py` và file config |
|
||||
| **S** — Single Responsibility | `scripts/check_loc.py --max-lines 400` | Mọi module production `<= 400 LOC` |
|
||||
| **O** — Orphan Module | `scripts/check_orphan_modules.py` | Module không được import từ đâu |
|
||||
| **A/N** — Tests | `pytest` | Toàn bộ suite |
|
||||
|
||||
## 2. Lệnh
|
||||
|
||||
```bash
|
||||
# Đủ 5 cổng — chạy trước khi tạo PR
|
||||
python scripts/run_quality_gate.py
|
||||
|
||||
# Chỉ guard tĩnh, bỏ test — vòng lặp sửa nhanh
|
||||
python scripts/run_quality_gate.py --skip-tests
|
||||
|
||||
# Từng cổng
|
||||
python scripts/check_imports.py
|
||||
python scripts/audit_security.py
|
||||
python scripts/check_loc.py --max-lines 400
|
||||
pytest tests/e2e/test_smoke.py -v
|
||||
```
|
||||
|
||||
## 3. Chạy test UI headless
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen pytest tests/ui -q # bash
|
||||
$env:QT_QPA_PLATFORM="offscreen"; pytest tests/ui -q # PowerShell
|
||||
```
|
||||
|
||||
64/108 module test dựng widget thật và 20 module import PySide6 ở module scope, nên môi
|
||||
trường test **phải** có đủ runtime dependency. Chỉ có **một** `requirements.txt`, không có
|
||||
cặp runtime/test riêng.
|
||||
|
||||
## 4. Bẫy khi sửa bug UI
|
||||
|
||||
- **Gate S rất dễ vỡ khi vá bug.** Nhiều file UI đã sát 400 dòng. Trước khi thêm code:
|
||||
```bash
|
||||
python scripts/check_loc.py --max-lines 400 | grep <tên file>
|
||||
```
|
||||
Sắp vượt → tách module **và nêu trong `fix_plan.md` trước khi làm** (`guardrail.md` G6).
|
||||
|
||||
- **Gate O bắt module mồ côi.** Tách file mới ra mà chưa import vào đâu là Gate O đỏ.
|
||||
Tách và nối dây trong cùng một commit.
|
||||
|
||||
- **Gate C ít khi liên quan bug UI** — trừ khi bản vá "tiện tay" import widget vào
|
||||
`application/`. Đó là dấu hiệu sửa sai tầng.
|
||||
|
||||
- **File `.py` mới phải được `git add` ngay.**
|
||||
`tests/test_no_ignored_source.py::test_khong_file_py_nao_bi_bo_quen_chua_theo_doi` quét
|
||||
`git ls-files --others --exclude-standard` và làm suite đỏ nếu có file `.py` chưa theo dõi
|
||||
trong thư mục nguồn. File test mới cũng tính. Triệu chứng giống hệt regression, nhưng
|
||||
không phải:
|
||||
|
||||
```
|
||||
AssertionError: File mã nguồn chưa được git add — clone sạch sẽ thiếu:
|
||||
tests/ui/test_<...>.py
|
||||
```
|
||||
|
||||
- **`.venv` không được nằm trong repo.** `install.bat` dựng venv ở
|
||||
`%LOCALAPPDATA%\CoworkLocal` chính vì gate đi bộ toàn cây thư mục — một `.venv` trong repo
|
||||
biến mọi module vendored thành vi phạm Gate O.
|
||||
|
||||
## 5. Định nghĩa "xong"
|
||||
|
||||
Từ `docs/governance/definition-of-done.md`:
|
||||
|
||||
- code xong;
|
||||
- test liên quan pass;
|
||||
- tài liệu cập nhật nếu cần;
|
||||
- PR đã được review;
|
||||
- đã merge vào nhánh mặc định.
|
||||
|
||||
**Một PR = một thay đổi logic.** Không gộp nhiều bug UI không liên quan vào một PR.
|
||||
|
||||
Đóng góp từ FSG AI Core Team chỉ "xong" khi PR đã merge vào Cowork Local — "Core AI code
|
||||
xong" hoặc "pre-review pass" **không** phải Done. Bằng chứng bắt buộc: core issue reference,
|
||||
PR, evidence test, reviewer phía Cowork, merge commit.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suite này vốn đã KHÔNG xanh
|
||||
|
||||
Tại `e5fa21e` (2026-09-07), chạy đầy đủ trên Windows + Python 3.14 cho ra:
|
||||
|
||||
```
|
||||
11 failed, 884 passed, 2 skipped, 66 errors
|
||||
```
|
||||
|
||||
Nghĩa là **"pytest đỏ" không nói lên điều gì** về bản vá của bạn. Bắt buộc phải so với
|
||||
baseline, và so bằng **danh sách tên test**:
|
||||
|
||||
```bash
|
||||
git stash push --include-untracked -m baseline
|
||||
QT_QPA_PLATFORM=offscreen pytest -q > /tmp/base.txt 2>&1
|
||||
git stash pop
|
||||
QT_QPA_PLATFORM=offscreen pytest -q > /tmp/after.txt 2>&1
|
||||
|
||||
grep "^FAILED" /tmp/base.txt | sed 's/ - .*//' | sort > /tmp/f_base.txt
|
||||
grep "^FAILED" /tmp/after.txt | sed 's/ - .*//' | sort > /tmp/f_after.txt
|
||||
comm -13 /tmp/f_base.txt /tmp/f_after.txt # rỗng = không regression
|
||||
```
|
||||
|
||||
Không so con số tổng: một test cũ hỏng cộng một test mới xanh cho ra cùng con số.
|
||||
|
||||
Nhóm đỏ lớn nhất hiện nay là `tests/characterization/test_co4e_runs_page.py` —
|
||||
`RuntimeError: libshiboken: Internal C++ object (QGraphicsScene) already deleted`
|
||||
(bẫy P13 trong `qt_pitfalls.md`). Chưa ai nhận sửa.
|
||||
|
||||
Gate A và Gate S cũng đỏ sẵn:
|
||||
|
||||
- A — 3 phát hiện trong `tests/test_project_context_{e2e,issue,knowledge}.py`;
|
||||
- S — `core/chat_agent.py` 423 LOC, `mcp_servers/project_context/providers/knowledge.py` 408 LOC.
|
||||
|
||||
Đừng nhận nhầm bốn thứ trên là do bản vá của mình (`guardrail.md` G10).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Screen Map — dịch lời người dùng thành file:line
|
||||
|
||||
Người dùng báo lỗi bằng lời ("cái bảng bên phải màn thống kê"). File này để agent
|
||||
Triage quy nó về đúng widget.
|
||||
|
||||
---
|
||||
|
||||
## 1. Nav rail — bốn màn chính
|
||||
|
||||
Định nghĩa tại `presentation/shell/main_window.py:151` (`_nav_defs`), thứ tự = page index:
|
||||
|
||||
| Row | i18n key | Icon | Dựng | Widget |
|
||||
|---|---|---|---|---|
|
||||
| 0 | `app.tab.dashboard` | `dashboard` | lười | `presentation/dashboard/dashboard_tab.py::DashboardTab` |
|
||||
| 1 | `app.tab.schedule` | `schedule` | lười | `presentation/scheduling/schedule_task_tab.py::ScheduleTaskTab` |
|
||||
| 2 | `app.tab.workspace` | `workspaces` | **ngay** (màn HOME) | `ui/workspace_tab.py::WorkspaceTab` |
|
||||
| 3 | `app.tab.monitoring` | `monitoring` | lười | `ui/monitoring_tab.py::MonitoringTab` |
|
||||
|
||||
App mở lên là ở **Workspace ▸ Project**.
|
||||
|
||||
## 2. Sub-tab của Workspace
|
||||
|
||||
`ui/workspace_tab.py:214-245`:
|
||||
|
||||
| Tab | i18n key | Widget |
|
||||
|---|---|---|
|
||||
| Project | `workspace.tab_project` | `_build_project_tab()` trong chính file đó |
|
||||
| Cowork | `workspace.tab_cowork` | `ui/cowork_tab.py` |
|
||||
| Co4E | `workspace.tab_co4e` | `ui/co4e_tab.py` → `presentation/co4e/` |
|
||||
| Folder | `workspace.tab_folder` | `presentation/folder/folder_tab.py` |
|
||||
| GraphRAG | `workspace.tab_graphrag` | `presentation/graph/structure_graph_view.py` |
|
||||
|
||||
Monitoring **giữ tab strip riêng** với 8 sub-view (tổng quan, trạng thái agent, công cụ,
|
||||
nhật ký hành động, lịch sử gọi MCP, sự kiện bảo mật, agents admin, icon). Workspace là màn
|
||||
duy nhất giấu tab strip đi.
|
||||
|
||||
## 3. Thành phần luôn nổi trên mọi màn
|
||||
|
||||
| Thành phần | File | Triệu chứng người dùng hay mô tả |
|
||||
|---|---|---|
|
||||
| Nav rail trái, nút thu gọn | `presentation/shell/nav_rail.py` | "menu bị co lại", "không thấy tên project" |
|
||||
| Top bar (theme, ngôn ngữ) | `presentation/shell/top_bar.py` | "đổi giao diện không ăn" |
|
||||
| Toast góc trên trái | `presentation/shell/toast.py` | "thông báo xong việc che mất nút" |
|
||||
| Help agent nổi góc dưới phải | `ui/help_agent_widget.py` | "con robot che nút gửi" |
|
||||
| Status bar dưới cùng | `main_window.statusBar()` | "dòng chữ dưới đáy không đổi" |
|
||||
|
||||
## 4. Dialog
|
||||
|
||||
`ui/`: `login_dialog.py`, `permission_dialog.py`, `settings_dialog.py`, `skills_dialog.py`,
|
||||
`task_editor_dialog.py`, `file_edit_dialog.py`, `flow_dialog.py`, `mcp_servers_dialog.py`,
|
||||
`co4e_agent_dialog.py`, `ext_connector_dialog.py`.
|
||||
|
||||
## 5. 🔎 Hai file tra cứu bắt buộc dùng
|
||||
|
||||
### `docs/screens/manifest.json`
|
||||
|
||||
Mỗi màn đã chụp ảnh có một entry: `slug`, `title`, `theme`, `note` (**đúng `file.py:line`
|
||||
nơi màn đó được dựng**), `file` (ảnh), `nav`.
|
||||
|
||||
```bash
|
||||
# Người dùng nói "màn Kanban lịch trình"
|
||||
python -c "import json;print([e for e in json.load(open('docs/screens/manifest.json')) if 'schedule' in e['slug']])"
|
||||
```
|
||||
|
||||
Ảnh có **cả bản dark và light** (`*-dark.png` / `*-light.png`) — dùng để đối chiếu trước/sau
|
||||
và để kiểm tra bug chỉ xảy ra ở một theme.
|
||||
|
||||
### `docs/screens/controls.json`
|
||||
|
||||
Danh mục **mọi control** đã trích tự động từ source: `file`, `var`, `type` (`QLineEdit`...),
|
||||
`kind` (mô tả tiếng Việt: "ô nhập", "nút"...), `label`, `line`, `signals`, `object_name`.
|
||||
|
||||
```bash
|
||||
# Người dùng nói "ô nhập email trong màn tài khoản"
|
||||
python - <<'PY'
|
||||
import json
|
||||
for f in json.load(open('docs/screens/controls.json')):
|
||||
for c in f['controls']:
|
||||
if 'email' in (c['var'] + c['label']).lower():
|
||||
print(f["file"], c["line"], c["var"], c["type"], c["object_name"])
|
||||
PY
|
||||
```
|
||||
|
||||
Cột `object_name` đặc biệt quan trọng khi sửa bug màu/style: rỗng nghĩa là widget **chưa**
|
||||
được style qua `_TEMPLATE`, nên nó đang ăn style mặc định của class — thường chính là
|
||||
nguyên nhân của "chỗ này nhìn khác chỗ kia".
|
||||
|
||||
## 6. Quy trình tra 4 bước cho Triage
|
||||
|
||||
1. Xác định **nav row** (Dashboard / Schedule / Workspace / Monitoring) từ mô tả hoặc ảnh.
|
||||
2. Xác định **sub-tab / dialog**.
|
||||
3. Tra `manifest.json` → lấy `note` = `file.py:line`.
|
||||
4. Tra `controls.json` → lấy đúng `var` + `line` + `object_name` của control bị lỗi.
|
||||
|
||||
Không qua đủ 4 bước thì `confidence` tối đa là `low`.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Secret & Config — nơi credential được phép nằm
|
||||
|
||||
Nguồn: `infrastructure/secrets/secret_store.py`, `infrastructure/secrets/keyring_adapter.py`,
|
||||
`infrastructure/config/schema_migration.py`, `config.py`, `SECURITY.md`.
|
||||
|
||||
Đây là knowledge module của `security-defect-fixer`. Ba module UI (`theme_tokens`,
|
||||
`i18n_rules`, `screen_map`) không đụng tới phần này.
|
||||
|
||||
---
|
||||
|
||||
## 1. Thang bậc: credential được phép nằm ở đâu
|
||||
|
||||
Từ an toàn nhất xuống:
|
||||
|
||||
| Bậc | Nơi | Dùng cho | API |
|
||||
|---|---|---|---|
|
||||
| 1 | **OS Keyring** qua `SecretStore` | API key, token, mật khẩu thật | `secrets.set/get/has/delete` |
|
||||
| 2 | **Biến môi trường** | Giá trị do quản trị viên đặt lúc triển khai | `_apply_env_overrides` |
|
||||
| 3 | **`config.json`** | Cấu hình **không bí mật** | `ctx.config.<nhóm>` |
|
||||
| 4 | **Hằng số trong mã nguồn** | ❌ Không bao giờ cho credential | — |
|
||||
|
||||
Bậc 4 là lỗi bị Gate A bắt, và tệ hơn: nó đi vào Git history vĩnh viễn.
|
||||
|
||||
## 2. `SecretStore` — interface, không phải hàm tiện ích
|
||||
|
||||
```python
|
||||
# infrastructure/secrets/secret_store.py
|
||||
@runtime_checkable
|
||||
class SecretStore(Protocol):
|
||||
def get(self, key: str) -> str | None: ... # thiếu key KHÔNG được ném lỗi
|
||||
def set(self, key: str, value: str) -> None: ...
|
||||
def delete(self, key: str) -> None: ... # không có sẵn thì im lặng
|
||||
def has(self, key: str) -> bool: ... # kiểm tra mà không đọc giá trị ra
|
||||
|
||||
def provider_key(name: str) -> str:
|
||||
return f"provider:{name}" # quy ước đặt key
|
||||
```
|
||||
|
||||
Lý do là Protocol chứ không phải hàm: bản thật gọi OS Keyring — chậm, có thể ném lỗi, và
|
||||
**test không được đụng keyring máy thật**. Có interface thì test tiêm `FakeSecretStore`.
|
||||
|
||||
Bản thật: `KeyringAdapter`, `SERVICE = "cowork-local"`, có property `available`.
|
||||
|
||||
**Luật khi thêm secret mới:**
|
||||
|
||||
- Đặt key theo quy ước có sẵn, không tự nghĩ kiểu mới. Chưa có quy ước cho loại của bạn →
|
||||
thêm một hàm `*_key()` cạnh `provider_key`, đừng rải chuỗi literal khắp nơi.
|
||||
- Màn Settings hiển thị trạng thái bằng `has()`, **không** bằng `get()`. Không đọc giá trị bí
|
||||
mật ra chỉ để vẽ dấu tích.
|
||||
- `KeyringAdapter.available` là False (Linux thiếu backend, CI) → phải có đường thoái lui
|
||||
không làm hỏng app.
|
||||
|
||||
## 3. Schema migration — cách đổi hình dạng config an toàn
|
||||
|
||||
```python
|
||||
# infrastructure/config/schema_migration.py
|
||||
CURRENT_VERSION = 2
|
||||
ASSUMED_VERSION = 1 # file thiếu schema_version ⇒ coi là 1
|
||||
STEPS = {1: _v1_to_v2} # mỗi bước v(n) → v(n+1), chạy tuần tự, không nhảy cóc
|
||||
```
|
||||
|
||||
Bốn luật đã chốt:
|
||||
|
||||
1. **Sao lưu trước khi nâng** — `backup()` tạo `config.json.v<timestamp>.bak`. Người dùng lùi
|
||||
về bản app cũ vẫn còn đường về.
|
||||
2. **Chỉ nâng, không hạ.** File mới hơn app → log cảnh báo, dùng nguyên trạng, không đoán ngược.
|
||||
3. **Mỗi bước là một hàm riêng** trong `STEPS`, không viết logic đoán mò kiểu
|
||||
"có khoá `office` nghĩa là file cũ".
|
||||
4. **Bước không nâng được version thì dừng**, không lặp vô hạn.
|
||||
|
||||
### Tiền lệ cần bắt chước: `_v1_to_v2`
|
||||
|
||||
Đây **chính là** bước đã gỡ `api_key` khỏi đĩa đẩy vào `SecretStore`. Đọc nó trước khi
|
||||
thiết kế bất kỳ migration credential nào:
|
||||
|
||||
```python
|
||||
def _v1_to_v2(data, secrets):
|
||||
if secrets is None or not getattr(secrets, "available", True):
|
||||
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
||||
return data # KHÔNG chuyển — thà để khoá nằm nguyên còn hơn
|
||||
# xoá đi rồi người dùng mất khoá không hiểu vì sao
|
||||
...
|
||||
secrets.set(provider_key(name), key)
|
||||
conf["api_key"] = ""
|
||||
out["schema_version"] = 2
|
||||
```
|
||||
|
||||
Hai quyết định đáng học:
|
||||
|
||||
- **Không có keyring thì không chuyển.** Giữ nguyên version 1, lần chạy sau trên máy có
|
||||
keyring sẽ chuyển. Mất dữ liệu người dùng tệ hơn là hoãn migration.
|
||||
- **Bỏ qua giá trị bù nhìn.** `api_key == "ollama"` là placeholder, đẩy vào keyring chỉ tổ rác.
|
||||
|
||||
## 4. ⚠️ Bẫy `.get(key, fallback)` trên config đã deep-merge
|
||||
|
||||
Đây là bẫy sinh ra cả một lớp lỗi, và nó **không hiển nhiên**.
|
||||
|
||||
```python
|
||||
# config.py:265
|
||||
def _deep_merge(base, override): ...
|
||||
|
||||
# infrastructure/config/json_config_repository.py:90
|
||||
merged = _deep_merge(merged, stored) # bắt đầu từ DEFAULT_CONFIG
|
||||
```
|
||||
|
||||
Config đưa tới UI **luôn** đã được deep-merge với `DEFAULT_CONFIG`. Nghĩa là:
|
||||
|
||||
> Mọi key có trong `DEFAULT_CONFIG` thì **luôn tồn tại** trong dict. Tham số thứ hai của
|
||||
> `.get()` **không bao giờ chạy**.
|
||||
|
||||
```python
|
||||
# DEFAULT_CONFIG có "sandbox_pw": ""
|
||||
sec.get("sandbox_pw", "<literal đã bị gỡ>") # → "" , KHÔNG phải "<literal đã bị gỡ>"
|
||||
```
|
||||
|
||||
Hệ quả:
|
||||
|
||||
- Fallback trông như "mặc định an toàn" thực ra là **code chết**.
|
||||
- Giá trị thật sự đang chạy là giá trị trong `DEFAULT_CONFIG` — thường là `""`.
|
||||
- Chuỗi rỗng đem đi so sánh mật khẩu là **mở khoá cho input rỗng**.
|
||||
|
||||
**Luật:** đọc credential từ config thì **không** dùng fallback trong `.get()`. Đọc giá trị
|
||||
thật, rồi xử lý tường minh trường hợp rỗng — xem §9 về cách so sánh.
|
||||
|
||||
## 5. Ghi đè bằng biến môi trường
|
||||
|
||||
`config.py::_apply_env_overrides` (dòng 276) — các biến hiện có:
|
||||
|
||||
| Biến | Ghi vào |
|
||||
|---|---|
|
||||
| `COWORK_SANDBOX_PASSWORD` | `agent_security.sandbox_pw` |
|
||||
| `COWORK_MS365_UNLOCK_CODE` | `ms365.unlock_code` |
|
||||
| `COWORK_TEAMS_WEBHOOK` | `teams.webhook_url` |
|
||||
| `COWORK_ACTIVE_PROVIDER` | `active_provider` |
|
||||
| `COWORK_CA_BUNDLE` | `tls_ca_bundle` |
|
||||
|
||||
Env override chạy **sau** deep-merge, nên nó thắng cả default lẫn file. Thêm secret mới thì
|
||||
cân nhắc có cần đường env cho triển khai theo tổ chức không.
|
||||
|
||||
## 6. Sinh giá trị ngẫu nhiên — dùng lại thứ có sẵn
|
||||
|
||||
```python
|
||||
# core/accounts.py:89
|
||||
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # bỏ I, L, O, 0, 1 dễ đọc nhầm
|
||||
CODE_LENGTH = 12
|
||||
|
||||
def generate_code(existing_codes=None) -> str:
|
||||
"""A random, non-repeating 12-character access code."""
|
||||
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(CODE_LENGTH))
|
||||
```
|
||||
|
||||
Dùng `secrets`, **không** `random`. Bảng chữ đã loại ký tự dễ nhầm vì mã này được người
|
||||
đọc bằng mắt rồi gõ lại. Cần mã cho người dùng đọc → gọi lại hàm này, đừng viết bản thứ hai.
|
||||
|
||||
Không cần người đọc (token nội bộ) → `secrets.token_urlsafe(32)`.
|
||||
|
||||
## 7. Gate A và Git history
|
||||
|
||||
```bash
|
||||
python scripts/audit_security.py
|
||||
```
|
||||
|
||||
Quét file `.py` và file config. Hiện có 3 phát hiện **có sẵn** trong
|
||||
`tests/test_project_context_*.py` — đừng nhận nhầm là do bản vá của mình.
|
||||
|
||||
**Nếu secret đã nằm trong Git history** (`SECURITY.md`):
|
||||
|
||||
1. Dừng phân phối.
|
||||
2. Báo Cowork Team.
|
||||
3. **Không** rewrite history, **không** force-push nếu chưa có kế hoạch khắc phục phối hợp.
|
||||
4. Xoay (rotate) credential có thể đã lộ.
|
||||
|
||||
Gỡ literal khỏi code ở commit hôm nay **không** gỡ nó khỏi lịch sử. Luôn nêu điều này trong plan.
|
||||
|
||||
## 8. Câu hỏi phải hỏi người, không được tự quyết
|
||||
|
||||
`docs/governance/review-policy.md`: thay đổi chạm credential cần Cowork Team soi thêm, và
|
||||
**CI xanh không đủ để merge**. Bốn câu sau là quyết định sản phẩm/bảo mật, agent chỉ được đề xuất:
|
||||
|
||||
1. Đây là **khoá chống bấm nhầm** hay **cơ chế bảo mật thật**? (quyết định mức đầu tư)
|
||||
2. Lưu plaintext trong Keyring, hay lưu **hash** để cả admin cũng không đọc được?
|
||||
3. Người dùng hiện có sẽ ra sao — giữ mật khẩu cũ, hay bị buộc đặt lại?
|
||||
4. Giá trị sinh ra hiển thị cho người dùng thế nào, và hiện **mấy lần**?
|
||||
|
||||
---
|
||||
|
||||
## 9. So sánh credential — hai bẫy đi liền nhau
|
||||
|
||||
Ghi lại từ defect `SEC-20260907-01`. Cả hai đều là bug **thật** đã xảy ra trong repo này.
|
||||
|
||||
### 9.1 Chuỗi rỗng phải bị chặn TRƯỚC khi so sánh
|
||||
|
||||
`DEFAULT_CONFIG` cho credential thường là `""`, và §4 giải thích vì sao giá trị đó luôn
|
||||
đến tay chỗ dùng. Nên `entered == stored` biến ô nhập trống thành mật khẩu hợp lệ.
|
||||
|
||||
Mẫu đúng đã có sẵn trong repo — `infrastructure/config/json_config_repository.py`:
|
||||
|
||||
```python
|
||||
if (code or "") and code == self.ms365.get("unlock_code", ""):
|
||||
```
|
||||
|
||||
`(code or "") and ...` là chốt chặn. Bên sandbox thiếu đúng chốt này và thành lỗ hổng S1.
|
||||
|
||||
### 9.2 ⚠️ `secrets.compare_digest` KHÔNG nhận `str` ngoài ASCII
|
||||
|
||||
Đổi `==` sang `compare_digest` là nâng cấp đúng hướng (timing-safe), nhưng nó mang theo
|
||||
một ràng buộc mới mà `==` không có:
|
||||
|
||||
```python
|
||||
>>> secrets.compare_digest("mật khẩu", "mật khẩu")
|
||||
TypeError: comparing strings with non-ASCII characters is not supported
|
||||
```
|
||||
|
||||
Cowork Local mặc định **tiếng Việt** và phục vụ **khách Nhật**. Mật khẩu có dấu ở đây là
|
||||
input bình thường, không phải trường hợp biên. Để nguyên là exception thoát ra khỏi Qt slot.
|
||||
|
||||
**Luật:** so sánh trên bytes.
|
||||
|
||||
```python
|
||||
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
|
||||
```
|
||||
|
||||
### 9.3 Bài học tổng quát — quan trọng hơn hai mục trên
|
||||
|
||||
> Một API "an toàn hơn" thường có **miền đầu vào hẹp hơn** thứ nó thay thế.
|
||||
|
||||
`compare_digest` an toàn hơn `==` về timing, nhưng chỉ nhận ASCII-`str` hoặc bytes.
|
||||
Trước khi thay một phép toán bằng phiên bản "chuẩn bảo mật", luôn hỏi:
|
||||
|
||||
- [ ] Nó nhận những kiểu nào? Có hẹp hơn cái cũ không?
|
||||
- [ ] Dữ liệu thật của app có nằm trọn trong miền đó không? (ngôn ngữ, độ dài, `None`)
|
||||
- [ ] Nó ném exception hay trả `False` khi gặp đầu vào ngoài miền?
|
||||
- [ ] Có test cho đúng đầu vào ngoài miền đó chưa?
|
||||
|
||||
Ba dòng đầu của checklist này chính là thứ đã bị bỏ qua ở `SEC-20260907-01`, và nó lọt
|
||||
qua vòng review đầu tiên.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Theme & Design Tokens — luật màu sắc của Cowork Local
|
||||
|
||||
Nguồn: docstring đầu `theme/__init__.py`, `theme/palettes.py`, `theme/qss.py`,
|
||||
`theme/qss_controls.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Luật gốc
|
||||
|
||||
> **Không file nào ngoài `theme/` được đặt tên một màu.**
|
||||
|
||||
Cơ chế duy nhất:
|
||||
|
||||
```text
|
||||
Palette (token ngữ nghĩa) → _TEMPLATE (một QSS duy nhất) → stylesheet(theme)
|
||||
```
|
||||
|
||||
Hai cách hợp lệ để một widget có màu:
|
||||
|
||||
1. **Khai báo** — gán `objectName` cho widget, style nó trong `_TEMPLATE`
|
||||
(`theme/qss.py`). Đây là cách mặc định.
|
||||
2. **Vẽ tay** — widget vẽ bằng `QPainter` (chart, canvas, syntax highlighter) thì gọi
|
||||
`current_palette()` rồi đọc token.
|
||||
|
||||
Cách **không** hợp lệ, bị reject review:
|
||||
|
||||
```python
|
||||
self.label.setStyleSheet("color: #dc2626;") # ❌ hex ngoài theme/
|
||||
pen.setColor(QColor("red")) # ❌ tên màu literal
|
||||
self.card.setStyleSheet("background: rgba(0,0,0,.1)") # ❌
|
||||
```
|
||||
|
||||
## 2. API cần nhớ
|
||||
|
||||
| Hàm | Dùng khi |
|
||||
|---|---|
|
||||
| `theme.stylesheet(theme)` | Sinh QSS toàn app, truyền vào `QApplication.setStyleSheet` |
|
||||
| `theme.set_active_theme(theme)` | **Phải** gọi ngay cạnh mỗi `setStyleSheet(stylesheet(...))` |
|
||||
| `theme.current_theme()` | `'dark'` / `'light'` đang hiển thị |
|
||||
| `theme.current_palette()` | Token của theme đang hiển thị — dùng trong `paintEvent` |
|
||||
| `theme.palette(theme)` | Token của một theme cụ thể |
|
||||
| `theme.resolve_theme('system')` | Suy ra dark/light từ color scheme của OS |
|
||||
| `theme.role_colors(theme)` | Màu theo vai trò hội thoại: user/assistant/tool/result/error |
|
||||
|
||||
`current_palette()` tồn tại để code vẽ **không** phải đọc lại `config.json` mỗi lần
|
||||
repaint — đó từng là bug hiệu năng thật. Không thay bằng đọc config.
|
||||
|
||||
## 3. Nhóm token
|
||||
|
||||
Palette là `@dataclass(frozen=True)`. Các nhóm chính:
|
||||
|
||||
| Nhóm | Token | Ý nghĩa |
|
||||
|---|---|---|
|
||||
| Bề mặt (thang 4 bậc) | `bg` | nền cửa sổ / canvas |
|
||||
| | `surface` | panel, card, group box (**không** phải nav rail) |
|
||||
| | `surface_raised` | input, list, tree — thứ người dùng gõ/chọn |
|
||||
| | `overlay` | menu, tooltip, popup |
|
||||
| | `sunken` | log, code, terminal — thứ để đọc vào |
|
||||
| | `hover` / `active` | trạng thái hover / đang bấm |
|
||||
| Chữ | `text`, `text_muted`, ... | |
|
||||
| Nhấn | `accent`, `accent_solid` | **Hai token khác nhau có chủ đích**: màu đọc được *dạng chữ* trên nền tối thì quá nhạt để làm *nền* cho chữ trắng |
|
||||
| Trạng thái | `danger`, ... | |
|
||||
| Vai trò hội thoại | `role_user`, `role_assistant`, `role_tool`, `role_result`, `role_error` | |
|
||||
| Code | `code_string`, ... | syntax highlighting |
|
||||
|
||||
Token là **ngữ nghĩa**, không phải literal: `danger` / `text_muted` — không bao giờ
|
||||
`blue` / `grey2`. Thêm một theme = thêm một `Palette`, không phải sửa stylesheet.
|
||||
|
||||
## 4. Ràng buộc thiết kế (đừng "sửa" nhầm thành bug)
|
||||
|
||||
- **Không gradient, không glow.** Bảng màu lấy từ VS Code "Dark Modern" / "Light Modern".
|
||||
Bề mặt phẳng, góc gần vuông, một màu accent chỉ dành cho thứ người dùng thao tác.
|
||||
- **Chiều sâu đến từ thang bề mặt và viền mảnh**, không từ màu.
|
||||
- **Silhouette VS Code:** nav rail **tối hơn** vùng nội dung, không sáng hơn.
|
||||
Người dùng báo "menu trái tối quá" — đó là thiết kế, không phải bug. Xem `examples/bad_fix.md`.
|
||||
- **Contrast giữ ở WCAG AA (4.5:1)** cho body text và cho chữ trên nút đặc.
|
||||
- Bốn giá trị của VS Code không đạt AA đã được nhích lên vừa đủ (số dòng dark 3.59:1,
|
||||
chữ mờ trên sidebar sáng 4.28:1, xanh lá sáng 4.33:1, hổ phách sáng 3.12:1). Mỗi chỗ có
|
||||
comment ghi giá trị gốc — **không** trả chúng về giá trị VS Code.
|
||||
|
||||
## 5. Mũi tên combo box (`_chevron_asset`)
|
||||
|
||||
QSS `image:` chỉ nhận đường dẫn file/resource, không nhận `QPixmap`. Và một khi
|
||||
`::drop-down` / `::up-button` / `::down-button` bị style, Qt **ngừng vẽ mũi tên mặc định**.
|
||||
Vì vậy `theme/palettes.py::_chevron_asset` render sẵn PNG chevron ra thư mục tạm và cache
|
||||
theo hash `(direction, color)`.
|
||||
|
||||
Hệ quả khi debug:
|
||||
|
||||
- "Combo box mất mũi tên" → gần như luôn do một stylesheet cục bộ đè lên `::drop-down`.
|
||||
- File cache nằm ở `%TEMP%/cowork_local_theme/chevron_*.png`. Xoá nó để buộc render lại
|
||||
khi test màu mới.
|
||||
|
||||
## 6. Checklist sửa bug liên quan màu sắc
|
||||
|
||||
- [ ] Đã kiểm tra bug xuất hiện ở **cả** dark và light chưa? (`docs/screens/*-dark.png` / `*-light.png`)
|
||||
- [ ] Bản sửa dùng token, không dùng hex?
|
||||
- [ ] Nếu thêm token mới: đã thêm cho **cả** `DARK` và `LIGHT`?
|
||||
- [ ] Nếu là chữ trên nền đặc: đã dùng `accent_solid` thay vì `accent`?
|
||||
- [ ] Contrast còn ≥ 4.5:1?
|
||||
- [ ] Widget dựng sau khi đổi theme có nhận đúng stylesheet? (xem `qt_pitfalls.md` P07)
|
||||
Reference in New Issue
Block a user