Files
cowork-local/tools/extract_handwritten.py
T
NamPDTandClaude Opus 5 0fa61b6a95 feat(ui): flat nav rail, compact assistant, responsive layouts
Implements the redesign from docs/ui-audit.html. Rearrangement only — no
feature was removed; every control that moved kept its handler, and the
gates that used to hide things now grey them out instead.

Navigation
  * The rail is one flat list: the five Workspace sub-views sit at the top
    level instead of behind an accordion, with Dashboard/Monitoring pinned
    at the foot and Settings below them.
  * Cowork and GraphRAG stay listed and greyed while no project is
    selected, rather than vanishing and resizing the menu under the user.
  * Monitoring keeps its eight sub-views in its own tab strip (unhidden)
    instead of doubling the rail's length.
  * _goto now moves the highlight itself, fixing a long-standing bug where
    programmatic navigation left the rail pointing at the previous screen.
  * Rail header gained the project picker and "New chat"; RECENTS lists the
    active project's threads. Both are second views of existing state — the
    Cowork toolbar button and the full History panel are untouched.
  * Provider / language / theme moved from the top bar to an account row at
    the foot of the rail (same widgets, same signals).

Screens
  * Co4E: the flow tab strip is gone (per the design); Flow Status became a
    toolbar toggle with its own way back, and the three icon-only tabs became
    four labelled, foldable sections in one column. One flow open at a time
    is the one capability this costs; background runs are unaffected.
  * Dashboard: header split into two rows; cost promoted to a hero card.
  * Monitoring Overview: one scrolling column of titled sections; the model
    price table got its own full-width section instead of sharing a row with
    the CPU meters.
  * Settings and Task editor gained a section index down the left.
  * Help dock: 84x64 launcher + chevron became one 26px dot that expands to
    a labelled pill on hover; "hide to the edge" moved into the panel's menu.

Layout
  * The window's minimum width dropped from 1453px to 768px. The main cause
    was a QTabWidget taking its minimum from the widest page even when that
    page is hidden, so Co4E was forcing Project and Cowork wide.
  * Secondary panes fold themselves on a narrow window and restore when it
    grows, never overriding a fold the user made.
  * The long dialogs no longer scroll sideways at any font size.

Verification: tools/check_*.py build a real MainWindow offscreen against a
copy of ~/.cowork_local with the schedulers no-oped. check_design_parity.py
reads its checklist straight from the audit page's own proposals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:40:13 +09:00

158 lines
6.5 KiB
Python

"""Lift the hand-written audit sections out of 10-18.ui-audit.html.
That file was edited by hand: eight sections carry richer wireframes, prose and
interactive tables than the generator produces, plus the CSS and scripts they
need. Keeping two HTML files around means they drift, so this pulls the
hand-written parts into ``tools/audit_handwritten.py`` — a data module the
builder merges back in, making ``docs/ui-audit.html`` the single output again.
Two things are tokenised out before storing, so they stay generated rather than
frozen at extraction time:
{{SHOT}} the screenshot block (keeps ~8 MB of base64 out of the module)
{{CONTROLS}} the AST-derived control inventory (must track controls.json)
Workflow when you hand-edit one of those sections directly in the page:
1. edit docs/ui-audit.html
2. python tools/extract_handwritten.py (reads it back into the module)
3. python tools/build_audit_page.py (regenerates, edits preserved)
Pass another filename to import sections from a different copy.
"""
from __future__ import annotations
import contextlib
import io
import re
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DOCS = REPO / "docs"
OUT = REPO / "tools" / "audit_handwritten.py"
# Hand-written sections are DETECTED, not listed: every section whose text
# differs from what the generator alone would emit is stored.
#
# There used to be a fixed list here, and it cost a section — "Monitoring ▸ Công
# cụ" was hand-written but missing from the list, so each rebuild quietly put
# the generated version back. Diffing against the live output cannot work (it
# already contains the merged result and would find nothing), so the reference
# is a generator-only render produced in-process, with the merge disabled.
#
# These are the ones known so far; anything else detected is added on top.
KNOWN = [
"monitoring-sự-kiện-bảo-mật", "monitoring-lịch-sử-gọi-mcp",
"monitoring-nhật-ký-hành-động", "monitoring-trạng-thái-agent",
"monitoring-agents-admin", "monitoring-icon", "monitoring-công-cụ",
"dialog-settings", "dialog-task-editor",
]
SECTION = re.compile(r'<section class="sec" id="([^"]+)">(.*?)</section>', re.S)
BODY = re.compile(r'(<div class="bd">.*)', re.S)
SHOT = re.compile(r'<div class="shot">.*?</div>', re.S)
CONTROLS = re.compile(r'<details class="ctl">.*?</details>', re.S)
STYLE = re.compile(r"<style>(.*?)</style>", re.S)
SCRIPT = re.compile(r"<script>(.*?)</script>", re.S)
def bodies(html: str) -> dict[str, str]:
"""slug -> the section's <div class="bd"> … </div>, header excluded."""
out = {}
for m in SECTION.finditer(html):
b = BODY.search(m.group(2))
if b:
out[m.group(1)] = b.group(1).strip()
return out
def norm(s: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip()
def main(argv: list[str]) -> int:
src_path = DOCS / (argv[0] if argv else "ui-audit.html")
if not src_path.exists():
print(f"khong thay {src_path}")
return 1
src = src_path.read_text(encoding="utf-8")
# Screenshots are re-embedded by the builder; keep the base64 out of here.
hand = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>", src))
sys.path.insert(0, str(REPO / "tools"))
import build_audit_page as B
# Render what the generator ALONE would produce, into a temp file, and treat
# every section that differs from it as hand-written.
with tempfile.TemporaryDirectory() as tmp:
keep_out, keep_hand = B.OUT, B.HAND_SECTIONS
B.OUT, B.HAND_SECTIONS = Path(tmp) / "gen-only.html", {}
try:
with contextlib.redirect_stdout(io.StringIO()):
B.main()
made = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "<IMG>",
B.OUT.read_text(encoding="utf-8")))
finally:
B.OUT, B.HAND_SECTIONS = keep_out, keep_hand
detected = sorted(s for s, body in hand.items() if norm(body) != norm(made.get(s, "")))
slugs = [s for s in hand if s in set(detected) | set(KNOWN)]
new = [s for s in detected if s not in KNOWN]
gone = [s for s in KNOWN if s in hand and s not in detected]
if new:
print(f"phat hien them section viet tay: {new}")
if gone:
# Not an error: a hand section can be edited back to match the generator.
print(f"section trong KNOWN nay giong ban sinh: {gone}")
stored = {}
for slug in slugs:
body = hand[slug]
body = SHOT.sub("{{SHOT}}", body, count=1)
body = CONTROLS.sub("{{CONTROLS}}", body, count=1)
stored[slug] = body
# CSS rules and scripts the hand edits added. Compared against the builder's
# OWN constants, not its output — the output already carries the merge.
extra_css = "\n".join(
ln for ln in STYLE.search(src).group(1).splitlines()
if ln.strip() and ln not in B.CSS)
# Scripts already sitting INSIDE a stored section travel with it — collecting
# them again would bind every listener twice (the +/- steppers would then
# count by two). Only page-level scripts belong in EXTRA_JS.
gen_js = {norm(B.JS)}
in_section = "".join(stored.values())
extra_js = [j for j in SCRIPT.findall(src)
if norm(j) not in gen_js and j not in in_section]
parts = [
'"""Hand-written audit sections, extracted from 10-18.ui-audit.html.\n\n'
"GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the\n"
"source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and\n"
'{{CONTROLS}} so those stay generated.\n"""\n',
"SECTIONS = {",
]
for slug, body in stored.items():
parts.append(f" {slug!r}: {body!r},")
parts.append("}\n")
parts.append(f"EXTRA_CSS = {extra_css!r}\n")
parts.append("EXTRA_JS = [")
for j in extra_js:
parts.append(f" {j!r},")
parts.append("]\n")
OUT.write_text("\n".join(parts), encoding="utf-8")
print(f"section viet tay : {len(stored)}")
for slug, body in stored.items():
print(f" {slug:32} {len(body):>7,} ky tu"
f" shot={'{{SHOT}}' in body} ctl={'{{CONTROLS}}' in body}")
print(f"CSS them : {len(extra_css.splitlines())} dong")
print(f"script them : {len(extra_js)}")
print(f"ghi -> {OUT.relative_to(REPO)} ({OUT.stat().st_size / 1024:.0f} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))